25 Commits

Author SHA1 Message Date
imperosol
f1a60e589a remove unused settings 2026-03-12 10:26:40 +01:00
thomas girod
b4a6b6961b Merge pull request #1307 from ae-utbm/counter-sellers
Counter sellers
2026-03-11 18:09:49 +01:00
thomas girod
0f0702825e Merge pull request #1281 from ae-utbm/test_election
add test_election_form
2026-03-10 19:42:02 +01:00
imperosol
b74b1ac691 refactor TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
33d4a99a2c move form test into a class TestElectionForm 2026-03-10 19:39:40 +01:00
TitouanDor
c154b311c3 add test with wrong data form 2026-03-10 19:39:40 +01:00
TitouanDor
fb8da93c68 add test_election_form 2026-03-10 19:39:40 +01:00
thomas girod
1845a7cbcf Merge pull request #1312 from ae-utbm/dynamic-formset
Dynamic formset
2026-03-10 19:31:49 +01:00
imperosol
f17f17d8de use dynamic formset for product action formset 2026-03-10 19:26:30 +01:00
imperosol
7bb3d064ee add dynamic-formset-index.ts 2026-03-10 19:26:30 +01:00
imperosol
4f84ec09d7 add tests 2026-03-10 19:26:05 +01:00
imperosol
7e649b40c5 add translation 2026-03-10 19:26:05 +01:00
thomas girod
296feb6e32 Merge pull request #1305 from ae-utbm/user-all-groups
User all groups
2026-03-10 19:08:24 +01:00
imperosol
30663d87a4 directly work on group ids 2026-03-09 19:36:15 +01:00
thomas girod
b5ff9b4c13 Merge pull request #1314 from ae-utbm/user-clubs
feat: API route to get user memberships
2026-03-09 19:06:30 +01:00
imperosol
e2f6671ad0 apply review comments 2026-03-09 18:59:41 +01:00
imperosol
9a67926a49 feat: API route to get user memberships 2026-03-09 18:11:23 +01:00
imperosol
78c373f84e differentiate regular and temporary barmen on the counter edit view 2026-03-09 16:04:46 +01:00
imperosol
a7c8b318bd add fields to CounterSellers 2026-03-09 16:04:46 +01:00
imperosol
1701ab5f33 feat: custom through model for Counter.sellers 2026-03-09 16:04:46 +01:00
imperosol
09a98db786 refactor election views permission check 2026-03-09 16:04:19 +01:00
imperosol
84ed180c1e refactor sas moderation view permission 2026-03-09 16:04:19 +01:00
imperosol
52759764a1 feat: User.all_groups 2026-03-09 16:04:19 +01:00
Titouan
be1563f46f Merge pull request #1313 from ae-utbm/price_fix
modify price on discount
2026-03-08 15:37:26 +01:00
TitouanDor
5d3d44ec67 modify price on discount 2026-03-08 15:09:46 +01:00
22 changed files with 970 additions and 359 deletions

View File

@@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club") @api_controller("/club")
@@ -38,3 +44,22 @@ class ClubController(ControllerBase):
return self.get_object_or_exception( return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id Club.objects.prefetch_related(prefetch), id=club_id
) )
@api_controller("/user/{int:user_id}/club")
class UserClubController(ControllerBase):
@route.get(
"",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanView],
url_name="fetch_user_clubs",
)
def fetch_user_clubs(self, user_id: int):
"""Get all the active memberships of the given user."""
user = self.get_object_or_exception(User, id=user_id)
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user")
)

View File

@@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema):
class ClubMemberSchema(ModelSchema): class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta: class Meta:
model = Membership model = Membership
fields = ["start_date", "end_date", "role", "description"] fields = ["start_date", "end_date", "role", "description"]
@@ -53,3 +55,13 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"] fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema] members: list[ClubMemberSchema]
class UserMembershipSchema(ModelSchema):
"""A schema to represent the active club memberships of a user."""
class Meta:
model = Membership
fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema

View File

@@ -0,0 +1,50 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
class TestFetchClub(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
)
cls.members = Membership.objects.bulk_create(
[
recipe.prepare(club=clubs[0]),
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
recipe.prepare(club=clubs[1]),
]
)
def test_fetch_memberships(self):
self.client.force_login(subscriber_user.make())
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
]
def test_fetch_club_nb_queries(self):
self.client.force_login(subscriber_user.make())
with self.assertNumQueries(6):
# - 5 queries for authentication
# - 1 query for the actual data
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200

View File

@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False return False
if super().has_permission(): if super().has_permission():
return True return True
return self.club is not None and any( return (
g.id == self.club.board_group_id for g in self.request.user.cached_groups self.club is not None
and self.club.board_group_id in self.request.user.all_groups
) )

View File

@@ -356,23 +356,27 @@ class User(AbstractUser):
) )
if group_id is None: if group_id is None:
return False return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID: return group_id in self.all_groups
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
@cached_property @cached_property
def cached_groups(self) -> list[Group]: def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in.""" """Get the list of groups this user is in."""
return list(self.groups.all()) additional_groups = []
if self.is_subscribed:
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
if self.is_superuser:
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
qs = self.groups.all()
if additional_groups:
# This is somewhat counter-intuitive, but this query runs way faster with
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
qs = qs.union(Group.objects.filter(id__in=additional_groups))
return {g.id: g for g in qs}
@cached_property @cached_property
def is_root(self) -> bool: def is_root(self) -> bool:
if self.is_superuser: return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
return True
root_id = settings.SITH_GROUP_ROOT_ID
return any(g.id == root_id for g in self.cached_groups)
@cached_property @cached_property
def is_board_member(self) -> bool: def is_board_member(self) -> bool:
@@ -1099,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID) return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"): if user.has_perm("core.view_page"):
return self.all() return self.all()
groups_ids = [g.id for g in user.cached_groups] return self.filter(view_groups__in=user.all_groups)
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
# This function prevents generating migration upon settings change # This function prevents generating migration upon settings change
@@ -1376,7 +1377,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups) return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float: def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text. """Similarity ratio between this revision's content and the given text.

View File

@@ -0,0 +1,77 @@
interface Config {
/**
* The prefix of the formset, in case it has been changed.
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
*/
prefix?: string;
}
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
document.addEventListener("alpine:init", () => {
/**
* Alpine data element to allow the dynamic addition of forms to a formset.
*
* To use this, you need :
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
* - a template containing the empty form
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
* noted by `x-ref="formTemplate"`
* - a button with `@click="addForm"`
* - you may also have one or more buttons with `@click="removeForm(element)"`,
* where `element` is the HTML element containing the form.
*
* For an example of how this is used, you can have a look to
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace("__prefix__", this.nbForms.toString());
el.id = el.id.replace("__prefix__", this.nbForms.toString());
}
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
}
inputs[0].focus();
this.nbForms += 1;
},
removeForm(container: HTMLDivElement) {
container.remove();
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace(/\d+/, i.toString());
el.id = el.id.replace(/\d+/, i.toString());
}
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
}
}
},
}));
});

View File

@@ -35,8 +35,8 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script> <script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script> <script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script> <script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>

View File

@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
group_in = baker.make(Group) group_in = baker.make(Group)
self.public_user.groups.add(group_in) self.public_user.groups.add(group_in)
# clear the cached property `User.cached_groups` # clear the cached property `User.all_groups`
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("all_groups", None)
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(1): with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group) group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("all_groups", None)
# Test when the user is not in the group # Test when the user is not in the group
with self.assertNumQueries(1): with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
@@ -15,7 +16,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User from core.models import User, UserQuerySet
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField, FutureDateTimeField,
NFCTextInput, NFCTextInput,
@@ -32,6 +33,7 @@ from core.views.widgets.ajax_select import (
from counter.models import ( from counter.models import (
BillingInfo, BillingInfo,
Counter, Counter,
CounterSellers,
Customer, Customer,
Eticket, Eticket,
InvoiceCall, InvoiceCall,
@@ -170,14 +172,39 @@ class RefillForm(forms.ModelForm):
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
class Meta: class Meta:
model = Counter model = Counter
fields = ["sellers", "products"] fields = ["products"]
widgets = {"sellers": AutoCompleteSelectMultipleUser}
sellers_regular = forms.ModelMultipleChoiceField(
label=_("Regular barmen"),
help_text=_(
"Barmen having regular permanences "
"or frequently giving a hand throughout the semester."
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
sellers_temporary = forms.ModelMultipleChoiceField(
label=_("Temporary barmen"),
help_text=_(
"Barmen who will be there only for a limited period (e.g. for one evening)"
),
queryset=User.objects.all(),
widget=AutoCompleteSelectMultipleUser,
required=False,
)
field_order = ["sellers_regular", "sellers_temporary", "products"]
def __init__(self, *args, user: User, instance: Counter, **kwargs): def __init__(self, *args, user: User, instance: Counter, **kwargs):
super().__init__(*args, instance=instance, **kwargs) super().__init__(*args, instance=instance, **kwargs)
# if the user is an admin, he will have access to all products,
# else only to active products owned by the counter's club
# or already on the counter
if user.has_perm("counter.change_counter"): if user.has_perm("counter.change_counter"):
self.fields["products"].widget = AutoCompleteSelectMultipleProduct() self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
else: else:
# updating the queryset of the field also updates the choices of
# the widget, so it's important to set the queryset after the widget
self.fields["products"].widget = AutoCompleteSelectMultiple() self.fields["products"].widget = AutoCompleteSelectMultiple()
self.fields["products"].queryset = Product.objects.filter( self.fields["products"].queryset = Product.objects.filter(
Q(club_id=instance.club_id) | Q(counters=instance), archived=False Q(club_id=instance.club_id) | Q(counters=instance), archived=False
@@ -186,6 +213,61 @@ class CounterEditForm(forms.ModelForm):
"If you want to add a product that is not owned by " "If you want to add a product that is not owned by "
"your club to this counter, you should ask an admin." "your club to this counter, you should ask an admin."
) )
self.fields["sellers_regular"].initial = self.instance.sellers.filter(
countersellers__is_regular=True
).all()
self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
countersellers__is_regular=False
).all()
def clean(self):
regular: UserQuerySet = self.cleaned_data["sellers_regular"]
temporary: UserQuerySet = self.cleaned_data["sellers_temporary"]
duplicates = list(regular.intersection(temporary))
if duplicates:
raise ValidationError(
_(
"A user cannot be a regular and a temporary barman "
"at the same time, "
"but the following users have been defined as both : %(users)s"
)
% {"users": ", ".join([u.get_display_name() for u in duplicates])}
)
return self.cleaned_data
def save_sellers(self):
sellers = []
for users, is_regular in (
(self.cleaned_data["sellers_regular"], True),
(self.cleaned_data["sellers_temporary"], False),
):
sellers.extend(
[
CounterSellers(counter=self.instance, user=u, is_regular=is_regular)
for u in users
]
)
# start by deleting removed CounterSellers objects
user_ids = [seller.user.id for seller in sellers]
CounterSellers.objects.filter(
~Q(user_id__in=user_ids), counter=self.instance
).delete()
# then create or update the new barmen
CounterSellers.objects.bulk_create(
sellers,
update_conflicts=True,
update_fields=["is_regular"],
unique_fields=["user", "counter"],
)
def save(self, commit=True): # noqa: FBT002
self.instance = super().save(commit=commit)
if commit and any(
key in self.changed_data for key in ("sellers_regular", "sellers_temporary")
):
self.save_sellers()
return self.instance
class ScheduledProductActionForm(forms.ModelForm): class ScheduledProductActionForm(forms.ModelForm):
@@ -291,7 +373,8 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
absolute_max=None, absolute_max=None,
can_delete=True, can_delete=True,
can_delete_extra=False, can_delete_extra=False,
extra=2, extra=0,
min_num=1,
) )

View File

@@ -0,0 +1,88 @@
# Generated by Django 5.2.11 on 2026-03-04 15:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("counter", "0037_productformula"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunSQL(
sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers",
reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers",
),
],
state_operations=[
migrations.CreateModel(
name="CounterSellers",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"counter",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="counter.counter",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("counter", "user"),
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
],
},
),
migrations.AlterField(
model_name="counter",
name="sellers",
field=models.ManyToManyField(
blank=True,
related_name="counters",
through="counter.CounterSellers",
to=settings.AUTH_USER_MODEL,
verbose_name="sellers",
),
),
],
),
migrations.AddField(
model_name="countersellers",
name="created_at",
field=models.DateTimeField(
auto_now_add=True,
default=django.utils.timezone.now,
verbose_name="created at",
),
preserve_default=False,
),
migrations.AddField(
model_name="countersellers",
name="is_regular",
field=models.BooleanField(default=False, verbose_name="regular barman"),
),
]

View File

@@ -551,7 +551,11 @@ class Counter(models.Model):
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))], choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
) )
sellers = models.ManyToManyField( sellers = models.ManyToManyField(
User, verbose_name=_("sellers"), related_name="counters", blank=True User,
verbose_name=_("sellers"),
related_name="counters",
blank=True,
through="CounterSellers",
) )
edit_groups = models.ManyToManyField( edit_groups = models.ManyToManyField(
Group, related_name="editable_counters", blank=True Group, related_name="editable_counters", blank=True
@@ -743,6 +747,26 @@ class Counter(models.Model):
] ]
class CounterSellers(models.Model):
"""Custom through model for the counter-sellers M2M relationship."""
counter = models.ForeignKey(Counter, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
is_regular = models.BooleanField(_("regular barman"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["counter", "user"],
name="counter_counter_sellers_counter_id_subscriber_id_key",
)
]
def __str__(self):
return f"counter {self.counter_id} - user {self.user_id}"
class RefillingQuerySet(models.QuerySet): class RefillingQuerySet(models.QuerySet):
def annotate_total(self) -> Self: def annotate_total(self) -> Self:
"""Annotate the Queryset with the total amount. """Annotate the Queryset with the total amount.

View File

@@ -1,5 +1,44 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% macro action_form(form) %}
<fieldset x-data="{action: '{{ form.task.initial }}'}">
{{ form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ form.task.errors }}
{{ form.task.label_tag() }}
{{ form.task|add_attr("x-model=action") }}
</div>
<div>{{ form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ form.counters.as_field_group() }}
</div>
{%- if form.DELETE -%}
<div class="row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
<hr />
</fieldset>
{% endmacro %}
{% block content %} {% block content %}
{% if object %} {% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2> <h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -25,34 +64,20 @@
</em> </em>
</p> </p>
{{ form.action_formset.management_form }} <div x-data="dynamicFormSet" class="margin-bottom">
{%- for action_form in form.action_formset.forms -%} {{ form.action_formset.management_form }}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}"> <div x-ref="formContainer">
{{ action_form.non_field_errors() }} {%- for f in form.action_formset.forms -%}
<div class="row gap-2x margin-bottom"> {{ action_form(f) }}
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>{{ action_form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{%- if action_form.DELETE -%}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{%- endif -%}
{%- for field in action_form.hidden_fields() -%}
{{ field }}
{%- endfor -%} {%- endfor -%}
</fieldset> </div>
{%- if not loop.last -%} <template x-ref="formTemplate">
<hr class="margin-bottom"> {{ action_form(form.action_formset.empty_form) }}
{%- endif -%} </template>
{%- endfor -%} <button @click.prevent="addForm()" class="btn btn-grey">
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -1,13 +1,132 @@
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from club.models import Membership from club.models import Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import Group, User
from counter.baker_recipes import product_recipe from counter.baker_recipes import product_recipe
from counter.forms import CounterEditForm from counter.forms import CounterEditForm
from counter.models import Counter from counter.models import Counter, CounterSellers
class TestEditCounterSellers(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter, type="BAR")
cls.products = product_recipe.make(_quantity=2, _bulk_create=True)
cls.counter.products.add(*cls.products)
users = subscriber_user.make(_quantity=6, _bulk_create=True)
cls.regular_barmen = users[:2]
cls.tmp_barmen = users[2:4]
cls.not_barmen = users[4:]
CounterSellers.objects.bulk_create(
[
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.regular_barmen),
is_regular=True,
_quantity=len(cls.regular_barmen),
),
*baker.prepare(
CounterSellers,
counter=cls.counter,
user=iter(cls.tmp_barmen),
is_regular=False,
_quantity=len(cls.tmp_barmen),
),
]
)
cls.operator = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
def test_view_ok(self):
url = reverse("counter:admin", kwargs={"counter_id": self.counter.id})
self.client.force_login(self.operator)
res = self.client.get(url)
assert res.status_code == 200
res = self.client.post(
url,
data={
"sellers_regular": [u.id for u in self.regular_barmen],
"sellers_temporary": [u.id for u in self.tmp_barmen],
"products": [p.id for p in self.products],
},
)
self.assertRedirects(res, url)
def test_add_barmen(self):
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.not_barmen[0],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == {
*self.tmp_barmen,
self.not_barmen[1],
}
def test_barman_change_status(self):
"""Test when a barman goes from temporary to regular"""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]],
"sellers_temporary": [*self.tmp_barmen[1:]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert form.is_valid()
form.save()
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
*self.regular_barmen,
self.tmp_barmen[0],
}
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen[1:])
def test_barman_duplicate(self):
"""Test that a barman cannot be regular and temporary at the same time."""
form = CounterEditForm(
data={
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]],
"products": self.products,
},
instance=self.counter,
user=self.operator,
)
assert not form.is_valid()
assert form.errors == {
"__all__": [
"Un utilisateur ne peut pas être un barman "
"régulier et temporaire en même temps, "
"mais les utilisateurs suivants ont été définis "
f"comme les deux : {self.not_barmen[0].get_display_name()}"
],
}
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set(
self.regular_barmen
)
assert set(
self.counter.sellers.filter(countersellers__is_regular=False)
) == set(self.tmp_barmen)
class TestEditCounterProducts(TestCase): class TestEditCounterProducts(TestCase):

View File

@@ -16,6 +16,7 @@ from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
@@ -58,7 +59,9 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
current_tab = "counters" current_tab = "counters"
class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView): class CounterEditView(
CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
"""Edit a counter's main informations (for the counter's manager).""" """Edit a counter's main informations (for the counter's manager)."""
model = Counter model = Counter
@@ -66,6 +69,7 @@ class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "counters" current_tab = "counters"
success_message = _("Counter update done")
def test_func(self): def test_func(self):
if self.request.user.has_perm("counter.change_counter"): if self.request.user.has_perm("counter.change_counter"):

View File

@@ -6,6 +6,8 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
@@ -52,6 +54,102 @@ class TestElectionUpdateView(TestElection):
assert response.status_code == 403 assert response.status_code == 403
class TestElectionForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.election = baker.make(Election, end_date=now() + timedelta(days=1))
cls.group = baker.make(Group)
cls.election.vote_groups.add(cls.group)
cls.election.edit_groups.add(cls.group)
lists = baker.make(
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
)
cls.roles = baker.make(
Role, election=cls.election, _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
recipe = Recipe(Candidature)
cls.cand = [
recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]),
recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]),
recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]),
recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]),
]
Candidature.objects.bulk_create(cls.cand)
cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id})
cls.detail_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
def test_election_good_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[2].id)},
{postes[0]: "", postes[1]: ""},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)},
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
assert self.election.can_vote(voter)
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert set(self.election.voters.all()) == set(voters)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 50.0, "vote": 2},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 50.0, "vote": 2},
"total vote": 4,
},
postes[1]: {
self.cand[2].user.username: {"percent": 50.0, "vote": 2},
self.cand[3].user.username: {"percent": 25.0, "vote": 1},
"blank vote": {"percent": 25.0, "vote": 1},
"total vote": 4,
},
}
def test_election_bad_form(self):
postes = (self.roles[0].title, self.roles[1].title)
votes = [
{postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate
{postes[0]: ""},
{
postes[0]: "0123456789", # unknow users
postes[1]: str(subscriber_user.make().id), # not a candidate
},
{},
]
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
self.group.users.set(voters)
for voter, vote in zip(voters, votes, strict=True):
self.client.force_login(voter)
response = self.client.post(self.vote_url, data=vote)
assertRedirects(response, self.detail_url)
assert self.election.results == {
postes[0]: {
self.cand[0].user.username: {"percent": 0.0, "vote": 0},
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
postes[1]: {
self.cand[2].user.username: {"percent": 0.0, "vote": 0},
self.cand[3].user.username: {"percent": 0.0, "vote": 0},
"blank vote": {"percent": 100.0, "vote": 2},
"total vote": 2,
},
}
@pytest.mark.django_db @pytest.mark.django_db
def test_election_create_list_permission(client: Client): def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1)) election = baker.make(Election, end_candidature=now() + timedelta(hours=1))

View File

@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from cryptography.utils import cached_property from cryptography.utils import cached_property
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import ( from django.contrib.auth.mixins import (
LoginRequiredMixin, LoginRequiredMixin,
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
def test_func(self): def test_func(self):
if not self.election.can_vote(self.request.user): if not self.election.can_vote(self.request.user):
return False return False
return self.election.vote_groups.filter(
groups = set(self.election.vote_groups.values_list("id", flat=True)) id__in=self.request.user.all_groups
if ( ).exists()
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def vote(self, election_data): def vote(self, election_data):
with transaction.atomic(): with transaction.atomic():
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
return False return False
if self.request.user.has_perm("election.add_role"): if self.request.user.has_perm("election.add_role"):
return True return True
groups = set(self.election.edit_groups.values_list("id", flat=True)) return self.election.edit_groups.filter(
if ( id__in=self.request.user.all_groups
settings.SITH_GROUP_SUBSCRIBERS_ID in groups ).exists()
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} return {"election": self.election}
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
.union(self.election.edit_groups.values("id")) .union(self.election.edit_groups.values("id"))
.values_list("id", flat=True) .values_list("id", flat=True)
) )
if ( return not groups.isdisjoint(self.request.user.all_groups.keys())
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def get_initial(self): def get_initial(self):
return {"election": self.election} return {"election": self.election}

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-07 15:47+0100\n" "POT-Creation-Date: 2026-03-10 10:28+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -2937,6 +2937,29 @@ msgstr "Cet UID est invalide"
msgid "User not found" msgid "User not found"
msgstr "Utilisateur non trouvé" msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "Regular barmen"
msgstr "Barmen réguliers"
#: counter/forms.py
msgid ""
"Barmen having regular permanences or frequently giving a hand throughout the "
"semester."
msgstr ""
"Les barmen assurant des permanences régulières ou donnant régulièrement un "
"coup de main au cours du semestre."
#: counter/forms.py
msgid "Temporary barmen"
msgstr "Barmen temporaires"
#: counter/forms.py
msgid ""
"Barmen who will be there only for a limited period (e.g. for one evening)"
msgstr ""
"Les barmen qui seront là uniquement pour une durée limitée (par exemple, le "
"temps d'une soirée)"
#: counter/forms.py #: counter/forms.py
msgid "" msgid ""
"If you want to add a product that is not owned by your club to this counter, " "If you want to add a product that is not owned by your club to this counter, "
@@ -2945,6 +2968,16 @@ msgstr ""
"Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à " "Si vous souhaitez ajouter sur ce comptoir un produit qui n'appartient pas à "
"votre club, vous devriez demander à un admin." "votre club, vous devriez demander à un admin."
#: counter/forms.py
#, python-format
msgid ""
"A user cannot be a regular and a temporary barman at the same time, but the "
"following users have been defined as both : %(users)s"
msgstr ""
"Un utilisateur ne peut pas être un barman régulier et temporaire en même "
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py #: counter/forms.py
msgid "Date and time of action" msgid "Date and time of action"
msgstr "Date et heure de l'action" msgstr "Date et heure de l'action"
@@ -3193,6 +3226,10 @@ msgstr "vendeurs"
msgid "token" msgid "token"
msgstr "jeton" msgstr "jeton"
#: counter/models.py
msgid "regular barman"
msgstr "barman régulier"
#: counter/models.py sith/settings.py #: counter/models.py sith/settings.py
msgid "Credit card" msgid "Credit card"
msgstr "Carte bancaire" msgstr "Carte bancaire"
@@ -3757,6 +3794,10 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte." "aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#, python-format #, python-format
msgid "Edit product %(name)s" msgid "Edit product %(name)s"
@@ -3784,6 +3825,10 @@ msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du " "Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance." "produit à l'avance."
#: counter/templates/counter/product_form.jinja
msgid "Add action"
msgstr "Ajouter une action"
#: counter/templates/counter/product_list.jinja #: counter/templates/counter/product_list.jinja
msgid "Product list" msgid "Product list"
msgstr "Liste des produits" msgstr "Liste des produits"
@@ -3897,6 +3942,10 @@ msgstr "Temps"
msgid "Top 100 barman %(counter_name)s (all semesters)" msgid "Top 100 barman %(counter_name)s (all semesters)"
msgstr "Top 100 barman %(counter_name)s (tous les semestres)" msgstr "Top 100 barman %(counter_name)s (tous les semestres)"
#: counter/views/admin.py
msgid "Counter update done"
msgstr "Mise à jour du comptoir effectuée"
#: counter/views/admin.py #: counter/views/admin.py
#, python-format #, python-format
msgid "%(formula)s (formula)" msgid "%(formula)s (formula)"
@@ -5245,8 +5294,6 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
#, fuzzy
#| msgid "GA staff member"
msgid "GA staff member" msgid "GA staff member"
msgstr "Membre staff GA" msgstr "Membre staff GA"

View File

@@ -109,232 +109,225 @@ interface ViewerConfig {
/** id of the first picture to load on the page */ /** id of the first picture to load on the page */
firstPictureId: number; firstPictureId: number;
/** if the user is sas admin */ /** if the user is sas admin */
userIsSasAdmin: boolean; userCanModerate: boolean;
} }
/** /**
* Load user picture page with a nice download bar * Load user picture page with a nice download bar
**/ **/
exportToHtml("loadViewer", (config: ViewerConfig) => { document.addEventListener("alpine:init", () => {
document.addEventListener("alpine:init", () => { Alpine.data("picture_viewer", (config: ViewerConfig) => ({
Alpine.data("picture_viewer", () => ({ /**
/** * All the pictures that can be displayed on this picture viewer
* All the pictures that can be displayed on this picture viewer **/
**/ pictures: [] as PictureWithIdentifications[],
pictures: [] as PictureWithIdentifications[], /**
/** * The currently displayed picture
* The currently displayed picture * Default dummy data are pre-loaded to avoid javascript error
* Default dummy data are pre-loaded to avoid javascript error * when loading the page at the beginning
* when loading the page at the beginning * @type PictureWithIdentifications
* @type PictureWithIdentifications **/
**/ currentPicture: {
currentPicture: { // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case is_moderated: true,
is_moderated: true, id: null as number,
id: null as number, name: "",
name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case display_name: "",
display_name: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case compressed_url: "",
compressed_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case profile_url: "",
profile_url: "", // biome-ignore lint/style/useNamingConvention: api is in snake_case
// biome-ignore lint/style/useNamingConvention: api is in snake_case full_size_url: "",
full_size_url: "", owner: "",
owner: "", date: new Date(),
date: new Date(), identifications: [] as IdentifiedUserSchema[],
identifications: [] as IdentifiedUserSchema[], },
}, /**
/** * The picture which will be displayed next if the user press the "next" button
* The picture which will be displayed next if the user press the "next" button **/
**/ nextPicture: null as PictureWithIdentifications,
nextPicture: null as PictureWithIdentifications, /**
/** * The picture which will be displayed next if the user press the "previous" button
* The picture which will be displayed next if the user press the "previous" button **/
**/ previousPicture: null as PictureWithIdentifications,
previousPicture: null as PictureWithIdentifications, /**
/** * The select2 component used to identify users
* The select2 component used to identify users **/
**/ selector: undefined as UserAjaxSelect,
selector: undefined as UserAjaxSelect, /**
/** * Error message when a moderation operation fails
* Error message when a moderation operation fails **/
**/ moderationError: "",
moderationError: "", /**
/** * Method of pushing new url to the browser history
* Method of pushing new url to the browser history * Used by popstate event and always reset to it's default value when used
* Used by popstate event and always reset to it's default value when used **/
**/ pushstate: History.Push,
pushstate: History.Push,
async init() { async init() {
this.pictures = ( this.pictures = (
await paginated(picturesFetchPictures, { await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search; this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = []; const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
]; ];
for (const user of users) { for (const user of users) {
if (!ids.includes(user.id)) { if (!ids.includes(user.id)) {
resp.push(user); resp.push(user);
}
} }
return resp; }
}); return resp;
this.currentPicture = this.pictures.find( });
(i: PictureSchema) => i.id === config.firstPictureId, this.currentPicture = this.pictures.find(
); (i: PictureSchema) => i.id === config.firstPictureId,
this.$watch( );
"currentPicture", this.$watch(
(current: PictureSchema, previous: PictureSchema) => { "currentPicture",
if (current === previous) { (current: PictureSchema, previous: PictureSchema) => {
/* Avoid recursive updates */ if (current === previous) {
return; /* Avoid recursive updates */
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.pushstate = History.Replace; this.updatePicture();
this.currentPicture = this.pictures.find( },
(i: PictureSchema) => );
i.id === Number.parseInt(event.state.sasPictureId, 10), window.addEventListener("popstate", async (event) => {
); if (!event.state || event.state.sasPictureId === undefined) {
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(
updateArgs.data,
updateArgs.unused,
updateArgs.url,
);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return; return;
} }
this.currentPicture.is_moderated = true; this.pushstate = History.Replace;
this.currentPicture.asked_for_removal = false; this.currentPicture = this.pictures.find(
}, (i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
async deletePicture() { /**
const res = await picturesDeletePicture({ * Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userCanModerate) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
async deletePicture() {
const res = await picturesDeletePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id }, picture_id: this.currentPicture.id,
}); },
if (res.error) { body: widget.items.map((i: string) => Number.parseInt(i, 10)),
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`; });
return; // refresh the identified users list
} await this.currentPicture.loadIdentifications({ forceReload: true });
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/** // Clear selection and cache of retrieved user so they can be filtered again
* Send the identification request and update the list of identified users. widget.clear(false);
*/ widget.clearOptions();
async submitIdentification(): Promise<void> { widget.setTextboxValue("");
const widget: TomSelect = this.selector.widget; },
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
// Clear selection and cache of retrieved user so they can be filtered again /**
widget.clear(false); * Check if an identification can be removed by the currently logged user
widget.clearOptions(); */
widget.setTextboxValue(""); canBeRemoved(identification: IdentifiedUserSchema): boolean {
}, return config.userCanModerate || identification.user.id === config.userId;
},
/** /**
* Check if an identification can be removed by the currently logged user * Untag a user from the current picture
*/ */
canBeRemoved(identification: IdentifiedUserSchema): boolean { async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
return config.userIsSasAdmin || identification.user.id === config.userId; const res = await usersidentifiedDeleteRelation({
}, // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
/** });
* Untag a user from the current picture if (!res.error && Array.isArray(this.currentPicture.identifications)) {
*/ this.currentPicture.identifications =
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> { this.currentPicture.identifications.filter(
const res = await usersidentifiedDeleteRelation({ (i: IdentifiedUserSchema) => i.id !== identification.id,
// biome-ignore lint/style/useNamingConvention: api is in snake_case );
path: { relation_id: identification.id }, }
}); },
if (!res.error && Array.isArray(this.currentPicture.identifications)) { }));
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
});
}); });

View File

@@ -17,10 +17,8 @@
{% from "sas/macros.jinja" import print_path %} {% from "sas/macros.jinja" import print_path %}
{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% block content %} {% block content %}
<main x-data="picture_viewer"> <main x-data="picture_viewer(config)">
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span> <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
</code> </code>
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated. It will be hidden to other users until it has been moderated.
{% endtrans %} {% endtrans %}
</p> </p>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<template x-if="currentPicture.asked_for_removal"> <template x-if="currentPicture.asked_for_removal">
<div> <div>
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5> <h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id"> <template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div> <div>
<h6 <h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<i x-text="Intl.DateTimeFormat( <i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', '{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'} {dateStyle: 'long', timeStyle: 'short'}
@@ -70,7 +66,7 @@
</template> </template>
{% endif %} {% endif %}
</div> </div>
{% if user_is_sas_admin %} {% if user.has_perm("sas.moderate_sasfile") %}
<div class="alert-aside"> <div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()"> <button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
@@ -204,16 +200,13 @@
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }}
<script> <script>
window.addEventListener("DOMContentLoaded", () => { const config = {
loadViewer({ albumId: {{ album.id }},
albumId: {{ album.id }} , albumUrl: "{{ album.get_absolute_url() }}",
albumUrl: "{{ album.get_absolute_url() }}", firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #} userId: {{ user.id }},
userId: {{ user.id }}, userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
userIsSasAdmin: {{ user_is_sas_admin|tojson }} }
});
})
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -161,16 +161,22 @@ class TestSasModeration(TestCase):
assert len(res.context_data["pictures"]) == 1 assert len(res.context_data["pictures"]) == 1
assert res.context_data["pictures"][0] == self.to_moderate assert res.context_data["pictures"][0] == self.to_moderate
res = self.client.post(
reverse("sas:moderation"),
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
)
def test_moderation_page_forbidden(self): def test_moderation_page_forbidden(self):
self.client.force_login(self.simple_user) self.client.force_login(self.simple_user)
res = self.client.get(reverse("sas:moderation")) res = self.client.get(reverse("sas:moderation"))
assert res.status_code == 403 assert res.status_code == 403
def test_moderate_album(self):
self.client.force_login(self.moderator)
url = reverse("sas:moderation")
album = baker.make(
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
)
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
assertRedirects(res, url)
album.refresh_from_db()
assert album.is_moderated
def test_moderate_picture(self): def test_moderate_picture(self):
self.client.force_login(self.moderator) self.client.force_login(self.moderator)
res = self.client.get( res = self.client.get(

View File

@@ -15,10 +15,10 @@
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
@@ -191,26 +191,21 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views # Admin views
class ModerationView(TemplateView): class ModerationView(PermissionRequiredMixin, TemplateView):
template_name = "sas/moderation.jinja" template_name = "sas/moderation.jinja"
permission_required = "sas.moderate_sasfile"
def get(self, request, *args, **kwargs):
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return super().get(request, *args, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if "album_id" not in request.POST: if "album_id" not in request.POST:
raise Http404 raise Http404
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): album = get_object_or_404(Album, pk=request.POST["album_id"])
album = get_object_or_404(Album, pk=request.POST["album_id"]) if "moderate" in request.POST:
if "moderate" in request.POST: album.moderator = request.user
album.moderator = request.user album.is_moderated = True
album.is_moderated = True album.save()
album.save() elif "delete" in request.POST:
elif "delete" in request.POST: album.delete()
album.delete() return redirect(self.request.path)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)

View File

@@ -355,7 +355,6 @@ SITH_TWITTER = "@ae_utbm"
# AE configuration # AE configuration
SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1) SITH_MAIN_CLUB_ID = env.int("SITH_MAIN_CLUB_ID", default=1)
SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2) SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2)
SITH_LAUNDERETTE_CLUB_ID = env.int("SITH_LAUNDERETTE_CLUB_ID", default=84)
# Main root for club pages # Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs" SITH_CLUB_ROOT_PAGE = "clubs"
@@ -483,13 +482,6 @@ SITH_LOG_OPERATION_TYPE = [
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide"
SITH_ECOCUP_CONS = env.int("SITH_ECOCUP_CONS", default=1151)
SITH_ECOCUP_DECO = env.int("SITH_ECOCUP_DECO", default=1152)
# The limit is the maximum difference between cons and deco possible for a customer
SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -512,7 +504,6 @@ SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER = env.int(
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int( SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS = env.int(
"SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2 "SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS", default=2
) )
SITH_PRODUCTTYPE_SUBSCRIPTION = env.int("SITH_PRODUCTTYPE_SUBSCRIPTION", default=2)
# Number of weeks before the end of a subscription when the subscriber can resubscribe # Number of weeks before the end of a subscription when the subscriber can resubscribe
SITH_SUBSCRIPTION_END = 10 SITH_SUBSCRIPTION_END = 10
@@ -551,27 +542,27 @@ SITH_SUBSCRIPTIONS = {
# Discount subscriptions # Discount subscriptions
"un-semestre-reduction": { "un-semestre-reduction": {
"name": _("One semester (-20%)"), "name": _("One semester (-20%)"),
"price": 12, "price": 16,
"duration": 1, "duration": 1,
}, },
"deux-semestres-reduction": { "deux-semestres-reduction": {
"name": _("Two semesters (-20%)"), "name": _("Two semesters (-20%)"),
"price": 22, "price": 28,
"duration": 2, "duration": 2,
}, },
"cursus-tronc-commun-reduction": { "cursus-tronc-commun-reduction": {
"name": _("Common core cursus (-20%)"), "name": _("Common core cursus (-20%)"),
"price": 36, "price": 48,
"duration": 4, "duration": 4,
}, },
"cursus-branche-reduction": { "cursus-branche-reduction": {
"name": _("Branch cursus (-20%)"), "name": _("Branch cursus (-20%)"),
"price": 36, "price": 48,
"duration": 6, "duration": 6,
}, },
"cursus-alternant-reduction": { "cursus-alternant-reduction": {
"name": _("Alternating cursus (-20%)"), "name": _("Alternating cursus (-20%)"),
"price": 24, "price": 28,
"duration": 6, "duration": 6,
}, },
# CA special offer # CA special offer