Merge pull request #960 from ae-utbm/taiste

User model migration, better product types ordering and subscription page fix
This commit is contained in:
thomas girod 2024-12-21 02:40:20 +01:00 committed by GitHub
commit b773a05bb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 1432 additions and 863 deletions

View File

@ -216,7 +216,7 @@ class TestOperation(TestCase):
self.journal.operations.filter(target_label="Le fantome du jour").exists() 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() sat = SimplifiedAccountingType.objects.all().first()
response = self.client.post( response = self.client.post(
reverse("accounting:op_new", args=[self.journal.id]), reverse("accounting:op_new", args=[self.journal.id]),
@ -237,15 +237,14 @@ class TestOperation(TestCase):
"done": False, "done": False,
}, },
) )
self.assertFalse(response.status_code == 403) assert response.status_code != 403
self.assertTrue(self.journal.operations.filter(amount=23).exists()) assert self.journal.operations.filter(amount=23).exists()
response_get = self.client.get( response_get = self.client.get(
reverse("accounting:journal_details", args=[self.journal.id]) reverse("accounting:journal_details", args=[self.journal.id])
) )
self.assertTrue( assert "<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
"<td>Le fantome de l&#39;aurore</td>" in str(response_get.content)
) assert (
self.assertTrue(
self.journal.operations.filter(amount=23) self.journal.operations.filter(amount=23)
.values("accounting_type") .values("accounting_type")
.first()["accounting_type"] .first()["accounting_type"]

View File

@ -215,17 +215,14 @@ class JournalTabsMixin(TabedViewMixin):
return _("Journal") return _("Journal")
def get_list_of_tabs(self): def get_list_of_tabs(self):
tab_list = [] return [
tab_list.append(
{ {
"url": reverse( "url": reverse(
"accounting:journal_details", kwargs={"j_id": self.object.id} "accounting:journal_details", kwargs={"j_id": self.object.id}
), ),
"slug": "journal", "slug": "journal",
"name": _("Journal"), "name": _("Journal"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"accounting:journal_nature_statement", "accounting:journal_nature_statement",
@ -233,9 +230,7 @@ class JournalTabsMixin(TabedViewMixin):
), ),
"slug": "nature_statement", "slug": "nature_statement",
"name": _("Statement by nature"), "name": _("Statement by nature"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"accounting:journal_person_statement", "accounting:journal_person_statement",
@ -243,9 +238,7 @@ class JournalTabsMixin(TabedViewMixin):
), ),
"slug": "person_statement", "slug": "person_statement",
"name": _("Statement by person"), "name": _("Statement by person"),
} },
)
tab_list.append(
{ {
"url": reverse( "url": reverse(
"accounting:journal_accounting_statement", "accounting:journal_accounting_statement",
@ -253,9 +246,8 @@ class JournalTabsMixin(TabedViewMixin):
), ),
"slug": "accounting_statement", "slug": "accounting_statement",
"name": _("Accounting statement"), "name": _("Accounting statement"),
} },
) ]
return tab_list
class JournalCreateView(CanCreateMixin, CreateView): class JournalCreateView(CanCreateMixin, CreateView):

View File

@ -3,19 +3,6 @@ from __future__ import unicode_literals
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models 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): class Migration(migrations.Migration):
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")] dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
@ -48,11 +35,4 @@ class Migration(migrations.Migration):
null=True, 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"
),
] ]

View File

@ -31,14 +31,14 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ 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. # Create your models here.
@ -438,19 +438,18 @@ class Mailing(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.is_moderated: if not self.is_moderated:
for user in ( unread_notif_subquery = Notification.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
.first() )
.users.all() for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
): ):
if not user.notifications.filter( Notification(
type="MAILING_MODERATION", viewed=False user=user,
).exists(): url=reverse("com:mailing_admin"),
Notification( type="MAILING_MODERATION",
user=user, ).save(*args, **kwargs)
url=reverse("com:mailing_admin"),
type="MAILING_MODERATION",
).save(*args, **kwargs)
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self): def clean(self):

View File

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

View File

@ -34,7 +34,7 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club from club.models import Club
from core.models import Notification, Preferences, RealGroup, User from core.models import Notification, Preferences, User
class Sith(models.Model): class Sith(models.Model):
@ -62,16 +62,31 @@ NEWS_TYPES = [
class News(models.Model): class News(models.Model):
"""The news class.""" """News about club events."""
title = models.CharField(_("title"), max_length=64) title = models.CharField(_("title"), max_length=64)
summary = models.TextField(_("summary")) summary = models.TextField(
content = models.TextField(_("content")) _("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 = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT" _("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
) )
club = models.ForeignKey( 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( author = models.ForeignKey(
User, User,
@ -85,7 +100,7 @@ class News(models.Model):
related_name="moderated_news", related_name="moderated_news",
verbose_name=_("moderator"), verbose_name=_("moderator"),
null=True, null=True,
on_delete=models.CASCADE, on_delete=models.SET_NULL,
) )
def __str__(self): def __str__(self):
@ -93,17 +108,15 @@ class News(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
for u in ( for user in User.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
.first()
.users.all()
): ):
Notification( Notification.objects.create(
user=u, user=user,
url=reverse("com:news_admin_list"), url=reverse("com:news_admin_list"),
type="NEWS_MODERATION", type="NEWS_MODERATION",
param="1", param="1",
).save() )
def get_absolute_url(self): def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id}) return reverse("com:news_detail", kwargs={"news_id": self.id})
@ -321,16 +334,14 @@ class Poster(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.is_moderated: if not self.is_moderated:
for u in ( for user in User.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
.first()
.users.all()
): ):
Notification( Notification.objects.create(
user=u, user=user,
url=reverse("com:poster_moderate_list"), url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION", type="POSTER_MODERATION",
).save() )
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):

View File

@ -34,43 +34,90 @@
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors() }} {{ form.non_field_errors() }}
{{ form.author }} {{ form.author }}
<p>{{ form.type.errors }}<label for="{{ form.type.name }}">{{ form.type.label }}</label> <p>
{{ form.type.errors }}
<label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
<ul> <ul>
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li> <li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li> <li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
<li>{% trans %}Weekly: recurrent event, associated with many dates (specify the first one, and a deadline){% endtrans %}</li> <li>
<li>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li> {% trans trimmed%}
Weekly: recurrent event, associated with many dates
(specify the first one, and a deadline)
{% endtrans %}
</li>
<li>
{% trans trimmed %}
Call: long time event, associated with a long date (like election appliance)
{% endtrans %}
</li>
</ul> </ul>
{{ form.type }}</p> {{ form.type }}
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</p> </p>
<p class="date">{{ form.end_date.errors }}<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label> {{ form.end_date }}</p> <p class="date">
<p class="until">{{ form.until.errors }}<label for="{{ form.until.name }}">{{ form.until.label }}</label> {{ form.until }}</p> {{ form.start_date.errors }}
<p>{{ form.title.errors }}<label for="{{ form.title.name }}">{{ form.title.label }}</label> {{ form.title }}</p> <label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p> {{ form.start_date }}
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p> </p>
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p> <p class="date">
{{ form.end_date.errors }}
<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
{{ form.end_date }}
</p>
<p class="until">
{{ form.until.errors }}
<label for="{{ form.until.name }}">{{ form.until.label }}</label>
{{ form.until }}
</p>
<p>
{{ form.title.errors }}
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
{{ form.title }}
</p>
<p>
{{ form.club.errors }}
<label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label>
<span class="helptext">{{ form.club.help_text }}</span>
{{ form.club }}
</p>
<p>
{{ form.summary.errors }}
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
<span class="helptext">{{ form.summary.help_text }}</span>
{{ form.summary }}
</p>
<p>
{{ form.content.errors }}
<label for="{{ form.content.name }}">{{ form.content.label }}</label>
<span class="helptext">{{ form.content.help_text }}</span>
{{ form.content }}
</p>
{% if user.is_com_admin %} {% if user.is_com_admin %}
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label> <p>
{{ form.automoderation }}</p> {{ form.automoderation.errors }}
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}
</p>
{% endif %} {% endif %}
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p> <p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
</form> </form>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
$( function() { $(function () {
var type = $('input[name=type]'); let type = $('input[name=type]');
var dates = $('.date'); let dates = $('.date');
var until = $('.until'); let until = $('.until');
function update_targets () {
type_checked = $('input[name=type]:checked'); function update_targets() {
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") { const type_checked = $('input[name=type]:checked');
if (["CALL", "EVENT"].includes(type_checked.val())) {
dates.show(); dates.show();
until.hide(); until.hide();
} else if (type_checked.val() == "WEEKLY") { } else if (type_checked.val() === "WEEKLY") {
dates.show(); dates.show();
until.show(); until.show();
} else { } else {
@ -78,9 +125,10 @@
until.hide(); until.hide();
} }
} }
update_targets(); update_targets();
type.change(update_targets); type.change(update_targets);
} ); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -23,7 +23,7 @@ from django.utils.translation import gettext as _
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle 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() @pytest.fixture()
@ -49,9 +49,7 @@ class TestCom(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.skia = User.objects.get(username="skia") cls.skia = User.objects.get(username="skia")
cls.com_group = RealGroup.objects.filter( cls.com_group = Group.objects.get(id=settings.SITH_GROUP_COM_ADMIN_ID)
id=settings.SITH_GROUP_COM_ADMIN_ID
).first()
cls.skia.groups.set([cls.com_group]) cls.skia.groups.set([cls.com_group])
def setUp(self): def setUp(self):

View File

@ -28,7 +28,7 @@ from smtplib import SMTPRecipientsRefused
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError 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.forms.models import modelform_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect 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 club.models import Club, Mailing
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle 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 ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
@ -223,15 +223,13 @@ class NewsForm(forms.ModelForm):
): ):
self.add_error( self.add_error(
"end_date", "end_date",
ValidationError( ValidationError(_("An event cannot end before its beginning.")),
_("You crazy? You can not finish an event before starting it.")
),
) )
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]: if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required."))) self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data return self.cleaned_data
def save(self): def save(self, *args, **kwargs):
ret = super().save() ret = super().save()
self.instance.dates.all().delete() self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL": if self.instance.type == "EVENT" or self.instance.type == "CALL":
@ -280,21 +278,18 @@ class NewsEditView(CanEditMixin, UpdateView):
else: else:
self.object.is_moderated = False self.object.is_moderated = False
self.object.save() self.object.save()
for u in ( unread_notif_subquery = Notification.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
.first() )
.users.all() for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
): ):
if not u.notifications.filter( Notification.objects.create(
type="NEWS_MODERATION", viewed=False user=user,
).exists(): url=self.object.get_absolute_url(),
Notification( type="NEWS_MODERATION",
user=u, )
url=reverse(
"com:news_detail", kwargs={"news_id": self.object.id}
),
type="NEWS_MODERATION",
).save()
return super().form_valid(form) return super().form_valid(form)
@ -325,19 +320,18 @@ class NewsCreateView(CanCreateMixin, CreateView):
self.object.is_moderated = True self.object.is_moderated = True
self.object.save() self.object.save()
else: else:
for u in ( unread_notif_subquery = Notification.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
.first() )
.users.all() for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
): ):
if not u.notifications.filter( Notification.objects.create(
type="NEWS_MODERATION", viewed=False user=user,
).exists(): url=reverse("com:news_admin_list"),
Notification( type="NEWS_MODERATION",
user=u, )
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
).save()
return super().form_valid(form) return super().form_valid(form)

View File

@ -261,19 +261,19 @@ class Command(BaseCommand):
User.groups.through.objects.bulk_create( User.groups.through.objects.bulk_create(
[ [
User.groups.through( 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( 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( 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( 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( User.groups.through(
realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
), ),
] ]
) )

View File

@ -11,7 +11,7 @@ from django.utils.timezone import localdate, make_aware, now
from faker import Faker from faker import Faker
from club.models import Club, Membership from club.models import Club, Membership
from core.models import RealGroup, User from core.models import Group, User
from counter.models import ( from counter.models import (
Counter, Counter,
Customer, Customer,
@ -225,9 +225,7 @@ class Command(BaseCommand):
ae = Club.objects.get(unix_name="ae") ae = Club.objects.get(unix_name="ae")
other_clubs = random.sample(list(Club.objects.all()), k=3) other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list( groups = list(
RealGroup.objects.filter( Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
name__in=["Subscribers", "Old subscribers", "Public"]
)
) )
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

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

View File

@ -30,19 +30,13 @@ import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from pathlib import Path 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.conf import settings
from django.contrib.auth.models import AbstractBaseUser, UserManager from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.models import ( from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
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 (
Group as AuthGroup,
)
from django.contrib.auth.models import (
GroupManager as AuthGroupManager,
)
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators from django.core import validators
from django.core.cache import cache from django.core.cache import cache
@ -242,7 +236,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
pass pass
class User(AbstractBaseUser): class User(AbstractUser):
"""Defines the base user class, useable in every app. """Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it, 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 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) first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64) last_name = models.CharField(_("last name"), max_length=64)
email = models.EmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=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) 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) last_update = models.DateTimeField(_("last update"), auto_now=True)
is_superuser = models.BooleanField( groups = models.ManyToManyField(
_("superuser"), Group,
default=False, verbose_name=_("groups"),
help_text=_("Designates whether this user is a superuser. "), 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( home = models.OneToOneField(
"SithFile", "SithFile",
related_name="home_of", related_name="home_of",
@ -401,8 +366,6 @@ class User(AbstractBaseUser):
objects = CustomUserManager() objects = CustomUserManager()
USERNAME_FIELD = "username"
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
@ -422,22 +385,23 @@ class User(AbstractBaseUser):
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png" settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists() ).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 @cached_property
def was_subscribed(self) -> bool: 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() return self.subscriptions.exists()
@cached_property @cached_property
def is_subscribed(self) -> bool: 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() subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
) ).exists()
return s.exists()
@cached_property @cached_property
def account_balance(self): def account_balance(self):
@ -530,10 +494,8 @@ class User(AbstractBaseUser):
@cached_property @cached_property
def can_create_subscription(self) -> bool: def can_create_subscription(self) -> bool:
from club.models import Membership return self.is_root or (
self.memberships.board()
return (
Membership.objects.board()
.ongoing() .ongoing()
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS) .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists() .exists()
@ -601,11 +563,6 @@ class User(AbstractBaseUser):
"date_of_birth": self.date_of_birth, "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): def get_short_name(self):
"""Returns the short name for the user.""" """Returns the short name for the user."""
if self.nick_name: if self.nick_name:
@ -984,13 +941,11 @@ class SithFile(models.Model):
if copy_rights: if copy_rights:
self.copy_rights() self.copy_rights()
if self.is_in_sas: if self.is_in_sas:
for u in ( for user in User.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID) groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
.first()
.users.all()
): ):
Notification( Notification(
user=u, user=user,
url=reverse("sas:moderation"), url=reverse("sas:moderation"),
type="SAS_MODERATION", type="SAS_MODERATION",
param="1", param="1",

View File

@ -1,5 +1,7 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
Alpine.plugin(sort);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

@ -67,6 +67,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
remove_button: { remove_button: {
title: gettext("Remove"), title: gettext("Remove"),
}, },
// biome-ignore lint/style/useNamingConvention: this is required by the api
restore_on_backspace: {},
}, },
persist: false, persist: false,
maxItems: this.node.multiple ? this.max : 1, maxItems: this.node.multiple ? this.max : 1,

View File

@ -87,3 +87,38 @@ a:not(.button) {
color: $primary-color; 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;
}
}

View File

@ -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 { .tabs {
border-radius: 5px; 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 { .ui-dialog .ui-dialog-buttonpane {
bottom: 0; bottom: 0;

View File

@ -3,17 +3,18 @@
{% macro page_history(page) %} {% macro page_history(page) %}
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p> <p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
<ul> <ul>
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %} {% set page_name = page.get_full_name() %}
{% if loop.index < 2 %} {%- for rev in page.revisions.order_by("-date").select_related("author") -%}
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> - <li>
{{ user_profile_link(page.revisions.last().author) }} - {% if loop.first %}
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li> <a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
{% else %} {% else %}
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> - <a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
{{ user_profile_link(r.author) }} - {% endif %}
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li> {{ user_profile_link(rev.author) }} -
{% endif %} {{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
{% endfor %} </li>
{%- endfor -%}
</ul> </ul>
{% endmacro %} {% endmacro %}

View File

@ -52,7 +52,7 @@
%} %}
<li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li> <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
<li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li> <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
<li><a href="{{ url('counter:producttype_list') }}">{% trans %}Product types management{% endtrans %}</a></li> <li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
<li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li> <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li> <li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li> <li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>

View File

@ -118,7 +118,9 @@ class TestUserRegistration:
response = client.post(reverse("core:register"), valid_payload) response = client.post(reverse("core:register"), valid_payload)
assert response.status_code == 200 assert response.status_code == 200
error_html = "<li>Un objet User avec ce champ Adresse email existe déjà.</li>" error_html = (
"<li>Un objet Utilisateur avec ce champ Adresse email existe déjà.</li>"
)
assertInHTML(error_html, str(response.content.decode())) assertInHTML(error_html, str(response.content.decode()))
def test_register_fail_with_not_existing_email( def test_register_fail_with_not_existing_email(

View File

@ -14,7 +14,7 @@ from PIL import Image
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user 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 sas.models import Picture
from sith import settings from sith import settings
@ -26,12 +26,10 @@ class TestImageAccess:
[ [
lambda: baker.make(User, is_superuser=True), lambda: baker.make(User, is_superuser=True),
lambda: baker.make( lambda: baker.make(
User, User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
), ),
lambda: baker.make( lambda: baker.make(
User, User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
), ),
], ],
) )

View File

@ -21,6 +21,7 @@ from wsgiref.util import FileWrapper
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect 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.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView 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 ( from core.views import (
AllowFragment, AllowFragment,
CanEditMixin, CanEditMixin,
@ -159,19 +160,18 @@ class AddFilesForm(forms.Form):
% {"file_name": f, "msg": repr(e)}, % {"file_name": f, "msg": repr(e)},
) )
if notif: if notif:
for u in ( unread_notif_subquery = Notification.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID) user=OuterRef("pk"), type="FILE_MODERATION", viewed=False
.first() )
.users.all() for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
): ):
if not u.notifications.filter( Notification.objects.create(
type="FILE_MODERATION", viewed=False user=user,
).exists(): url=reverse("core:file_moderation"),
Notification( type="FILE_MODERATION",
user=u, )
url=reverse("core:file_moderation"),
type="FILE_MODERATION",
).save()
class FileListView(ListView): class FileListView(ListView):

View File

@ -44,7 +44,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField 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.utils import resize_image
from core.views.widgets.select import ( from core.views.widgets.select import (
AutoCompleteSelect, AutoCompleteSelect,
@ -167,9 +167,7 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email") fields = ("first_name", "last_name", "email")
field_classes = { field_classes = {"email": AntiSpamEmailField}
"email": AntiSpamEmailField,
}
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
@ -287,15 +285,19 @@ class UserProfileForm(forms.ModelForm):
self._post_clean() self._post_clean()
class UserPropForm(forms.ModelForm): class UserGroupsForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
groups = forms.ModelMultipleChoiceField(
queryset=RealGroup.objects.all(),
widget=CheckboxSelectMultiple,
label=_("Groups"),
)
class Meta: class Meta:
model = User model = User
fields = ["groups"] fields = ["groups"]
help_texts = {"groups": "Which groups this user belongs to"}
widgets = {"groups": CheckboxSelectMultiple}
class UserGodfathersForm(forms.Form): class UserGodfathersForm(forms.Form):

View File

@ -23,9 +23,7 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User from core.models import RealGroup, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import ( from core.views.widgets.select import AutoCompleteSelectMultipleUser
AutoCompleteSelectMultipleUser,
)
# Forms # Forms

View File

@ -35,7 +35,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet from django.db.models import DateField, QuerySet
from django.db.models.functions import Trunc from django.db.models.functions import Trunc
from django.forms import CheckboxSelectMultiple
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@ -68,6 +67,7 @@ from core.views.forms import (
LoginForm, LoginForm,
RegisteringForm, RegisteringForm,
UserGodfathersForm, UserGodfathersForm,
UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
@ -583,9 +583,7 @@ class UserUpdateGroupView(UserTabsMixin, CanEditPropMixin, UpdateView):
model = User model = User
pk_url_kwarg = "user_id" pk_url_kwarg = "user_id"
template_name = "core/user_group.jinja" template_name = "core/user_group.jinja"
form_class = modelform_factory( form_class = UserGroupsForm
User, fields=["groups"], widgets={"groups": CheckboxSelectMultiple}
)
context_object_name = "profile" context_object_name = "profile"
current_tab = "groups" current_tab = "groups"

View File

@ -129,7 +129,7 @@ class PermanencyAdmin(SearchModelAdmin):
@admin.register(ProductType) @admin.register(ProductType)
class ProductTypeAdmin(admin.ModelAdmin): class ProductTypeAdmin(admin.ModelAdmin):
list_display = ("name", "priority") list_display = ("name", "order")
@admin.register(CashRegisterSummary) @admin.register(CashRegisterSummary)

View File

@ -12,24 +12,33 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import Annotated from django.conf import settings
from django.db.models import F
from annotated_types import MinLen from django.shortcuts import get_object_or_404
from django.db.models import Q
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product from counter.models import Counter, Product, ProductType
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,
CounterSchema, CounterSchema,
ProductFilterSchema,
ProductSchema, ProductSchema,
ProductTypeSchema,
ReorderProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema, SimplifiedCounterSchema,
) )
IsCounterAdmin = (
IsRoot
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
)
@api_controller("/counter") @api_controller("/counter")
class CounterController(ControllerBase): class CounterController(ControllerBase):
@ -64,15 +73,72 @@ class CounterController(ControllerBase):
class ProductController(ControllerBase): class ProductController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[ProductSchema], response=PaginatedResponseSchema[SimpleProductSchema],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_products(self, search: Annotated[str, MinLen(1)]): def search_products(self, filters: Query[ProductFilterSchema]):
return ( return filters.filter(
Product.objects.filter( Product.objects.order_by(
Q(name__icontains=search) | Q(code__icontains=search) F("product_type__order").asc(nulls_last=True),
) "product_type",
.filter(archived=False) "name",
.values() ).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)

View File

@ -1,38 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models 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): class Migration(migrations.Migration):
@ -44,5 +12,4 @@ class Migration(migrations.Migration):
name="recorded_products", name="recorded_products",
field=models.IntegerField(verbose_name="recorded items", default=0), field=models.IntegerField(verbose_name="recorded items", default=0),
), ),
migrations.RunPython(balance_ecocups),
] ]

View File

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

View File

@ -35,6 +35,7 @@ from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ordered_model.models import OrderedModel
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from accounting.models import CurrencyField from accounting.models import CurrencyField
@ -289,32 +290,32 @@ class AccountDump(models.Model):
) )
class ProductType(models.Model): class ProductType(OrderedModel):
"""A product type. """A product type.
Useful only for categorizing. Useful only for categorizing.
""" """
name = models.CharField(_("name"), max_length=30) name = models.CharField(_("name"), max_length=30)
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), default="")
comment = models.TextField(_("comment"), null=True, blank=True) comment = models.TextField(
_("comment"),
default="",
help_text=_("A text that will be shown on the eboutic."),
)
icon = ResizedImageField( icon = ResizedImageField(
height=70, force_format="WEBP", upload_to="products", null=True, blank=True 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: class Meta:
verbose_name = _("product type") verbose_name = _("product type")
ordering = ["-priority", "name"] ordering = ["order"]
def __str__(self): def __str__(self):
return self.name return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse("counter:producttype_list") return reverse("counter:product_type_list")
def is_owned_by(self, user): def is_owned_by(self, user):
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""

View File

@ -1,10 +1,13 @@
from typing import Annotated from typing import Annotated, Self
from annotated_types import MinLen 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 club.schemas import ClubSchema
from counter.models import Counter, Product from core.schemas import GroupSchema, SimpleUserSchema
from counter.models import Counter, Product, ProductType
class CounterSchema(ModelSchema): class CounterSchema(ModelSchema):
@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
fields = ["id", "name"] 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: class Meta:
model = Product model = Product
fields = ["id", "name", "code"] 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")

View File

@ -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 { escape_html } from "tom-select/dist/types/utils";
import { import {
type CounterSchema, type CounterSchema,
type ProductSchema, type SimpleProductSchema,
counterSearchCounter, counterSearchCounter,
productSearchProducts, productSearchProducts,
} from "#openapi"; } from "#openapi";
@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span> <span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`; </div>`;
} }
protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`; return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
} }
} }

View File

@ -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<HTMLLIElement>;
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;
},
}));
});

View File

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

View File

@ -0,0 +1,64 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product type list{% endtrans %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("counter/css/product_type.scss") }}">
{% endblock %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/counter/product-type-index.ts") }}"></script>
{% endblock %}
{% block content %}
<p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
{% if product_types %}
<aside>
<p>
{% trans %}Product types are in the same order on this page and on the eboutic.{% endtrans %}
</p>
<p>
{% trans trimmed %}
You can reorder them here by drag-and-drop.
The changes will then be applied globally immediately.
{% endtrans %}
</p>
</aside>
<div x-data="productTypesList">
<p
class="alert snackbar"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms
x-text="alertMessage.content"
></p>
<h3>{% trans %}Product type list{% endtrans %}</h3>
<ul
x-sort="($item, $position) => reorder($item, $position)"
x-ref="productTypes"
class="product-type-list"
:aria-busy="loading"
>
{%- for product_type in product_types -%}
<li x-sort:item="{{ product_type.id }}">
<i class="fa fa-grip-vertical"></i>
<a href="{{ url('counter:product_type_edit', type_id=product_type.id) }}">
{{ product_type.name }}
</a>
</li>
{%- endfor -%}
</ul>
</div>
{% else %}
<p>
{% trans %}There are no product types in this website.{% endtrans %}
</p>
{% endif %}
{% endblock %}

View File

@ -1,24 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Product type list{% endtrans %}
{% endblock %}
{% block content %}
<p><a href="{{ url('counter:new_producttype') }}">{% trans %}New product type{% endtrans %}</a></p>
{% if producttype_list %}
<h3>{% trans %}Product type list{% endtrans %}</h3>
<ul>
{% for t in producttype_list %}
<li><a href="{{ url('counter:producttype_edit', type_id=t.id) }}">{{ t }}</a></li>
{% endfor %}
</ul>
{% else %}
{% trans %}There is no product types in this website.{% endtrans %}
{% endif %}
{% endblock %}

View File

@ -1,11 +1,19 @@
from io import BytesIO from io import BytesIO
from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from PIL import Image 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 from counter.models import Product, ProductType
@ -31,3 +39,48 @@ def test_resize_product_icon(model):
assert product.icon.height == 70 assert product.icon.height == 70
assert product.icon.name == f"products/{name}.webp" assert product.icon.name == f"products/{name}.webp"
assert Image.open(product.icon).format == "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"))

View File

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

View File

@ -121,19 +121,19 @@ urlpatterns = [
name="product_edit", name="product_edit",
), ),
path( path(
"admin/producttype/list/", "admin/product-type/list/",
ProductTypeListView.as_view(), ProductTypeListView.as_view(),
name="producttype_list", name="product_type_list",
), ),
path( path(
"admin/producttype/create/", "admin/product-type/create/",
ProductTypeCreateView.as_view(), ProductTypeCreateView.as_view(),
name="new_producttype", name="new_product_type",
), ),
path( path(
"admin/producttype/<int:type_id>/", "admin/product-type/<int:type_id>/",
ProductTypeEditView.as_view(), ProductTypeEditView.as_view(),
name="producttype_edit", name="product_type_edit",
), ),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"), path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"), path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),

View File

@ -101,15 +101,16 @@ class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins.""" """A list view for the admins."""
model = ProductType model = ProductType
template_name = "counter/producttype_list.jinja" template_name = "counter/product_type_list.jinja"
current_tab = "product_types" current_tab = "product_types"
context_object_name = "product_types"
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins.""" """A create view for the admins."""
model = ProductType model = ProductType
fields = ["name", "description", "comment", "icon", "priority"] fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
current_tab = "products" current_tab = "products"
@ -119,7 +120,7 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
model = ProductType model = ProductType
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
fields = ["name", "description", "comment", "icon", "priority"] fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id" pk_url_kwarg = "type_id"
current_tab = "products" current_tab = "products"
@ -129,7 +130,7 @@ class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
queryset = Product.objects.values("id", "name", "code", "product_type__name") queryset = Product.objects.values("id", "name", "code", "product_type__name")
template_name = "counter/product_list.jinja" template_name = "counter/product_list.jinja"
ordering = [ ordering = [
F("product_type__priority").desc(nulls_last=True), F("product_type__order").asc(nulls_last=True),
"product_type", "product_type",
"name", "name",
] ]

View File

@ -99,7 +99,7 @@ class CounterAdminTabsMixin(TabedViewMixin):
"name": _("Archived products"), "name": _("Archived products"),
}, },
{ {
"url": reverse_lazy("counter:producttype_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",
"name": _("Product types"), "name": _("Product types"),
}, },

View File

@ -2,7 +2,7 @@ from pydantic import TypeAdapter
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
from counter.models import Counter, Product 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"] _js = ["bundled/counter/components/ajax-select-index.ts"]
@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple):
class AutoCompleteSelectProduct(AutoCompleteSelect): class AutoCompleteSelectProduct(AutoCompleteSelect):
component_name = "product-ajax-select" component_name = "product-ajax-select"
model = Product model = Product
adapter = TypeAdapter(list[ProductSchema]) adapter = TypeAdapter(list[SimpleProductSchema])
js = _js js = _js
class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
component_name = "product-ajax-select" component_name = "product-ajax-select"
model = Product model = Product
adapter = TypeAdapter(list[ProductSchema]) adapter = TypeAdapter(list[SimpleProductSchema])
js = _js js = _js

View File

@ -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. l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serveur.
```bash ```bash
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend # Pour le backend
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend ./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 ## Éditer le fichier django.po

View File

@ -36,7 +36,7 @@ def get_eboutic_products(user: User) -> list[Product]:
.products.filter(product_type__isnull=False) .products.filter(product_type__isnull=False)
.filter(archived=False) .filter(archived=False)
.filter(limit_age__lte=user.age) .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=F("product_type__name"))
.annotate(category_comment=F("product_type__comment")) .annotate(category_comment=F("product_type__comment"))
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to` .prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`

View File

@ -88,7 +88,7 @@
</div> </div>
{% endif %} {% 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') %} {% for category, items in priority_groups.list|groupby('category') %}
{% if items|count > 0 %} {% if items|count > 0 %}
<section> <section>

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "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" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -17,119 +17,128 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\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 #: core/static/user/js/user_edit.js:91
#, javascript-format #, javascript-format
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: core/static/webpack/core/components/ajax-select-base.ts:68 #: counter/static/bundled/counter/product-type-index.ts:36
msgid "Remove" msgid "Products types reordered!"
msgstr "Retirer" msgstr "Types de produits réordonnés !"
#: core/static/webpack/core/components/ajax-select-base.ts:88 #: counter/static/bundled/counter/product-type-index.ts:40
msgid "You need to type %(number)s more characters" #, javascript-format
msgstr "Vous devez taper %(number)s caractères de plus" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: 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"
#: eboutic/static/eboutic/js/makecommand.js:56 #: eboutic/static/eboutic/js/makecommand.js:56
msgid "Incorrect value" msgid "Incorrect value"
msgstr "Valeur incorrecte" msgstr "Valeur incorrecte"
#: sas/static/webpack/sas/viewer-index.ts:271 #: sas/static/bundled/sas/viewer-index.ts:271
msgid "Couldn't moderate picture" msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image" 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" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" msgstr "Il n'a pas été possible de supprimer l'image"

16
package-lock.json generated
View File

@ -9,12 +9,13 @@
"version": "3", "version": "3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.1", "alpinejs": "^3.14.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"cytoscape": "^3.30.2", "cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",
@ -44,6 +45,12 @@
"vite-plugin-static-copy": "^2.1.0" "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": { "node_modules/@ampproject/remapping": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@ -3064,9 +3071,10 @@
} }
}, },
"node_modules/alpinejs": { "node_modules/alpinejs": {
"version": "3.14.1", "version": "3.14.7",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.1.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.14.7.tgz",
"integrity": "sha512-ICar8UsnRZAYvv/fCNfNeKMXNoXGUfwHrjx7LqXd08zIP95G2d9bAOuaL97re+1mgt/HojqHsfdOLo/A5LuWgQ==", "integrity": "sha512-ScnbydNBcWVnCiVupD3wWUvoMPm8244xkvDNMxVCspgmap9m4QuJ7pjc+77UtByU+1+Ejg0wzYkP4mQaOMcvng==",
"license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "~3.1.1" "@vue/reactivity": "~3.1.1"
} }

View File

@ -33,12 +33,13 @@
"vite-plugin-static-copy": "^2.1.0" "vite-plugin-static-copy": "^2.1.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.1", "alpinejs": "^3.14.7",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"cytoscape": "^3.30.2", "cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0", "cytoscape-cxtmenu": "^3.5.0",

View File

@ -25,26 +25,11 @@ from __future__ import unicode_literals
from django.db import migrations 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): class Migration(migrations.Migration):
dependencies = [("pedagogy", "0001_initial")] dependencies = [("pedagogy", "0001_initial")]
operations = [ # This migration contained just a RunPython operation
migrations.RunPython( # Which has since been removed.
remove_multiples_comments_from_same_user, # The migration itself is kept in order not to break the migration tree
reverse_code=migrations.RunPython.noop, operations = []
)
]

View File

@ -8,7 +8,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import RealGroup, User from core.models import Group, User
from pedagogy.models import UV from pedagogy.models import UV
@ -80,9 +80,7 @@ class TestUVSearch(TestCase):
subscriber_user.make(), subscriber_user.make(),
baker.make( baker.make(
User, User,
groups=[ groups=[Group.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)],
RealGroup.objects.get(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
],
), ),
): ):
# users that have right # users that have right

View File

@ -24,6 +24,7 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.views.generic import ( from django.views.generic import (
@ -34,7 +35,7 @@ from django.views.generic import (
UpdateView, UpdateView,
) )
from core.models import Notification, RealGroup from core.models import Notification, User
from core.views import ( from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditPropMixin, CanEditPropMixin,
@ -156,21 +157,19 @@ class UVCommentReportCreateView(CanCreateMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
resp = super().form_valid(form) resp = super().form_valid(form)
# Send a message to moderation admins # Send a message to moderation admins
for user in ( unread_notif_subquery = Notification.objects.filter(
RealGroup.objects.filter(id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID) user=OuterRef("pk"), type="PEDAGOGY_MODERATION", viewed=False
.first() )
.users.all() for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_PEDAGOGY_ADMIN_ID],
): ):
if not user.notifications.filter( Notification.objects.create(
type="PEDAGOGY_MODERATION", viewed=False user=user,
).exists(): url=reverse("pedagogy:moderation"),
Notification( type="PEDAGOGY_MODERATION",
user=user, )
url=reverse("pedagogy:moderation"),
type="PEDAGOGY_MODERATION",
).save()
return resp return resp

View File

@ -19,7 +19,7 @@ from django.urls import reverse
from django.utils.timezone import localtime, now from django.utils.timezone import localtime, now
from club.models import Club 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 counter.models import Counter, Customer, Product, Refilling, Selling
from subscription.models import Subscription from subscription.models import Subscription
@ -50,9 +50,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem" self.to_keep.address = "Jerusalem"
self.to_delete.parent_address = "Rome" self.to_delete.parent_address = "Rome"
self.to_delete.address = "Rome" self.to_delete.address = "Rome"
subscribers = RealGroup.objects.get(name="Subscribers") subscribers = Group.objects.get(name="Subscribers")
mde_admin = RealGroup.objects.get(name="MDE admin") mde_admin = Group.objects.get(name="MDE admin")
sas_admin = RealGroup.objects.get(name="SAS admin") sas_admin = Group.objects.get(name="SAS admin")
self.to_keep.groups.add(subscribers.id) self.to_keep.groups.add(subscribers.id)
self.to_delete.groups.add(mde_admin.id) self.to_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id) self.to_keep.groups.add(sas_admin.id)

View File

@ -7,7 +7,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from core.baker_recipes import old_subscriber_user, subscriber_user 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.baker_recipes import picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationRequest
@ -155,7 +155,7 @@ class TestPictureRelation(TestSas):
def test_delete_relation_with_authorized_users(self): def test_delete_relation_with_authorized_users(self):
"""Test that deletion works as intended when called by an authorized user.""" """Test that deletion works as intended when called by an authorized user."""
relation: PeoplePictureRelation = self.user_a.pictures.first() 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]) sas_admin = baker.make(User, groups=[sas_admin_group])
root = baker.make(User, is_superuser=True) root = baker.make(User, is_superuser=True)
for user in sas_admin, root, self.user_a: for user in sas_admin, root, self.user_a:
@ -189,7 +189,7 @@ class TestPictureModeration(TestSas):
def setUpTestData(cls): def setUpTestData(cls):
super().setUpTestData() super().setUpTestData()
cls.sas_admin = baker.make( 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 = Picture.objects.filter(parent=cls.album_a)[0]
cls.picture.is_moderated = False cls.picture.is_moderated = False

View File

@ -23,7 +23,7 @@ from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user 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.baker_recipes import picture_recipe
from sas.models import Album, Picture from sas.models import Album, Picture
@ -38,7 +38,7 @@ from sas.models import Album, Picture
old_subscriber_user.make, old_subscriber_user.make,
lambda: baker.make(User, is_superuser=True), lambda: baker.make(User, is_superuser=True),
lambda: baker.make( 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), lambda: baker.make(User),
], ],
@ -80,7 +80,7 @@ class TestSasModeration(TestCase):
cls.to_moderate.is_moderated = False cls.to_moderate.is_moderated = False
cls.to_moderate.save() cls.to_moderate.save()
cls.moderator = baker.make( 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() cls.simple_user = subscriber_user.make()

View File

@ -90,13 +90,20 @@ def test_form_new_user(settings: SettingsWrapper):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize( @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]): def test_page_access(
"""Just check the page doesn't crash.""" client: Client, user_factory: Callable[[], User], status_code: int
):
"""Check that only authorized users may access this page."""
client.force_login(user_factory()) client.force_login(user_factory())
res = client.get(reverse("subscription:subscription")) res = client.get(reverse("subscription:subscription"))
assert res.status_code == 200 assert res.status_code == status_code
@pytest.mark.django_db @pytest.mark.django_db