Compare commits

..
Author SHA1 Message Date
dependabot[bot] a3cbfa5413 [UPDATE] Update aemark requirement from >=0.1.1 to >=0.1.4
Updates the requirements on aemark to permit the latest version.

---
updated-dependencies:
- dependency-name: aemark
  dependency-version: 0.1.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 00:34:29 +00:00
26 changed files with 1397 additions and 273 deletions

No files matched your search

+113 -2
View File
@@ -31,10 +31,20 @@ from django.forms.models import ModelChoiceField, ModelChoiceIterator
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 club.models import Club, ClubLink, ClubRole, Membership from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectUser from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema from counter.schemas import SaleFilterSchema
@@ -121,6 +131,107 @@ class ClubAdminEditForm(ClubEditForm):
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields] fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
class MailingForm(forms.Form):
"""Form handling mailing lists right."""
ACTION_NEW_MAILING = 1
ACTION_NEW_SUBSCRIPTION = 2
ACTION_REMOVE_SUBSCRIPTION = 3
subscription_users = forms.ModelMultipleChoiceField(
label=_("Users to add"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, club_id, user_id, mailings, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["action"] = forms.TypedChoiceField(
choices=(
(self.ACTION_NEW_MAILING, _("New Mailing")),
(self.ACTION_NEW_SUBSCRIPTION, _("Subscribe")),
(self.ACTION_REMOVE_SUBSCRIPTION, _("Remove")),
),
coerce=int,
label=_("Action"),
initial=1,
required=True,
widget=forms.HiddenInput(),
)
# Generate bulk removal forms, they are never required
for mailing in mailings:
self.fields["removal_" + str(mailing.id)] = forms.ModelMultipleChoiceField(
mailing.subscriptions.all(),
label=_("Remove"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
# Include fields for handling mailing creation
mailing_fields = ("email",)
self.fields.update(forms.fields_for_model(Mailing, fields=mailing_fields))
for field in mailing_fields:
self.fields["mailing_" + field] = self.fields.pop(field)
self.fields["mailing_" + field].required = False
# Include fields for handling subscription creation
subscription_fields = ("mailing", "email")
self.fields.update(
forms.fields_for_model(MailingSubscription, fields=subscription_fields)
)
for field in subscription_fields:
self.fields["subscription_" + field] = self.fields.pop(field)
self.fields["subscription_" + field].required = False
self.fields["subscription_mailing"].queryset = Mailing.objects.filter(
club__id=club_id, is_moderated=True
)
def check_required(self, cleaned_data, field):
"""If the given field doesn't exist or has no value, add a required error on it."""
if not cleaned_data.get(field, None):
self.add_error(field, _("This field is required"))
def clean_subscription_users(self):
"""Convert given users into real users and check their validity."""
cleaned_data = super().clean()
users = []
for user in cleaned_data["subscription_users"]:
if not user.email:
raise forms.ValidationError(
_("One of the selected users doesn't have an email address"),
code="invalid",
)
users.append(user)
return users
def clean(self):
cleaned_data = super().clean()
if "action" not in cleaned_data:
# If there is no action provided, we can stop here
raise forms.ValidationError(_("An action is required"), code="invalid")
if cleaned_data["action"] == self.ACTION_NEW_MAILING:
self.check_required(cleaned_data, "mailing_email")
if cleaned_data["action"] == self.ACTION_NEW_SUBSCRIPTION:
self.check_required(cleaned_data, "subscription_mailing")
if not cleaned_data.get(
"subscription_users", None
) and not cleaned_data.get("subscription_email", None):
raise forms.ValidationError(
_("You must specify at least an user or an email address"),
code="invalid",
)
return cleaned_data
class SellingsForm(forms.Form): class SellingsForm(forms.Form):
begin_date = forms.DateTimeField( begin_date = forms.DateTimeField(
label=_("Begin date"), widget=SelectDateTime, required=False label=_("Begin date"), widget=SelectDateTime, required=False
@@ -1,16 +0,0 @@
# Generated by Django 5.2.17 on 2026-10-15 13:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("club", "0018_clubrole_linked_groups")]
operations = [
migrations.AlterUniqueTogether(
name="mailingsubscription", unique_together=None
),
migrations.RemoveField(model_name="mailingsubscription", name="user"),
migrations.DeleteModel(name="Mailing"),
migrations.DeleteModel(name="MailingSubscription"),
]
+166 -2
View File
@@ -28,7 +28,8 @@ from functools import reduce
from typing import Iterable, Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import ProgrammingError, models, transaction from django.db import ProgrammingError, models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse from django.urls import reverse
@@ -40,7 +41,7 @@ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
class ClubQuerySet(models.QuerySet): class ClubQuerySet(models.QuerySet):
@@ -632,6 +633,169 @@ class Membership(models.Model):
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True) return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model):
"""A Mailing list for a club.
Warning:
Remember that mailing lists should be validated by UTBM.
"""
club = models.ForeignKey(
Club,
verbose_name=_("Club"),
related_name="mailings",
null=False,
blank=False,
on_delete=models.CASCADE,
)
email = models.CharField(
_("Email address"),
unique=True,
null=False,
blank=False,
max_length=256,
validators=[
RegexValidator(
validate_email.user_regex,
_("Enter a valid address. Only the root of the address is needed."),
)
],
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_mailings",
verbose_name=_("moderator"),
null=True,
on_delete=models.CASCADE,
)
def __str__(self):
return "%s - %s" % (self.club, self.email_full)
def save(self, *args, **kwargs):
if not self.is_moderated:
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
Notification(
user=user,
url=reverse("com:mailing_admin"),
type="MAILING_MODERATION",
).save(*args, **kwargs)
super().save(*args, **kwargs)
def clean(self):
if Mailing.objects.filter(email=self.email).exists():
raise ValidationError(_("This mailing list already exists."))
if self.can_moderate(self.moderator):
self.is_moderated = True
else:
self.moderator = None
super().clean()
@property
def email_full(self):
return self.email + "@" + settings.SITH_MAILING_DOMAIN
def can_moderate(self, user):
return user.is_root or user.is_com_admin
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_root or user.is_com_admin
def can_view(self, user):
return self.club.has_rights_in_club(user)
def can_be_edited_by(self, user):
return self.club.has_rights_in_club(user)
def delete(self, *args, **kwargs):
self.subscriptions.all().delete()
super().delete()
def fetch_format(self):
destination = "".join(s.fetch_format() for s in self.subscriptions.all())
return f"{self.email}: {destination}"
class MailingSubscription(models.Model):
"""Link between user and mailing list."""
mailing = models.ForeignKey(
Mailing,
verbose_name=_("Mailing"),
related_name="subscriptions",
null=False,
blank=False,
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
related_name="mailing_subscriptions",
null=True,
blank=True,
on_delete=models.CASCADE,
)
email = models.EmailField(_("Email address"), blank=False, null=False)
class Meta:
unique_together = (("user", "email", "mailing"),)
def __str__(self):
return "(%s) - %s : %s" % (self.mailing, self.get_username, self.email)
def clean(self):
if not self.user and not self.email:
raise ValidationError(_("At least user or email is required"))
try:
if self.user and not self.email:
self.email = self.user.email
if MailingSubscription.objects.filter(
mailing=self.mailing, email=self.email
).exists():
raise ValidationError(
_("This email is already suscribed in this mailing")
)
except ObjectDoesNotExist:
pass
super().clean()
def is_owned_by(self, user):
if user.is_anonymous:
return False
return (
self.mailing.club.has_rights_in_club(user)
or user.is_root
or self.user.is_com_admin
)
def can_be_edited_by(self, user):
return self.user is not None and user.id == self.user.id
@cached_property
def get_email(self):
if self.user and not self.email:
return self.user.email
return self.email
@cached_property
def get_username(self):
if self.user:
return str(self.user)
return _("Unregistered user")
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model): class LinkType(models.Model):
"""A link type, in order to group links and give them icons. """A link type, in order to group links and give them icons.
+3
View File
@@ -15,6 +15,9 @@
{% trans %}Post in the Weekmail{% endtrans %} {% trans %}Post in the Weekmail{% endtrans %}
</a> </a>
</li> </li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
{% if object.trombi %} {% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li> <li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %} {% else %}
+108
View File
@@ -0,0 +1,108 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import select_all_checkbox %}
{% block title %}
{% trans %}Mailing lists{% endtrans %}
{% endblock %}
{% block content %}
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
{% if mailings_not_moderated %}
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
<ul>
{% for mailing in mailings_not_moderated %}
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if mailings_moderated %}
{% for mailing in mailings_moderated %}
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
{%- if user.is_owner(mailing) -%}
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
{%- endif -%}
</h2>
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
</form>
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
{% if form_mailing_removal.field.choices %}
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
{% csrf_token %}
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
<table>
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Email{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for widget in form_mailing_removal.subwidgets %}
{% set user = ms[widget.data.value.value][0] %}
<tr>
<td>{{ user.get_username }}</td>
<td>{{ user.get_email }}</td>
<td>{{ widget.tag() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ form_mailing_removal.errors }}
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
</form>
{% else %}
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
{% endif %}
{% endfor %}
{% else %}
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
{% endif %}
<p>{{ form.non_field_errors() }}</p>
{% if mailings_moderated %}
<h2>{% trans %}New member{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.subscription_mailing.errors }}
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
{{ form.subscription_mailing }}
</p>
<p>
{{ form.subscription_users.errors }}
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
{{ form.subscription_users }}
<span class="helptext">{{ form.subscription_users.help_text }}</span>
</p>
<p>
{{ form.subscription_email.errors }}
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
{{ form.subscription_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
</form>
{% endif %}
<h2>{% trans %}New mailing{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.mailing_email.errors }}
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
{{ form.mailing_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
</form>
{% endblock %}
+328
View File
@@ -0,0 +1,328 @@
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership
from core.models import User
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.rbatsbak = User.objects.get(username="rbatsbak")
cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root")
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
Membership(
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True),
).save()
def test_mailing_list_add_no_moderation(self):
# Test with Communication admin
self.client.force_login(self.comunity)
response = self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
self.assertRedirects(response, self.mail_url)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion foyer@utbm.fr" in response.text
# Test with Root
self.client.force_login(self.root)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion mde@utbm.fr" in response.text
def test_mailing_list_add_moderation(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "Liste de diffusion mde@utbm.fr" not in content
assert "<p>Listes de diffusions en attente de modération</p>" in content
assert "<li>mde@utbm.fr" in content
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(self.mail_url)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.force_login(self.krophil)
response = self.client.get(self.mail_url)
assert response.status_code == 403
def test_add_new_subscription_fail_not_moderated(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" not in response.text
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" in response.text
# Add multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
# Add arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
# Add user and arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "more.arbitrary@git.an",
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
assert "more.arbitrary@git.an" in content
assert "krophil@git.an" in content
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# No mailing specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
assert response.status_code == 200
assert _("This field is required") in response.text
# One of the selected users doesn't exist
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# An user has no email address
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("One of the selected users doesn't have an email address"),
response.text,
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("This email is already suscribed in this mailing"),
response.text,
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" in content
# Delete one user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" not in content
# Delete multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: [
user.id
for user in mde.subscriptions.filter(
user__in=[self.rbatsbak, self.comunity]
).all()
],
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" not in content
assert "richard@git.an" not in content
assert "krophil@git.an" not in content
+20
View File
@@ -29,6 +29,7 @@ from club.views import (
ClubCreateView, ClubCreateView,
ClubEditView, ClubEditView,
ClubListView, ClubListView,
ClubMailingView,
ClubMembersView, ClubMembersView,
ClubOldMembersView, ClubOldMembersView,
ClubPageEditView, ClubPageEditView,
@@ -42,6 +43,9 @@ from club.views import (
ClubSellingView, ClubSellingView,
ClubToolsView, ClubToolsView,
ClubView, ClubView,
MailingAutoGenerationView,
MailingDeleteView,
MailingSubscriptionDeleteView,
MembershipDeleteView, MembershipDeleteView,
MembershipSetOldView, MembershipSetOldView,
PosterCreateView, PosterCreateView,
@@ -92,6 +96,22 @@ urlpatterns = [
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv" "<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
), ),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"), path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
path(
"<int:mailing_id>/mailing/generate/",
MailingAutoGenerationView.as_view(),
name="mailing_generate",
),
path(
"<int:mailing_id>/mailing/delete/",
MailingDeleteView.as_view(),
name="mailing_delete",
),
path(
"<int:mailing_subscription_id>/mailing/delete/subscription/",
MailingSubscriptionDeleteView.as_view(),
name="mailing_subscription_delete",
),
path( path(
"membership/<int:membership_id>/set_old/", "membership/<int:membership_id>/set_old/",
MembershipSetOldView.as_view(), MembershipSetOldView.as_view(),
+171
View File
@@ -34,6 +34,7 @@ from django.contrib.auth.mixins import (
UserPassesTestMixin, UserPassesTestMixin,
) )
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Prefetch, Q, Sum from django.db.models import F, Prefetch, Q, Sum
from django.db.models.functions import Length from django.db.models.functions import Length
@@ -58,6 +59,7 @@ from club.forms import (
ClubRoleFormSet, ClubRoleFormSet,
ClubSearchForm, ClubSearchForm,
JoinClubForm, JoinClubForm,
MailingForm,
SellingsForm, SellingsForm,
) )
from club.models import ( from club.models import (
@@ -65,6 +67,8 @@ from club.models import (
ClubLink, ClubLink,
ClubRole, ClubRole,
LinkType, LinkType,
Mailing,
MailingSubscription,
Membership, Membership,
) )
from com.models import Poster from com.models import Poster
@@ -177,6 +181,13 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "sellings", "slug": "sellings",
"name": _("Sellings"), "name": _("Sellings"),
}, },
{
"url": reverse(
"club:mailing", kwargs={"club_id": self.object.id}
),
"slug": "mailing",
"name": _("Mailing list"),
},
{ {
"url": reverse( "url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id} "club:poster_list", kwargs={"club_id": self.object.id}
@@ -731,6 +742,166 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""A list of mailing for a given club."""
model = Club
form_class = MailingForm
pk_url_kwarg = "club_id"
template_name = "club/mailing.jinja"
current_tab = "mailing"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["club_id"] = self.object.id
kwargs["user_id"] = self.request.user.id
kwargs["mailings"] = self.object.mailings.all()
return kwargs
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
mailings = list(self.object.mailings.all())
kwargs["club"] = self.object
kwargs["user"] = self.request.user
kwargs["mailings"] = mailings
kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated]
kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated]
kwargs["form_actions"] = {
"NEW_MALING": self.form_class.ACTION_NEW_MAILING,
"NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION,
"REMOVE_SUBSCRIPTION": self.form_class.ACTION_REMOVE_SUBSCRIPTION,
}
return kwargs
def add_new_mailing(self, cleaned_data) -> ValidationError | None:
"""Create a new mailing list from the form."""
mailing = Mailing(
club=self.object,
email=cleaned_data["mailing_email"],
moderator=self.request.user,
is_moderated=False,
)
try:
mailing.clean()
except ValidationError as validation_error:
return validation_error
mailing.save()
return None
def add_new_subscription(self, cleaned_data) -> ValidationError | None:
"""Add mailing subscriptions for each user given and/or for the specified email in form."""
users_to_save = []
for user in cleaned_data["subscription_users"]:
sub = MailingSubscription(
mailing=cleaned_data["subscription_mailing"], user=user
)
try:
sub.clean()
except ValidationError as validation_error:
return validation_error
sub.save()
users_to_save.append(sub)
if cleaned_data["subscription_email"]:
sub = MailingSubscription(
mailing=cleaned_data["subscription_mailing"],
email=cleaned_data["subscription_email"],
)
try:
sub.clean()
except ValidationError as validation_error:
return validation_error
sub.save()
# Save users after we are sure there is no error
for user in users_to_save:
user.save()
return None
def remove_subscription(self, cleaned_data):
"""Remove specified users from a mailing list."""
fields = [
val for key, val in cleaned_data.items() if key.startswith("removal_")
]
for field in fields:
for sub in field:
sub.delete()
def form_valid(self, form):
resp = super().form_valid(form)
cleaned_data = form.clean()
error = None
if cleaned_data["action"] == self.form_class.ACTION_NEW_MAILING:
error = self.add_new_mailing(cleaned_data)
if cleaned_data["action"] == self.form_class.ACTION_NEW_SUBSCRIPTION:
error = self.add_new_subscription(cleaned_data)
if cleaned_data["action"] == self.form_class.ACTION_REMOVE_SUBSCRIPTION:
self.remove_subscription(cleaned_data)
if error:
form.add_error(NON_FIELD_ERRORS, error)
return self.form_invalid(form)
return resp
def get_success_url(self, **kwargs):
return reverse("club:mailing", kwargs={"club_id": self.object.id})
class MailingDeleteView(CanEditMixin, DeleteView):
model = Mailing
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "mailing_id"
redirect_page = None
def dispatch(self, request, *args, **kwargs):
self.club_id = self.get_object().club.id
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
if self.redirect_page:
return reverse_lazy(self.redirect_page)
else:
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
class MailingSubscriptionDeleteView(CanEditMixin, DeleteView):
model = MailingSubscription
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "mailing_subscription_id"
def dispatch(self, request, *args, **kwargs):
self.club_id = self.get_object().mailing.club.id
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
class MailingAutoGenerationView(View):
def dispatch(self, request, *args, **kwargs):
self.mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
if not request.user.can_edit(self.mailing):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.ongoing().filter(role__is_board=True)
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)
class PosterListView( class PosterListView(
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
): ):
+4 -1
View File
@@ -259,7 +259,10 @@ class Weekmail(models.Model):
return f"Weekmail {self.id} (sent: {self.sent}) - {self.title}" return f"Weekmail {self.id} (sent: {self.sent}) - {self.title}"
def send(self): def send(self):
"""Send the weekmail to all users with the receive weekmail option opt-in.""" """Send the weekmail to all users with the receive weekmail option opt-in.
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
"""
dest = [ dest = [
i[0] i[0]
for i in Preferences.objects.filter(receive_weekmail=True).values_list( for i in Preferences.objects.filter(receive_weekmail=True).values_list(
+45
View File
@@ -0,0 +1,45 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Mailing lists administration{% endtrans %}
{% endblock %}
{% macro display_mailings(list) %}
<table>
<tr>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Club{%endtrans%}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
{% for mailing in list %}
<tr>
<td>{{ mailing.email_full }}</td>
<td><a href="{{ url('club:mailing', club_id=mailing.club.id) }}">{{ mailing.club }}</a></td>
<td>
<a href="{{ url('com:mailing_delete', mailing_id=mailing.id) }}">{% trans %}Delete{% endtrans %}</a> - {% if not mailing.is_moderated %}<a href="{{ url('com:mailing_moderate', mailing_id=mailing.id) }}">{% trans %}Moderate{% endtrans %}</a>{% else %}{% trans user=mailing.moderator %}Moderated by {{ user }}{% endtrans %}{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% block content %}
<h1>{% trans %}This page lists all mailing lists{% endtrans %}</h1>
{% if has_unmoderated %}
<h2>{% trans %}Not moderated mailing lists{% endtrans %}</h2>
{{ display_mailings(unmoderated) }}
{% endif %}
<h2>{% trans %}Moderated mailing lists{% endtrans %}</h2>
{% if has_moderated %}
{{ display_mailings(moderated) }}
{% else %}
<p>{% trans %}No mailing list existing{% endtrans %}</p>
{% endif %}
{% endblock %}
+14
View File
@@ -15,9 +15,12 @@
from django.urls import path from django.urls import path
from club.views import MailingDeleteView
from com.views import ( from com.views import (
AlertMsgEditView, AlertMsgEditView,
InfoMsgEditView, InfoMsgEditView,
MailingListAdminView,
MailingModerateView,
NewsAdminListView, NewsAdminListView,
NewsCreateView, NewsCreateView,
NewsDeleteView, NewsDeleteView,
@@ -79,6 +82,17 @@ urlpatterns = [
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate" "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
), ),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"), path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path(
"mailings/<int:mailing_id>/moderate/",
MailingModerateView.as_view(),
name="mailing_moderate",
),
path(
"mailings/<int:mailing_id>/delete/",
MailingDeleteView.as_view(redirect_page="com:mailing_admin"),
name="mailing_delete",
),
path("poster/", PosterListView.as_view(), name="poster_list"), path("poster/", PosterListView.as_view(), name="poster_list"),
path("poster/create/", PosterCreateView.as_view(), name="poster_create"), path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"), path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
+41 -3
View File
@@ -30,9 +30,11 @@ from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.exceptions import ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, Max, OuterRef, Value from django.db.models import Exists, Max, OuterRef, Value
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
@@ -44,7 +46,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Membership from club.models import Club, Mailing, Membership
from com.forms import NewsDateForm, NewsForm, PosterForm from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -84,6 +86,11 @@ class ComTabsMixin(TabedViewMixin):
"slug": "alert", "slug": "alert",
"name": _("Alert message"), "name": _("Alert message"),
}, },
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
},
{ {
"url": reverse("com:poster_list"), "url": reverse("com:poster_list"),
"slug": "posters", "slug": "posters",
@@ -526,6 +533,37 @@ class WeekmailArticleDeleteView(CanEditPropMixin, DeleteView):
pk_url_kwarg = "article_id" pk_url_kwarg = "article_id"
class MailingListAdminView(ComTabsMixin, ListView):
template_name = "com/mailing_admin.jinja"
model = Mailing
current_tab = "mailings"
def dispatch(self, request, *args, **kwargs):
if not (request.user.is_com_admin or request.user.is_root):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["moderated"] = self.get_queryset().filter(is_moderated=True).all()
kwargs["unmoderated"] = self.get_queryset().filter(is_moderated=False).all()
kwargs["has_moderated"] = len(kwargs["moderated"]) > 0
kwargs["has_unmoderated"] = len(kwargs["unmoderated"]) > 0
return kwargs
class MailingModerateView(View):
def get(self, request, *args, **kwargs):
mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
if mailing.can_moderate(request.user):
mailing.is_moderated = True
mailing.moderator = request.user
mailing.save()
return redirect("com:mailing_admin")
raise PermissionDenied
class PosterListBaseView(ListView): class PosterListBaseView(ListView):
"""List communication posters.""" """List communication posters."""
+16
View File
@@ -1,15 +1,18 @@
from typing import Annotated, Literal from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F from django.db.models import F
from ninja import File, Query from ninja import File, Query
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
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 api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Mailing
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,
@@ -45,6 +48,19 @@ class UploadController(ControllerBase):
return image return image
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)
def fetch_mailing_lists(self, key: str):
if key != settings.SITH_MAILING_FETCH_KEY:
raise PermissionDenied
mailings = Mailing.objects.filter(
is_moderated=True, club__is_active=True
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data
@api_controller("/user") @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema])
+10
View File
@@ -79,5 +79,15 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if
profile.mailing_subscriptions.exists()
and (profile.id == user.id or user.is_root or user.is_com_admin)
%}
<h4>{% trans %}Subscribed mailing lists{% endtrans %}</h4>
{% for sub in profile.mailing_subscriptions.all() %}
<p>{{ sub.mailing.email }} <a href="{{ url('club:mailing_subscription_delete', mailing_subscription_id=sub.id) }}">{% trans %}Unsubscribe{% endtrans %}</a></p>
{% endfor %}
{% endif %}
{% endblock %} {% endblock %}
+1
View File
@@ -133,6 +133,7 @@
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li> <li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
<li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li> <li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
<li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li> <li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
<li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
<li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li> <li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li> <li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
{% endif %} {% endif %}
+14 -26
View File
@@ -83,17 +83,8 @@
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
<div class="alert alert-yellow">
<div class="alert-main">
{%- trans trimmed -%}
You can see product details.
However, you cannot edit it, thus you won't be able
to submit this form.
{%- endtrans -%}
</div>
</div>
{% if object %} {% if object %}
<h2>{{ object }}</h2> <h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p> <p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p> <p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %} {% else %}
@@ -136,17 +127,19 @@
{{ form.price_formset.management_form }} {{ form.price_formset.management_form }}
<div x-ref="formContainer"> <div x-ref="formContainer">
{%- for form in form.price_formset.forms -%} {%- for form in form.price_formset.forms -%}
<div>{{ price_form(form) }}</div> <div>
{{ price_form(form) }}
</div>
{%- endfor -%} {%- endfor -%}
</div> </div>
<template x-ref="formTemplate"> <template x-ref="formTemplate">
<div>{{ price_form(form.price_formset.empty_form) }}</div> <div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template> </template>
{% if user.has_perm("counter.change_product") %} <button class="btn btn-grey" @click.prevent="addForm()">
<button class="btn btn-grey" @click.prevent="addForm()"> <i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %} </button>
</button>
{% endif %}
</div> </div>
<br /> <br />
@@ -172,16 +165,11 @@
<template x-ref="formTemplate"> <template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }} {{ action_form(form.action_formset.empty_form) }}
</template> </template>
{% if user.has_perm("counter.change_product") %} <button @click.prevent="addForm()" class="btn btn-grey">
<button @click.prevent="addForm()" class="btn btn-grey"> <i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %} </button>
</button>
{% endif %}
</div> </div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div> <div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% if user.has_perm("counter.change_product") %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% endif %}
</form> </form>
{% endblock %} {% endblock %}
+3 -5
View File
@@ -80,11 +80,9 @@
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom"> <div class="row margin-bottom">
{% if user.has_perm("counter.add_product") %} <a href="{{ url('counter:new_product') }}" class="btn btn-blue">
<a href="{{ url('counter:new_product') }}" class="btn btn-blue"> {% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i> </a>
</a>
{% endif %}
<button <button
class="btn btn-blue" class="btn btn-blue"
@click="downloadCsv()" @click="downloadCsv()"
@@ -13,12 +13,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if user.has_perm("counter.add_producttype") %} <p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom"> <a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% trans %}New product type{% endtrans %} {% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i> <i class="fa fa-plus"></i>
</a> </a>
{% endif %} </p>
{% if product_types %} {% if product_types %}
<aside> <aside>
<p> <p>
+21 -34
View File
@@ -48,7 +48,7 @@ from counter.models import (
Selling, Selling,
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView): class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
@@ -84,18 +84,17 @@ class CounterEditView(
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id}) return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView): class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit a counter's main infos.""" """Edit a counter's main informations (for the counter's admin)."""
model = Counter model = Counter
form_class = modelform_factory(Counter, fields=["name", "club", "type"]) form_class = modelform_factory(Counter, fields=["name", "club", "type"])
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"
permission_required = "counter.change_counter"
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView): class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create a counter (for the admins).""" """Create a counter (for the admins)."""
model = Counter model = Counter
@@ -106,10 +105,9 @@ class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateVi
) )
template_name = "core/create.jinja" template_name = "core/create.jinja"
current_tab = "counters" current_tab = "counters"
permission_required = "counter.add_counter"
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView): class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
"""Delete a counter (for the admins).""" """Delete a counter (for the admins)."""
model = Counter model = Counter
@@ -117,33 +115,30 @@ class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteVi
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("counter:admin_list") success_url = reverse_lazy("counter:admin_list")
current_tab = "counters" current_tab = "counters"
permission_required = "counter.delete_counter"
# Product management # Product management
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins.""" """A list view for the admins."""
model = ProductType model = ProductType
template_name = "counter/product_type_list.jinja" template_name = "counter/product_type_list.jinja"
current_tab = "product_types" current_tab = "product_types"
context_object_name = "product_types" context_object_name = "product_types"
permission_required = "counter.view_producttype"
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, 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"] fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.add_producttype"
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView): class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins.""" """An edit view for the admins."""
model = ProductType model = ProductType
@@ -151,26 +146,23 @@ class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, Update
fields = ["name", "description", "comment", "icon"] fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id" pk_url_kwarg = "type_id"
current_tab = "products" current_tab = "products"
permission_required = "counter.change_producttype"
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView): class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
current_tab = "products" current_tab = "products"
template_name = "counter/product_list.jinja" template_name = "counter/product_list.jinja"
permission_required = "counter.view_product"
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView): class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins.""" """A create view for the admins."""
model = Product model = Product
form_class = ProductForm form_class = ProductForm
template_name = "counter/product_form.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.add_product"
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView): class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins.""" """An edit view for the admins."""
model = Product model = Product
@@ -178,13 +170,6 @@ class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
pk_url_kwarg = "product_id" pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.change_product"
def has_permission(self):
return self.request.user.has_perm("counter.change_product") or (
self.request.method == "GET"
and self.request.user.has_perm("counter.view_product")
)
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
@@ -314,6 +299,7 @@ class RefillingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object() self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta( if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -338,6 +324,7 @@ class SellingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object() self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta( if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -389,23 +376,23 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
return kwargs return kwargs
class CounterRefillingListView( class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
"""List of refillings on a counter.""" """List of refillings on a counter."""
model = Refilling model = Refilling
template_name = "counter/refilling_list.jinja" template_name = "counter/refilling_list.jinja"
current_tab = "counters" current_tab = "counters"
paginate_by = 30 paginate_by = 30
permission_required = "counter.view_refilling"
def get_queryset(self): def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"]) self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
return Refilling.objects.filter(counter=self.counter).order_by("-date") self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"counter": self.counter} kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.counter
return kwargs
class RefoundAccountView(UserPassesTestMixin, FormView): class RefoundAccountView(UserPassesTestMixin, FormView):
+5 -8
View File
@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
@@ -21,30 +21,28 @@ from django.views.generic.edit import CreateView, UpdateView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm from counter.forms import EticketForm
from counter.models import Eticket, Selling from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins.""" """A list view for the admins."""
model = Eticket model = Eticket
template_name = "counter/eticket_list.jinja" template_name = "counter/eticket_list.jinja"
ordering = ["id"] ordering = ["id"]
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.view_eticket"
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView): class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create an eticket.""" """Create an eticket."""
model = Eticket model = Eticket
template_name = "core/create.jinja" template_name = "core/create.jinja"
form_class = EticketForm form_class = EticketForm
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.add_eticket"
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView): class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit an eticket.""" """Edit an eticket."""
model = Eticket model = Eticket
@@ -52,7 +50,6 @@ class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
form_class = EticketForm form_class = EticketForm
pk_url_kwarg = "eticket_id" pk_url_kwarg = "eticket_id"
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.change_eticket"
class EticketPDFView(CanViewMixin, DetailView): class EticketPDFView(CanViewMixin, DetailView):
+67 -67
View File
@@ -13,13 +13,38 @@
# #
# #
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
class CounterAdminMixin(View):
"""Protect counter admin section."""
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
edit_club = []
def _test_group(self, user):
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
def _test_club(self, user):
return any(c.can_be_edited_by(user) for c in self.edit_club)
def dispatch(self, request, *args, **kwargs):
if not (
request.user.is_root
or self._test_group(request.user)
or self._test_club(request.user)
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class CounterTabsMixin(TabedViewMixin): class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
return self.object return self.object
@@ -64,70 +89,45 @@ class CounterTabsMixin(TabedViewMixin):
class CounterAdminTabsMixin(TabedViewMixin): class CounterAdminTabsMixin(TabedViewMixin):
tabs_title = _("Counter administration") tabs_title = _("Counter administration")
list_of_tabs = [
def get_list_of_tabs(self): {
user = self.request.user "url": reverse_lazy("counter:admin_list"),
res = [ "slug": "counters",
{ "name": _("Counters"),
"url": reverse_lazy("counter:admin_list"), },
"slug": "counters", {
"name": _("Counters"), "url": reverse_lazy("counter:product_list"),
} "slug": "products",
] "name": _("Products"),
if user.has_perm("counter.view_product"): },
res.append( {
{ "url": reverse_lazy("counter:product_formula_list"),
"url": reverse_lazy("counter:product_list"), "slug": "formulas",
"slug": "products", "name": _("Formulas"),
"name": _("Products"), },
} {
) "url": reverse_lazy("counter:product_type_list"),
if user.has_perm("counter.view_productformula"): "slug": "product_types",
res.append( "name": _("Product types"),
{ },
"url": reverse_lazy("counter:product_formula_list"), {
"slug": "formulas", "url": reverse_lazy("counter:returnable_list"),
"name": _("Formulas"), "slug": "returnable_products",
} "name": _("Returnable products"),
) },
if user.has_perm("counter.view_producttype"): {
res.append( "url": reverse_lazy("counter:cash_summary_list"),
{ "slug": "cash_summary",
"url": reverse_lazy("counter:product_type_list"), "name": _("Cash register summaries"),
"slug": "product_types", },
"name": _("Product types"), {
} "url": reverse_lazy("counter:invoices_call"),
) "slug": "invoices_call",
if user.has_perm("counter.view_returnableproduct"): "name": _("Invoices call"),
res.append( },
{ {
"url": reverse_lazy("counter:returnable_list"), "url": reverse_lazy("counter:eticket_list"),
"slug": "returnable_products", "slug": "etickets",
"name": _("Returnable products"), "name": _("Etickets"),
} },
) ]
if user.has_perm("counter.view_cashregistersummary"):
res.append(
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
}
)
if user.has_perm("counter.view_invoicecall"):
res.append(
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
}
)
if user.has_perm("counter.view_eticket"):
res.append(
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
}
)
return res
+7 -5
View File
@@ -48,7 +48,7 @@ des scripts complets mais qui restent lisibles,
``` ```
Ok, c'est de la triche, on installe un package externe. Ok, c'est de la triche, on installe un package externe.
Mais bon, ça marche, et l'équivalent pur bash Mais bon, ça marche, et l'équivalent pur bash
serait un pur enfer. serait carrément plus moche.
=== "nu" === "nu"
@@ -59,18 +59,20 @@ des scripts complets mais qui restent lisibles,
Nombre de lignes total : Nombre de lignes total :
```nu ```nu
ls **/*.py | par-each { open $in.name | lines | length } | math sum ls **/*.py | insert linecount { get name | open | lines | length } | math sum
``` ```
Vous pouvez aussi exlure les lignes vides Vous pouvez aussi exlure les lignes vides
et les les lignes de commentaire : et les les lignes de commentaire :
```nu ```nu
ls **/*.py | ls **/*.py |
par-each { insert linecount {
open $in.name | get name |
open |
lines | lines |
each { str trim } | each { str trim } |
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } | filter { |l| not ($l | str starts-with "#") } | # commentaires
filter { |l| ($l | str length) > 0 } | # lignes vides
length length
} | } |
math sum math sum
+16 -40
View File
@@ -18,8 +18,7 @@ il n'est pas possible de développer le site sur ce système d'exploitation.
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
OS ni de mettre un dual boot sur votre ordinateur : `WSL`. OS ni de mettre un dual boot sur votre ordinateur : `WSL`.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou - **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11.
Windows 11.
- **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install) - **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install)
```shell ```shell
@@ -44,7 +43,7 @@ il suffit d'utiliser la commande suivante :
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
``` ```
!!!success !!!note
À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
@@ -102,10 +101,10 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
ln -s /usr/bin/valkey-server /usr/bin/redis-server ln -s /usr/bin/valkey-server /usr/bin/redis-server
``` ```
=== "macOS" === "macOS"
Pour installer les dépendances, il est fortement recommandé Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Il est également nécessaire d'avoir installé xcode Il est également nécessaire d'avoir installé xcode
```bash ```bash
@@ -117,15 +116,12 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!note !!!note
Si vous rencontrez des erreurs lors de votre configuration, Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
n'hésitez pas à vérifier l'état de votre installation homebrew
avec :code:`brew doctor`
!!!note !!!note
Python ne fait pas parti des dépendances puisqu'il est automatiquement Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. Il est cependant parfois nécessaire d'installer installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
les headers Python nécessaire à la compilation de certains paquets.
## Finaliser l'installation ## Finaliser l'installation
@@ -149,6 +145,7 @@ uv run ./manage.py install_xapian
de texte à l'écran. de texte à l'écran.
C'est normal, il ne faut pas avoir peur. C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables La modification de la configuration passe par la modification de variables
d'environnement. d'environnement.
Ces variables sont stockées dans un fichier `.env`. Ces variables sont stockées dans un fichier `.env`.
@@ -177,8 +174,8 @@ uv run ./manage.py compilemessages
## Démarrer le serveur de développement ## Démarrer le serveur de développement
Il faut toujours avoir préalablement activé Il faut toujours avoir préalablement activé
l'environnement virtuel comme fait plus haut l'environnement virtuel comme fait plus haut
et se placer à la racine du projet. et se placer à la racine du projet.
Il suffit ensuite d'utiliser cette commande : Il suffit ensuite d'utiliser cette commande :
@@ -186,32 +183,11 @@ Il suffit ensuite d'utiliser cette commande :
uv run ./manage.py runserver uv run ./manage.py runserver
``` ```
Le serveur est alors accessible à l'adresse !!!note
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
Pour vous connecter, vous pouvez utiliser un des comptes utilisateur Le serveur est alors accessible à l'adresse
créés durant l'installation : [http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
| username | description |
|------------------|----------------------------------------------------------------------------|
| `root` | super-utilisateur<br/>il peut tout faire et tout voir |
| `subscriber` | simple cotisant |
| `old_subscriber` | ancien cotisant |
| `public` | non-cotisant |
| `krophil` | cotisant<br/>barman Foyer<br/>président du Troll Penché |
| `sli` | cotisant<br/>VP Troll |
| `skia` | cotisant<br/>barman MDE<br/>membre du bureau AE<br/>ancien membre du Troll |
| `comunity` | cotisant<br/>membre du bureau PdF<br/>admin communication |
| `counter` | cotisant<br/>admin comptoirs |
| `comptable` | cotisant<br/>admin comptabilité |
| `rbatsbak` | cotisant<br/>membre du Troll |
En plus de ces dernier, quelques centaines d'utilisateurs
supplémentaires sont générés procéduralement,
afin de rajouter un peu de volume de données.
Tous les utilisateurs ont le mot de passe `plop`.
!!!note !!!note
@@ -252,10 +228,10 @@ Tous les utilisateurs ont le mot de passe `plop`.
La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub. La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
Pour l'utiliser en local ou globalement pour la modifier, Pour l'utiliser en local ou globalement pour la modifier,
il existe une commande du site qui génère il existe une commande du site qui génère
la documentation et lance un serveur la rendant la documentation et lance un serveur la rendant
accessible à l'adresse [http://localhost:8080](http://localhost:8000). accessible à l'adresse [http://localhost:8080](http://localhost:8000).
Cette commande génère la documentation à Cette commande génère la documentation à
chacune de ses modifications, chacune de ses modifications,
inutile de relancer le serveur à chaque fois. inutile de relancer le serveur à chaque fois.
@@ -265,7 +241,7 @@ uv run mkdocs serve
## Lancer les tests ## Lancer les tests
Pour lancer les tests, il suffit d'utiliser Pour lancer les tests, il suffit d'utiliser
la commande suivante : la commande suivante :
```bash ```bash
+219 -60
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-15 15:20+0200\n" "POT-Creation-Date: 2026-10-02 12:56+0200\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"
@@ -192,11 +192,51 @@ msgstr ""
"arrivée durant l'interaction avec l'application tierce. Veuillez contacter " "arrivée durant l'interaction avec l'application tierce. Veuillez contacter "
"les responsables de cette dernière." "les responsables de cette dernière."
#: club/forms.py
msgid "Users to add"
msgstr "Utilisateurs à ajouter"
#: club/forms.py core/views/group.py
msgid "Search users to add (one or more)."
msgstr "Recherche les utilisateurs à ajouter (un ou plus)."
#: club/forms.py
msgid "New Mailing"
msgstr "Nouvelle mailing liste"
#: club/forms.py
msgid "Subscribe"
msgstr "S'abonner"
#: club/forms.py
msgid "Remove"
msgstr "Retirer"
#: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
#: club/forms.py club/tests/test_mailing.py
msgid "This field is required"
msgstr "Ce champ est obligatoire"
#: club/forms.py club/tests/test_mailing.py
msgid "One of the selected users doesn't have an email address"
msgstr "Un des utilisateurs sélectionnés n'a pas d'adresse email"
#: club/forms.py
msgid "An action is required"
msgstr "Une action est requise"
#: club/forms.py club/tests/test_mailing.py
msgid "You must specify at least an user or an email address"
msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
#: club/forms.py counter/forms.py #: club/forms.py counter/forms.py
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py #: club/forms.py com/forms.py counter/forms.py subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@@ -374,6 +414,60 @@ msgstr "rôle"
msgid "past member" msgid "past member"
msgstr "ancien membre" msgstr "ancien membre"
#: club/models.py com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/export.jinja
#: trombi/templates/trombi/user_profile.jinja
msgid "Club"
msgstr "Club"
#: club/models.py
msgid "Email address"
msgstr "Adresse email"
#: club/models.py
msgid "Enter a valid address. Only the root of the address is needed."
msgstr ""
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
#: club/models.py com/models.py core/models.py
msgid "is moderated"
msgstr "est modéré"
#: club/models.py com/models.py
msgid "moderator"
msgstr "modérateur"
#: club/models.py
msgid "This mailing list already exists."
msgstr "Cette liste de diffusion existe déjà."
#: club/models.py club/templates/club/mailing.jinja
msgid "Mailing"
msgstr "Liste de diffusion"
#: club/models.py club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/stats.jinja
msgid "User"
msgstr "Utilisateur"
#: club/models.py
msgid "At least user or email is required"
msgstr "Au moins un utilisateur ou un email est nécessaire"
#: club/models.py club/tests/test_mailing.py
msgid "This email is already suscribed in this mailing"
msgstr "Cet email est déjà abonné à cette mailing"
#: club/models.py
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/models.py #: club/models.py
msgid "The base url that links with this type must respect" msgid "The base url that links with this type must respect"
msgstr "L'url de base que tous les liens de ce type doivent respecter" msgstr "L'url de base que tous les liens de ce type doivent respecter"
@@ -460,13 +554,6 @@ msgstr "Membres du club"
msgid "Manage roles" msgid "Manage roles"
msgstr "Gérer les rôles" msgstr "Gérer les rôles"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/stats.jinja
msgid "User"
msgstr "Utilisateur"
#: club/templates/club/club_members.jinja #: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja #: club/templates/club/club_old_members.jinja
#: core/templates/core/user_clubs.jinja #: core/templates/core/user_clubs.jinja
@@ -730,7 +817,8 @@ msgstr "Total"
msgid "Payment method" msgid "Payment method"
msgstr "Méthode de paiement" msgstr "Méthode de paiement"
#: club/templates/club/club_sellings.jinja com/templates/com/macros.jinja #: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja #: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file_detail.jinja #: core/templates/core/file_detail.jinja
@@ -856,6 +944,60 @@ msgstr "Ajouter"
msgid "Join" msgid "Join"
msgstr "Rejoindre" msgstr "Rejoindre"
#: club/templates/club/mailing.jinja
msgid "Mailing lists"
msgstr "Mailing listes"
#: club/templates/club/mailing.jinja
msgid ""
"Remember : mailing lists need to be moderated, if your new created list is "
"not shown wait until moderation takes action"
msgstr ""
"Rappelez vous : les mailing listes doivent être modérées, si votre liste "
"nouvellement créée n'est pas affichée, attendez jusqu'à ce qu'un modérateur "
"prenne une décision"
#: club/templates/club/mailing.jinja
msgid "Mailing lists waiting for moderation"
msgstr "Listes de diffusions en attente de modération"
#: club/templates/club/mailing.jinja
msgid "Generate mailing list"
msgstr "Générer la liste de diffusion"
#: club/templates/club/mailing.jinja com/templates/com/mailing_admin.jinja
msgid "Email"
msgstr "Email"
#: club/templates/club/mailing.jinja
msgid "Remove from mailing list"
msgstr "Supprimer de la liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "There is no subscriber for this mailing list"
msgstr "Il n'y a pas d'abonnés dans cette liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "No mailing list existing for this club"
msgstr "Aucune mailing liste n'existe pour ce club"
#: club/templates/club/mailing.jinja
#: subscription/templates/subscription/subscription.jinja
msgid "New member"
msgstr "Nouveau membre"
#: club/templates/club/mailing.jinja
msgid "Add to mailing list"
msgstr "Ajouter à la mailing liste"
#: club/templates/club/mailing.jinja
msgid "New mailing"
msgstr "Nouvelle liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "Create mailing list"
msgstr "Créer une liste de diffusion"
#: club/templates/club/pagerev_edit.jinja core/templates/core/page/edit.jinja #: club/templates/club/pagerev_edit.jinja core/templates/core/page/edit.jinja
msgid "Edit page" msgid "Edit page"
msgstr "Éditer la page" msgstr "Éditer la page"
@@ -901,6 +1043,10 @@ msgstr "Éditer la page de club"
msgid "Sellings" msgid "Sellings"
msgstr "Vente" msgstr "Vente"
#: club/views.py
msgid "Mailing list"
msgstr "Listes de diffusion"
#: club/views.py #: club/views.py
msgid "You are now a member of this club." msgid "You are now a member of this club."
msgstr "Vous êtes maintenant membre de ce club." msgstr "Vous êtes maintenant membre de ce club."
@@ -947,7 +1093,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py #: com/forms.py subscription/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -1031,10 +1177,6 @@ msgstr "auteur"
msgid "is published" msgid "is published"
msgstr "est publié" msgstr "est publié"
#: com/models.py
msgid "moderator"
msgstr "modérateur"
#: com/models.py #: com/models.py
msgid "news" msgid "news"
msgstr "nouvelle" msgstr "nouvelle"
@@ -1095,10 +1237,6 @@ msgstr "fichier"
msgid "display time" msgid "display time"
msgstr "temps d'affichage" msgstr "temps d'affichage"
#: com/models.py core/models.py
msgid "is moderated"
msgstr "est modéré"
#: com/models.py #: com/models.py
msgid "Begin date should be before end date" msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin" msgstr "La date de début doit être avant celle de fin"
@@ -1134,6 +1272,46 @@ msgstr "Nouvelle publiée"
msgid "News deleted" msgid "News deleted"
msgstr "Nouvelle supprimée" msgstr "Nouvelle supprimée"
#: com/templates/com/mailing_admin.jinja com/views.py
#: core/templates/core/user_tools.jinja
msgid "Mailing lists administration"
msgstr "Administration des mailing listes"
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Actions"
msgstr "Actions"
#: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/mailing_admin.jinja
#, python-format
msgid "Moderated by %(user)s"
msgstr "Modéré par %(user)s"
#: com/templates/com/mailing_admin.jinja
msgid "This page lists all mailing lists"
msgstr "Cette page liste toutes les listes de diffusion"
#: com/templates/com/mailing_admin.jinja
msgid "Not moderated mailing lists"
msgstr "Listes de diffusion non modérées"
#: com/templates/com/mailing_admin.jinja
msgid "Moderated mailing lists"
msgstr "Modérer les listes de diffusion"
#: com/templates/com/mailing_admin.jinja
msgid "No mailing list existing"
msgstr "Aucune liste de diffusion existante"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "News admin" msgid "News admin"
msgstr "Administration des nouvelles" msgstr "Administration des nouvelles"
@@ -1166,15 +1344,6 @@ msgstr "Titre"
msgid "Summary" msgid "Summary"
msgstr "Résumé" msgstr "Résumé"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/export.jinja
#: trombi/templates/trombi/user_profile.jinja
msgid "Club"
msgstr "Club"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja sas/models.py #: forum/templates/forum/forum.jinja sas/models.py
msgid "Author" msgid "Author"
@@ -1189,11 +1358,6 @@ msgstr "Modérateur"
msgid "Dates" msgid "Dates"
msgstr "Dates" msgstr "Dates"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Actions"
msgstr "Actions"
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja #: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page/base.jinja #: core/templates/core/page/base.jinja
msgid "View" msgid "View"
@@ -1354,13 +1518,6 @@ msgstr "Créer"
msgid "Click to expand" msgid "Click to expand"
msgstr "Cliquez pour agrandir" msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_list.jinja core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/poster_list.jinja #: com/templates/com/poster_list.jinja
msgid "No posters" msgid "No posters"
msgstr "Aucune affiche" msgstr "Aucune affiche"
@@ -2671,6 +2828,14 @@ msgstr "Clubs actuels : "
msgid "Old club(s) :" msgid "Old club(s) :"
msgstr "Anciens clubs :" msgstr "Anciens clubs :"
#: core/templates/core/user_clubs.jinja
msgid "Subscribed mailing lists"
msgstr "Mailing listes abonnées"
#: core/templates/core/user_clubs.jinja
msgid "Unsubscribe"
msgstr "Se désabonner"
#: core/templates/core/user_detail.jinja #: core/templates/core/user_detail.jinja
#, python-format #, python-format
msgid "%(user_name)s's profile" msgid "%(user_name)s's profile"
@@ -3152,10 +3317,6 @@ msgstr "%s est déjà votre fillot/fillote"
msgid "Users to add to group" msgid "Users to add to group"
msgstr "Utilisateurs à ajouter au groupe" msgstr "Utilisateurs à ajouter au groupe"
#: core/views/group.py
msgid "Search users to add (one or more)."
msgstr "Recherche les utilisateurs à ajouter (un ou plus)."
#: core/views/group.py #: core/views/group.py
msgid "Users to remove from group" msgid "Users to remove from group"
msgstr "Utilisateurs à retirer du groupe" msgstr "Utilisateurs à retirer du groupe"
@@ -3255,10 +3416,6 @@ msgstr ""
"temps, mais les utilisateurs suivants ont été définis comme les deux : " "temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s" "%(users)s"
#: counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
#: 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"
@@ -3945,6 +4102,7 @@ msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja #: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go" msgid "Go"
msgstr "Valider" msgstr "Valider"
@@ -4079,12 +4237,9 @@ msgid "Remove price"
msgstr "Retirer le prix" msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "" #, python-format
"You can see product details. However, you cannot edit it, thus you won't be " msgid "Edit product %(name)s"
"able to submit this form." msgstr "Édition du produit %(name)s"
msgstr ""
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
"ne serez donc pas en mesure de soumettre le formulaire."
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "Creation date" msgid "Creation date"
@@ -5705,6 +5860,10 @@ msgstr "Cursus alternant (-20%)"
msgid "A new poster needs to be moderated" msgid "A new poster needs to be moderated"
msgstr "Une nouvelle affiche a besoin d'être modérée" msgstr "Une nouvelle affiche a besoin d'être modérée"
#: sith/settings.py
msgid "A new mailing list needs to be moderated"
msgstr "Une nouvelle mailing list a besoin d'être modérée"
#: sith/settings.py #: sith/settings.py
msgid "A new pedagogy comment has been signaled for moderation" msgid "A new pedagogy comment has been signaled for moderation"
msgstr "" msgstr ""
@@ -5826,17 +5985,17 @@ msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation" msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/stats.jinja #: subscription/templates/subscription/stats.jinja
msgid "All subscriptions" msgid "Total subscriptions"
msgstr "Toutes les cotisations" msgstr "Cotisations totales"
#: subscription/templates/subscription/stats.jinja
msgid "Subscriptions by type"
msgstr "Cotisations par type"
#: subscription/templates/subscription/subscription.jinja #: subscription/templates/subscription/subscription.jinja
msgid "Existing member" msgid "Existing member"
msgstr "Membre existant" msgstr "Membre existant"
#: subscription/templates/subscription/subscription.jinja
msgid "New member"
msgstr "Nouveau membre"
#: subscription/views.py #: subscription/views.py
msgid "the groups that can create subscriptions" msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations" msgstr "les groupes pouvant créer des cotisations"
+1 -1
View File
@@ -23,7 +23,7 @@ dependencies = [
"django-ninja>=1.7.1,<2.0.0", "django-ninja>=1.7.1,<2.0.0",
"django-ninja-extra>=0.31.7", "django-ninja-extra>=0.31.7",
"Pillow>=12.3.0,<13.0.0", "Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1", "aemark>=0.1.4",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=50.0.2,<51.0.0", "cryptography>=50.0.2,<51.0.0",
"django-phonenumber-field>=8.5.0,<9.0.0", "django-phonenumber-field>=8.5.0,<9.0.0",
+1
View File
@@ -621,6 +621,7 @@ with open(
SITH_NOTIFICATIONS = [ SITH_NOTIFICATIONS = [
("POSTER_MODERATION", _("A new poster needs to be moderated")), ("POSTER_MODERATION", _("A new poster needs to be moderated")),
("MAILING_MODERATION", _("A new mailing list needs to be moderated")),
( (
"PEDAGOGY_MODERATION", "PEDAGOGY_MODERATION",
_("A new pedagogy comment has been signaled for moderation"), _("A new pedagogy comment has been signaled for moderation"),