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

View File

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

View File

@ -3,19 +3,6 @@ from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
from club.models import Club
from core.operations import PsqlRunOnly
def generate_club_pages(apps, schema_editor):
def recursive_generate_club_page(club):
club.make_page()
for child in Club.objects.filter(parent=club).all():
recursive_generate_club_page(child)
for club in Club.objects.filter(parent=None).all():
recursive_generate_club_page(club)
class Migration(migrations.Migration):
dependencies = [("core", "0024_auto_20170906_1317"), ("club", "0010_club_logo")]
@ -48,11 +35,4 @@ class Migration(migrations.Migration):
null=True,
),
),
PsqlRunOnly(
"SET CONSTRAINTS ALL IMMEDIATE", reverse_sql=migrations.RunSQL.noop
),
migrations.RunPython(generate_club_pages),
PsqlRunOnly(
migrations.RunSQL.noop, reverse_sql="SET CONSTRAINTS ALL IMMEDIATE"
),
]

View File

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

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 club.models import Club
from core.models import Notification, Preferences, RealGroup, User
from core.models import Notification, Preferences, User
class Sith(models.Model):
@ -62,16 +62,31 @@ NEWS_TYPES = [
class News(models.Model):
"""The news class."""
"""News about club events."""
title = models.CharField(_("title"), max_length=64)
summary = models.TextField(_("summary"))
content = models.TextField(_("content"))
summary = models.TextField(
_("summary"),
help_text=_(
"A description of the event (what is the activity ? "
"is there an associated clic ? is there a inscription form ?)"
),
)
content = models.TextField(
_("content"),
blank=True,
default="",
help_text=_("A more detailed and exhaustive description of the event."),
)
type = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
)
club = models.ForeignKey(
Club, related_name="news", verbose_name=_("club"), on_delete=models.CASCADE
Club,
related_name="news",
verbose_name=_("club"),
on_delete=models.CASCADE,
help_text=_("The club which organizes the event."),
)
author = models.ForeignKey(
User,
@ -85,7 +100,7 @@ class News(models.Model):
related_name="moderated_news",
verbose_name=_("moderator"),
null=True,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
)
def __str__(self):
@ -93,17 +108,15 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
).save()
)
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
@ -321,16 +334,14 @@ class Poster(models.Model):
def save(self, *args, **kwargs):
if not self.is_moderated:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_COM_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification(
user=u,
Notification.objects.create(
user=user,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
).save()
)
return super().save(*args, **kwargs)
def clean(self, *args, **kwargs):

View File

@ -34,43 +34,90 @@
{% csrf_token %}
{{ form.non_field_errors() }}
{{ 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>
<li>{% trans %}Notice: Information, election result - no 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>{% trans %}Call: long time event, associated with a long date (election appliance, ...){% endtrans %}</li>
<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>
{{ form.type }}</p>
<p class="date">{{ form.start_date.errors }}<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label> {{ form.start_date }}</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 }}">{{ form.title.label }}</label> {{ form.title }}</p>
<p>{{ form.club.errors }}<label for="{{ form.club.name }}">{{ form.club.label }}</label> {{ form.club }}</p>
<p>{{ form.summary.errors }}<label for="{{ form.summary.name }}">{{ form.summary.label }}</label> {{ form.summary }}</p>
<p>{{ form.content.errors }}<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content }}</p>
{{ form.type }}
</p>
<p class="date">
{{ form.start_date.errors }}
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
{{ form.start_date }}
</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 %}
<p>{{ form.automoderation.errors }}<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}</p>
<p>
{{ form.automoderation.errors }}
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}
</p>
{% endif %}
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}" /></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$( function() {
var type = $('input[name=type]');
var dates = $('.date');
var until = $('.until');
function update_targets () {
type_checked = $('input[name=type]:checked');
if (type_checked.val() == "EVENT" || type_checked.val() == "CALL") {
$(function () {
let type = $('input[name=type]');
let dates = $('.date');
let until = $('.until');
function update_targets() {
const type_checked = $('input[name=type]:checked');
if (["CALL", "EVENT"].includes(type_checked.val())) {
dates.show();
until.hide();
} else if (type_checked.val() == "WEEKLY") {
} else if (type_checked.val() === "WEEKLY") {
dates.show();
until.show();
} else {
@ -78,9 +125,10 @@
until.hide();
}
}
update_targets();
type.change(update_targets);
} );
});
</script>
{% endblock %}

View File

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

View File

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

View File

@ -261,19 +261,19 @@ class Command(BaseCommand):
User.groups.through.objects.bulk_create(
[
User.groups.through(
realgroup_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
group_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
group_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
group_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
group_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
),
User.groups.through(
realgroup_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
),
]
)

View File

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

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
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Self
from typing import TYPE_CHECKING, Optional, Self
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, UserManager
from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser,
)
from django.contrib.auth.models import (
Group as AuthGroup,
)
from django.contrib.auth.models import (
GroupManager as AuthGroupManager,
)
from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import GroupManager as AuthGroupManager
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
@ -242,7 +236,7 @@ class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
pass
class User(AbstractBaseUser):
class User(AbstractUser):
"""Defines the base user class, useable in every app.
This is almost the same as the auth module AbstractUser since it inherits from it,
@ -253,51 +247,22 @@ class User(AbstractBaseUser):
Required fields: email, first_name, last_name, date_of_birth
"""
username = models.CharField(
_("username"),
max_length=254,
unique=True,
help_text=_(
"Required. 254 characters or fewer. Letters, digits and ./+/-/_ only."
),
validators=[
validators.RegexValidator(
r"^[\w.+-]+$",
_(
"Enter a valid username. This value may contain only "
"letters, numbers "
"and ./+/-/_ characters."
),
)
],
error_messages={"unique": _("A user with that username already exists.")},
)
first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64)
email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Designates whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Designates whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
date_joined = models.DateField(_("date joined"), auto_now_add=True)
last_update = models.DateTimeField(_("last update"), auto_now=True)
is_superuser = models.BooleanField(
_("superuser"),
default=False,
help_text=_("Designates whether this user is a superuser. "),
groups = models.ManyToManyField(
Group,
verbose_name=_("groups"),
help_text=_(
"The groups this user belongs to. A user will get all permissions "
"granted to each of their groups."
),
related_name="users",
blank=True,
)
groups = models.ManyToManyField(RealGroup, related_name="users", blank=True)
home = models.OneToOneField(
"SithFile",
related_name="home_of",
@ -401,8 +366,6 @@ class User(AbstractBaseUser):
objects = CustomUserManager()
USERNAME_FIELD = "username"
def __str__(self):
return self.get_display_name()
@ -422,22 +385,23 @@ class User(AbstractBaseUser):
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
def has_module_perms(self, package_name: str) -> bool:
return self.is_active
def has_perm(self, perm: str, obj: Any = None) -> bool:
return self.is_active and self.is_superuser
@cached_property
def was_subscribed(self) -> bool:
if "is_subscribed" in self.__dict__ and self.is_subscribed:
# if the user is currently subscribed, he is an old subscriber too
# if the property has already been cached, avoid another request
return True
return self.subscriptions.exists()
@cached_property
def is_subscribed(self) -> bool:
s = self.subscriptions.filter(
if "was_subscribed" in self.__dict__ and not self.was_subscribed:
# if the user never subscribed, he cannot be a subscriber now.
# if the property has already been cached, avoid another request
return False
return self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
)
return s.exists()
).exists()
@cached_property
def account_balance(self):
@ -530,10 +494,8 @@ class User(AbstractBaseUser):
@cached_property
def can_create_subscription(self) -> bool:
from club.models import Membership
return (
Membership.objects.board()
return self.is_root or (
self.memberships.board()
.ongoing()
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
.exists()
@ -601,11 +563,6 @@ class User(AbstractBaseUser):
"date_of_birth": self.date_of_birth,
}
def get_full_name(self):
"""Returns the first_name plus the last_name, with a space in between."""
full_name = "%s %s" % (self.first_name, self.last_name)
return full_name.strip()
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@ -984,13 +941,11 @@ class SithFile(models.Model):
if copy_rights:
self.copy_rights()
if self.is_in_sas:
for u in (
RealGroup.objects.filter(id=settings.SITH_GROUP_SAS_ADMIN_ID)
.first()
.users.all()
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=u,
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",

View File

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

View File

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

View File

@ -87,3 +87,38 @@ a:not(.button) {
color: $primary-color;
}
}
form {
margin: 0 auto 10px;
.helptext {
margin-top: .25rem;
margin-bottom: .25rem;
font-size: 80%;
}
fieldset {
margin-bottom: 1rem;
}
.row {
label {
margin: unset;
}
}
label {
display: block;
margin-bottom: 8px;
&.required:after {
margin-left: 4px;
content: "*";
color: red;
}
}
.choose_file_widget {
display: none;
}
}

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 {
border-radius: 5px;
@ -1401,21 +1412,6 @@ footer {
}
}
/*---------------------------------FORMS-------------------------------*/
form {
margin: 0 auto;
margin-bottom: 10px;
}
label {
display: block;
margin-bottom: 8px;
}
.choose_file_widget {
display: none;
}
.ui-dialog .ui-dialog-buttonpane {
bottom: 0;

View File

@ -3,17 +3,18 @@
{% macro page_history(page) %}
<p>{% trans page_name=page.name %}You're seeing the history of page "{{ page_name }}"{% endtrans %}</p>
<ul>
{% for r in (page.revisions.all()|sort(attribute='date', reverse=True)) %}
{% if loop.index < 2 %}
<li><a href="{{ url('core:page', page_name=page.get_full_name()) }}">{% trans %}last{% endtrans %}</a> -
{{ user_profile_link(page.revisions.last().author) }} -
{{ page.revisions.last().date|localtime|date(DATETIME_FORMAT) }} {{ page.revisions.last().date|localtime|time(DATETIME_FORMAT) }}</a></li>
{% set page_name = page.get_full_name() %}
{%- for rev in page.revisions.order_by("-date").select_related("author") -%}
<li>
{% if loop.first %}
<a href="{{ url('core:page', page_name=page_name) }}">{% trans %}last{% endtrans %}</a>
{% else %}
<li><a href="{{ url('core:page_rev', page_name=page.get_full_name(), rev=r['id']) }}">{{ r.revision }}</a> -
{{ user_profile_link(r.author) }} -
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
<a href="{{ url('core:page_rev', page_name=page_name, rev=rev.id) }}">{{ rev.revision }}</a>
{% endif %}
{% endfor %}
{{ user_profile_link(rev.author) }} -
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
</li>
{%- endfor -%}
</ul>
{% 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: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:invoices_call') }}">{% trans %}Invoices call{% 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)
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()))
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 core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, RealGroup, SithFile, User
from core.models import Group, SithFile, User
from sas.models import Picture
from sith import settings
@ -26,12 +26,10 @@ class TestImageAccess:
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User,
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)],
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User,
groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)],
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,24 +12,33 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from django.conf import settings
from django.db.models import F
from django.shortcuts import get_object_or_404
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsRoot
from counter.models import Counter, Product
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType
from counter.schemas import (
CounterFilterSchema,
CounterSchema,
ProductFilterSchema,
ProductSchema,
ProductTypeSchema,
ReorderProductTypeSchema,
SimpleProductSchema,
SimplifiedCounterSchema,
)
IsCounterAdmin = (
IsRoot
| IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID)
| IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
)
@api_controller("/counter")
class CounterController(ControllerBase):
@ -64,15 +73,72 @@ class CounterController(ControllerBase):
class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[ProductSchema],
response=PaginatedResponseSchema[SimpleProductSchema],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_products(self, search: Annotated[str, MinLen(1)]):
return (
Product.objects.filter(
Q(name__icontains=search) | Q(code__icontains=search)
def search_products(self, filters: Query[ProductFilterSchema]):
return filters.filter(
Product.objects.order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
).values()
)
.filter(archived=False)
.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 django.conf import settings
from django.db import migrations, models
from django.utils.translation import gettext_lazy as _
from core.models import User
from counter.models import Counter, Customer, Product, Selling
def balance_ecocups(apps, schema_editor):
for customer in Customer.objects.all():
customer.recorded_products = 0
for selling in customer.buyings.filter(
product__id__in=[settings.SITH_ECOCUP_CONS, settings.SITH_ECOCUP_DECO]
).all():
if selling.product.is_record_product:
customer.recorded_products += selling.quantity
elif selling.product.is_unrecord_product:
customer.recorded_products -= selling.quantity
if customer.recorded_products < -settings.SITH_ECOCUP_LIMIT:
qt = -(customer.recorded_products + settings.SITH_ECOCUP_LIMIT)
cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
Selling(
label=_("Ecocup regularization"),
product=cons,
unit_price=cons.selling_price,
club=cons.club,
counter=Counter.objects.filter(name="Foyer").first(),
quantity=qt,
seller=User.objects.get(id=0),
customer=customer,
).save(allow_negative=True)
customer.recorded_products += qt
customer.save()
class Migration(migrations.Migration):
@ -44,5 +12,4 @@ class Migration(migrations.Migration):
name="recorded_products",
field=models.IntegerField(verbose_name="recorded items", default=0),
),
migrations.RunPython(balance_ecocups),
]

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

View File

@ -1,10 +1,13 @@
from typing import Annotated
from typing import Annotated, Self
from annotated_types import MinLen
from ninja import Field, FilterSchema, ModelSchema
from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from core.schemas import SimpleUserSchema
from counter.models import Counter, Product
from club.schemas import ClubSchema
from core.schemas import GroupSchema, SimpleUserSchema
from counter.models import Counter, Product, ProductType
class CounterSchema(ModelSchema):
@ -26,7 +29,72 @@ class SimplifiedCounterSchema(ModelSchema):
fields = ["id", "name"]
class ProductSchema(ModelSchema):
class ProductTypeSchema(ModelSchema):
class Meta:
model = ProductType
fields = ["id", "name", "description", "comment", "icon", "order"]
url: str
@staticmethod
def resolve_url(obj: ProductType) -> str:
return reverse("counter:product_type_edit", kwargs={"type_id": obj.id})
class SimpleProductTypeSchema(ModelSchema):
class Meta:
model = ProductType
fields = ["id", "name"]
class ReorderProductTypeSchema(Schema):
below: int | None = None
above: int | None = None
@model_validator(mode="after")
def validate_exclusive(self) -> Self:
if self.below is None and self.above is None:
raise ValueError("Either 'below' or 'above' must be set.")
if self.below is not None and self.above is not None:
raise ValueError("Only one of 'below' or 'above' must be set.")
return self
class SimpleProductSchema(ModelSchema):
class Meta:
model = Product
fields = ["id", "name", "code"]
class ProductSchema(ModelSchema):
class Meta:
model = Product
fields = [
"id",
"name",
"code",
"description",
"purchase_price",
"selling_price",
"icon",
"limit_age",
"archived",
]
buying_groups: list[GroupSchema]
club: ClubSchema
product_type: SimpleProductTypeSchema | None
url: str
@staticmethod
def resolve_url(obj: Product) -> str:
return reverse("counter:product_edit", kwargs={"product_id": obj.id})
class ProductFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(
None, q=["name__icontains", "code__icontains"]
)
is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__in")

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

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 typing import Callable
from uuid import uuid4
import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.models import Product, ProductType
@ -31,3 +39,48 @@ def test_resize_product_icon(model):
assert product.icon.height == 70
assert product.icon.name == f"products/{name}.webp"
assert Image.open(product.icon).format == "WEBP"
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "status_code"),
[
(lambda: baker.make(User, is_superuser=True), 200),
(board_user.make, 403),
(subscriber_user.make, 403),
(
lambda: baker.make(
User,
groups=[Group.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)],
),
200,
),
(
lambda: baker.make(
User,
groups=[Group.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)],
),
200,
),
],
)
def test_fetch_product_access(
client: Client, user_factory: Callable[[], User], status_code: int
):
"""Test that only authorized users can use the `GET /product` route."""
client.force_login(user_factory())
assert (
client.get(reverse("api:search_products_detailed")).status_code == status_code
)
@pytest.mark.django_db
def test_fetch_product_nb_queries(client: Client):
client.force_login(baker.make(User, is_superuser=True))
cache.clear()
with assertNumQueries(5):
# - 2 for authentication
# - 1 for pagination
# - 1 for the actual request
# - 1 to prefetch the related buying_groups
client.get(reverse("api:search_products_detailed"))

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",
),
path(
"admin/producttype/list/",
"admin/product-type/list/",
ProductTypeListView.as_view(),
name="producttype_list",
name="product_type_list",
),
path(
"admin/producttype/create/",
"admin/product-type/create/",
ProductTypeCreateView.as_view(),
name="new_producttype",
name="new_product_type",
),
path(
"admin/producttype/<int:type_id>/",
"admin/product-type/<int:type_id>/",
ProductTypeEditView.as_view(),
name="producttype_edit",
name="product_type_edit",
),
path("admin/eticket/list/", EticketListView.as_view(), name="eticket_list"),
path("admin/eticket/new/", EticketCreateView.as_view(), name="new_eticket"),

View File

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

View File

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

View File

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

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.
```bash
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules # Pour le backend
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules # Pour le frontend
# Pour le backend
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules
# Pour le frontend
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated
```
## Éditer le fichier django.po

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-14 10:24+0100\n"
"POT-Creation-Date: 2024-12-17 00:46+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -17,119 +17,128 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: core/static/bundled/core/components/ajax-select-base.ts:68
msgid "Remove"
msgstr "Retirer"
#: core/static/bundled/core/components/ajax-select-base.ts:90
msgid "You need to type %(number)s more characters"
msgstr "Vous devez taper %(number)s caractères de plus"
#: core/static/bundled/core/components/ajax-select-base.ts:94
msgid "No results found"
msgstr "Aucun résultat trouvé"
#: core/static/bundled/core/components/easymde-index.ts:38
msgid "Heading"
msgstr "Titre"
#: core/static/bundled/core/components/easymde-index.ts:44
msgid "Italic"
msgstr "Italique"
#: core/static/bundled/core/components/easymde-index.ts:50
msgid "Bold"
msgstr "Gras"
#: core/static/bundled/core/components/easymde-index.ts:56
msgid "Strikethrough"
msgstr "Barré"
#: core/static/bundled/core/components/easymde-index.ts:65
msgid "Underline"
msgstr "Souligné"
#: core/static/bundled/core/components/easymde-index.ts:74
msgid "Superscript"
msgstr "Exposant"
#: core/static/bundled/core/components/easymde-index.ts:83
msgid "Subscript"
msgstr "Indice"
#: core/static/bundled/core/components/easymde-index.ts:89
msgid "Code"
msgstr "Code"
#: core/static/bundled/core/components/easymde-index.ts:96
msgid "Quote"
msgstr "Citation"
#: core/static/bundled/core/components/easymde-index.ts:102
msgid "Unordered list"
msgstr "Liste non ordonnée"
#: core/static/bundled/core/components/easymde-index.ts:108
msgid "Ordered list"
msgstr "Liste ordonnée"
#: core/static/bundled/core/components/easymde-index.ts:115
msgid "Insert link"
msgstr "Insérer lien"
#: core/static/bundled/core/components/easymde-index.ts:121
msgid "Insert image"
msgstr "Insérer image"
#: core/static/bundled/core/components/easymde-index.ts:127
msgid "Insert table"
msgstr "Insérer tableau"
#: core/static/bundled/core/components/easymde-index.ts:134
msgid "Clean block"
msgstr "Nettoyer bloc"
#: core/static/bundled/core/components/easymde-index.ts:141
msgid "Toggle preview"
msgstr "Activer la prévisualisation"
#: core/static/bundled/core/components/easymde-index.ts:147
msgid "Toggle side by side"
msgstr "Activer la vue côte à côte"
#: core/static/bundled/core/components/easymde-index.ts:153
msgid "Toggle fullscreen"
msgstr "Activer le plein écran"
#: core/static/bundled/core/components/easymde-index.ts:160
msgid "Markdown guide"
msgstr "Guide markdown"
#: core/static/bundled/core/components/nfc-input-index.ts:26
msgid "Unsupported NFC card"
msgstr "Carte NFC non supportée"
#: core/static/bundled/user/family-graph-index.js:233
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
#: core/static/bundled/user/pictures-index.js:76
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
#: core/static/user/js/user_edit.js:91
#, javascript-format
msgid "captured.%s"
msgstr "capture.%s"
#: core/static/webpack/core/components/ajax-select-base.ts:68
msgid "Remove"
msgstr "Retirer"
#: counter/static/bundled/counter/product-type-index.ts:36
msgid "Products types reordered!"
msgstr "Types de produits réordonnés !"
#: core/static/webpack/core/components/ajax-select-base.ts:88
msgid "You need to type %(number)s more characters"
msgstr "Vous devez taper %(number)s caractères de plus"
#: core/static/webpack/core/components/ajax-select-base.ts:92
msgid "No results found"
msgstr "Aucun résultat trouvé"
#: core/static/webpack/core/components/easymde-index.ts:38
msgid "Heading"
msgstr "Titre"
#: core/static/webpack/core/components/easymde-index.ts:44
msgid "Italic"
msgstr "Italique"
#: core/static/webpack/core/components/easymde-index.ts:50
msgid "Bold"
msgstr "Gras"
#: core/static/webpack/core/components/easymde-index.ts:56
msgid "Strikethrough"
msgstr "Barré"
#: core/static/webpack/core/components/easymde-index.ts:65
msgid "Underline"
msgstr "Souligné"
#: core/static/webpack/core/components/easymde-index.ts:74
msgid "Superscript"
msgstr "Exposant"
#: core/static/webpack/core/components/easymde-index.ts:83
msgid "Subscript"
msgstr "Indice"
#: core/static/webpack/core/components/easymde-index.ts:89
msgid "Code"
msgstr "Code"
#: core/static/webpack/core/components/easymde-index.ts:96
msgid "Quote"
msgstr "Citation"
#: core/static/webpack/core/components/easymde-index.ts:102
msgid "Unordered list"
msgstr "Liste non ordonnée"
#: core/static/webpack/core/components/easymde-index.ts:108
msgid "Ordered list"
msgstr "Liste ordonnée"
#: core/static/webpack/core/components/easymde-index.ts:115
msgid "Insert link"
msgstr "Insérer lien"
#: core/static/webpack/core/components/easymde-index.ts:121
msgid "Insert image"
msgstr "Insérer image"
#: core/static/webpack/core/components/easymde-index.ts:127
msgid "Insert table"
msgstr "Insérer tableau"
#: core/static/webpack/core/components/easymde-index.ts:134
msgid "Clean block"
msgstr "Nettoyer bloc"
#: core/static/webpack/core/components/easymde-index.ts:141
msgid "Toggle preview"
msgstr "Activer la prévisualisation"
#: core/static/webpack/core/components/easymde-index.ts:147
msgid "Toggle side by side"
msgstr "Activer la vue côte à côte"
#: core/static/webpack/core/components/easymde-index.ts:153
msgid "Toggle fullscreen"
msgstr "Activer le plein écran"
#: core/static/webpack/core/components/easymde-index.ts:160
msgid "Markdown guide"
msgstr "Guide markdown"
#: core/static/webpack/core/components/nfc-input-index.ts:24
msgid "Unsupported NFC card"
msgstr "Carte NFC non supportée"
#: core/static/webpack/user/family-graph-index.js:233
msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s"
#: core/static/webpack/user/pictures-index.js:76
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"
#: counter/static/bundled/counter/product-type-index.ts:40
#, javascript-format
msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/eboutic/js/makecommand.js:56
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: sas/static/webpack/sas/viewer-index.ts:271
#: sas/static/bundled/sas/viewer-index.ts:271
msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/webpack/sas/viewer-index.ts:284
#: sas/static/bundled/sas/viewer-index.ts:284
msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image"

16
package-lock.json generated
View File

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

View File

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

View File

@ -25,26 +25,11 @@ from __future__ import unicode_literals
from django.db import migrations
from core.models import User
def remove_multiples_comments_from_same_user(apps, schema_editor):
for user in User.objects.exclude(uv_comments=None).prefetch_related("uv_comments"):
for uv in user.uv_comments.values("uv").distinct():
last = (
user.uv_comments.filter(uv__id=uv["uv"])
.order_by("-publish_date")
.first()
)
user.uv_comments.filter(uv__id=uv["uv"]).exclude(pk=last.pk).delete()
class Migration(migrations.Migration):
dependencies = [("pedagogy", "0001_initial")]
operations = [
migrations.RunPython(
remove_multiples_comments_from_same_user,
reverse_code=migrations.RunPython.noop,
)
]
# This migration contained just a RunPython operation
# Which has since been removed.
# The migration itself is kept in order not to break the migration tree
operations = []

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import RealGroup, User
from core.models import Group, User
from sas.baker_recipes import picture_recipe
from sas.models import Album, Picture
@ -38,7 +38,7 @@ from sas.models import Album, Picture
old_subscriber_user.make,
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(User),
],
@ -80,7 +80,7 @@ class TestSasModeration(TestCase):
cls.to_moderate.is_moderated = False
cls.to_moderate.save()
cls.moderator = baker.make(
User, groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
cls.simple_user = subscriber_user.make()

View File

@ -90,13 +90,20 @@ def test_form_new_user(settings: SettingsWrapper):
@pytest.mark.django_db
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make]
("user_factory", "status_code"),
[
(lambda: baker.make(User, is_superuser=True), 200),
(board_user.make, 200),
(subscriber_user.make, 403),
],
)
def test_load_page(client: Client, user_factory: Callable[[], User]):
"""Just check the page doesn't crash."""
def test_page_access(
client: Client, user_factory: Callable[[], User], status_code: int
):
"""Check that only authorized users may access this page."""
client.force_login(user_factory())
res = client.get(reverse("subscription:subscription"))
assert res.status_code == 200
assert res.status_code == status_code
@pytest.mark.django_db