mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
Merge pull request #960 from ae-utbm/taiste
User model migration, better product types ordering and subscription page fix
This commit is contained in:
commit
b773a05bb5
@ -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'aurore</td>" in str(response_get.content)
|
||||||
"<td>Le fantome de l'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"]
|
||||||
|
@ -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):
|
||||||
|
@ -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"
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
@ -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,14 +438,13 @@ 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(
|
|
||||||
type="MAILING_MODERATION", viewed=False
|
|
||||||
).exists():
|
|
||||||
Notification(
|
Notification(
|
||||||
user=user,
|
user=user,
|
||||||
url=reverse("com:mailing_admin"),
|
url=reverse("com:mailing_admin"),
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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):
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
52
com/views.py
52
com/views.py
@ -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(
|
|
||||||
user=u,
|
|
||||||
url=reverse(
|
|
||||||
"com:news_detail", kwargs={"news_id": self.object.id}
|
|
||||||
),
|
|
||||||
type="NEWS_MODERATION",
|
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():
|
|
||||||
Notification(
|
|
||||||
user=u,
|
|
||||||
url=reverse("com:news_admin_list"),
|
url=reverse("com:news_admin_list"),
|
||||||
type="NEWS_MODERATION",
|
type="NEWS_MODERATION",
|
||||||
).save()
|
)
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -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"])
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
105
core/models.py
105
core/models.py
@ -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",
|
||||||
|
@ -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", () => {
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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) }} -
|
|
||||||
{{ r.date|localtime|date(DATETIME_FORMAT) }} {{ r.date|localtime|time(DATETIME_FORMAT) }}</a></li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{{ user_profile_link(rev.author) }} -
|
||||||
|
{{ rev.date|localtime|date(DATETIME_FORMAT) }} {{ rev.date|localtime|time(DATETIME_FORMAT) }}
|
||||||
|
</li>
|
||||||
|
{%- endfor -%}
|
||||||
</ul>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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(
|
||||||
|
@ -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)],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -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():
|
|
||||||
Notification(
|
|
||||||
user=u,
|
|
||||||
url=reverse("core:file_moderation"),
|
url=reverse("core:file_moderation"),
|
||||||
type="FILE_MODERATION",
|
type="FILE_MODERATION",
|
||||||
).save()
|
)
|
||||||
|
|
||||||
|
|
||||||
class FileListView(ListView):
|
class FileListView(ListView):
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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",
|
||||||
|
"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)
|
||||||
|
@ -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),
|
|
||||||
]
|
]
|
||||||
|
@ -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"),
|
||||||
|
]
|
@ -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."""
|
||||||
|
@ -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")
|
||||||
|
@ -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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
64
counter/static/bundled/counter/product-type-index.ts
Normal file
64
counter/static/bundled/counter/product-type-index.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
15
counter/static/counter/css/product_type.scss
Normal file
15
counter/static/counter/css/product_type.scss
Normal 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;
|
||||||
|
}
|
64
counter/templates/counter/product_type_list.jinja
Normal file
64
counter/templates/counter/product_type_list.jinja
Normal 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 %}
|
@ -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 %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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"))
|
||||||
|
89
counter/tests/test_product_type.py
Normal file
89
counter/tests/test_product_type.py
Normal 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
|
@ -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"),
|
||||||
|
@ -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",
|
||||||
]
|
]
|
||||||
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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`
|
||||||
|
@ -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
@ -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
16
package-lock.json
generated
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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 = []
|
||||||
)
|
|
||||||
]
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
||||||
).exists():
|
|
||||||
Notification(
|
|
||||||
user=user,
|
user=user,
|
||||||
url=reverse("pedagogy:moderation"),
|
url=reverse("pedagogy:moderation"),
|
||||||
type="PEDAGOGY_MODERATION",
|
type="PEDAGOGY_MODERATION",
|
||||||
).save()
|
)
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user