mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-15 15:09:22 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bda88ad224 | ||
|
|
edc5a3f029 | ||
|
|
cfc8b47b2e | ||
|
|
3ea11d0530 | ||
|
|
aba2194a17 | ||
|
|
7c64f172ad | ||
|
|
2e3b400f2c | ||
|
|
5590a174fe | ||
|
|
b4dfe312ce |
No files matched your search
+113
-2
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-09-24 14:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0051_user_cgu_approved_at")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="preferences",
|
||||||
|
name="show_my_timetable",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=(
|
||||||
|
"Allow subscribers to see your timetable and "
|
||||||
|
"to compare it to theirs (if you have filled it)"
|
||||||
|
),
|
||||||
|
verbose_name="show your timetable to others",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -799,6 +799,14 @@ class Preferences(models.Model):
|
|||||||
),
|
),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
show_my_timetable = models.BooleanField(
|
||||||
|
_("show your timetable to others"),
|
||||||
|
help_text=_(
|
||||||
|
"Allow subscribers to see your timetable and "
|
||||||
|
"to compare it to theirs (if you have filled it)"
|
||||||
|
),
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
notify_on_click = models.BooleanField(
|
notify_on_click = models.BooleanField(
|
||||||
_("get a notification for every click"), default=False
|
_("get a notification for every click"), default=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,5 +29,13 @@
|
|||||||
</span>
|
</span>
|
||||||
{{ form.show_my_stats.errors }}
|
{{ form.show_my_stats.errors }}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="form-group">
|
||||||
|
{{ form.show_my_timetable }}
|
||||||
|
{{ form.show_my_timetable.label_tag() }}
|
||||||
|
<span class="helptext">
|
||||||
|
{{ form.show_my_timetable.help_text }}
|
||||||
|
</span>
|
||||||
|
{{ form.show_my_timetable.errors }}
|
||||||
|
</fieldset>
|
||||||
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
||||||
</form>
|
</form>
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ from core.views import (
|
|||||||
notification,
|
notification,
|
||||||
send_file,
|
send_file,
|
||||||
)
|
)
|
||||||
|
from timetable.views import UserTimetableView
|
||||||
|
|
||||||
register_converter(FourDigitYearConverter, "yyyy")
|
register_converter(FourDigitYearConverter, "yyyy")
|
||||||
register_converter(TwoDigitMonthConverter, "mm")
|
register_converter(TwoDigitMonthConverter, "mm")
|
||||||
@@ -184,6 +185,11 @@ urlpatterns = [
|
|||||||
name="user_account_detail",
|
name="user_account_detail",
|
||||||
),
|
),
|
||||||
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
|
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
|
||||||
|
path(
|
||||||
|
"user/<int:user_id>/edt/",
|
||||||
|
UserTimetableView.as_view(),
|
||||||
|
name="user_timetable",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"user/<int:user_id>/gift/create/",
|
"user/<int:user_id>/gift/create/",
|
||||||
GiftCreateView.as_view(),
|
GiftCreateView.as_view(),
|
||||||
|
|||||||
+10
-2
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
|
|
||||||
__preferences_fields = forms.fields_for_model(
|
__preferences_fields = forms.fields_for_model(
|
||||||
Preferences,
|
Preferences,
|
||||||
["show_my_stats"],
|
["show_my_stats", "show_my_timetable"],
|
||||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
widgets={
|
||||||
|
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
show_my_stats = __preferences_fields["show_my_stats"]
|
show_my_stats = __preferences_fields["show_my_stats"]
|
||||||
|
show_my_timetable = __preferences_fields["show_my_timetable"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
||||||
@@ -347,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
if instance:
|
if instance:
|
||||||
initial = initial or {}
|
initial = initial or {}
|
||||||
initial["show_my_stats"] = instance.preferences.show_my_stats
|
initial["show_my_stats"] = instance.preferences.show_my_stats
|
||||||
|
initial["show_my_timetable"] = instance.preferences.show_my_timetable
|
||||||
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
||||||
|
|
||||||
def save(self, commit=True) -> User: # noqa: FBT002
|
def save(self, commit=True) -> User: # noqa: FBT002
|
||||||
instance = super().save(commit=commit)
|
instance = super().save(commit=commit)
|
||||||
if commit:
|
if commit:
|
||||||
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
||||||
|
instance.preferences.show_my_timetable = self.cleaned_data[
|
||||||
|
"show_my_timetable"
|
||||||
|
]
|
||||||
instance.preferences.save()
|
instance.preferences.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,6 +1,14 @@
|
|||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
ClassVar,
|
||||||
|
LiteralString,
|
||||||
|
Protocol,
|
||||||
|
TypedDict,
|
||||||
|
Unpack,
|
||||||
|
)
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@@ -12,11 +20,17 @@ if TYPE_CHECKING:
|
|||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
|
|
||||||
|
|
||||||
|
class TabItem(TypedDict):
|
||||||
|
url: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class TabedViewMixin(View):
|
class TabedViewMixin(View):
|
||||||
"""Basic functions for displaying tabs in the template."""
|
"""Basic functions for displaying tabs in the template."""
|
||||||
|
|
||||||
current_tab: ClassVar[str | None] = None
|
current_tab: ClassVar[str | None] = None
|
||||||
list_of_tabs: ClassVar[list | None] = None
|
list_of_tabs: ClassVar[list[TabItem] | None] = None
|
||||||
tabs_title: ClassVar[str | None] = None
|
tabs_title: ClassVar[str | None] = None
|
||||||
|
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
|
|||||||
+207
-50
@@ -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-09 21:44+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,6 +192,46 @@ 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"
|
||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
@@ -5705,6 +5862,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 ""
|
||||||
@@ -5833,10 +5994,6 @@ msgstr "Toutes les cotisations"
|
|||||||
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"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type TomSelect from "tom-select";
|
|||||||
import type { TomOption } from "tom-select/src/types";
|
import type { TomOption } from "tom-select/src/types";
|
||||||
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api";
|
||||||
|
import { History } from "#core:utils/history";
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -227,6 +228,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* 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
|
||||||
|
* Used by popstate event and always reset to it's default value when used
|
||||||
|
**/
|
||||||
|
pushstate: History.Push,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.pictures = (
|
this.pictures = (
|
||||||
@@ -261,10 +267,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!event.state || event.state.sasPictureId === undefined) {
|
if (!event.state || event.state.sasPictureId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.pushstate = History.Replace;
|
||||||
this.currentPicture = this.pictures.find(
|
this.currentPicture = this.pictures.find(
|
||||||
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
||||||
) as PictureWithIdentifications;
|
) as PictureWithIdentifications;
|
||||||
});
|
});
|
||||||
|
this.pushstate = History.Replace; /* Avoid first url push */
|
||||||
await this.updatePicture();
|
await this.updatePicture();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -277,11 +285,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* the list of identified users are updated.
|
* the list of identified users are updated.
|
||||||
*/
|
*/
|
||||||
async updatePicture(): Promise<void> {
|
async updatePicture(): Promise<void> {
|
||||||
window.history.replaceState(
|
const updateArgs = {
|
||||||
{ sasPictureId: this.currentPicture.id },
|
data: { sasPictureId: this.currentPicture.id },
|
||||||
"",
|
unused: "",
|
||||||
this.currentPicture.sas_url,
|
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 = "";
|
this.moderationError = "";
|
||||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -1 +1,17 @@
|
|||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from timetable.models import Timetable, TimetableSlot
|
||||||
|
|
||||||
|
|
||||||
|
class TimetableSlotInline(admin.TabularInline):
|
||||||
|
model = TimetableSlot
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Timetable)
|
||||||
|
class TimetableAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "semester")
|
||||||
|
search_fields = ("user__nick_name", "user__first_name", "user__last_name")
|
||||||
|
autocomplete_fields = ("user",)
|
||||||
|
inlines = (TimetableSlotInline,)
|
||||||
|
list_select_related = ("user",)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from ninja.security import SessionAuth
|
||||||
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
|
from ninja_extra.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
from core.utils import get_semester_code
|
||||||
|
from timetable.models import Timetable, TimetableSlot
|
||||||
|
from timetable.schemas import CreateTimetableSchema, TimetableSchema
|
||||||
|
|
||||||
|
|
||||||
|
@api_controller("/edt", urls_namespace="timetable")
|
||||||
|
class TimetableController(ControllerBase):
|
||||||
|
@route.put(
|
||||||
|
"/{int:user_id}",
|
||||||
|
auth=SessionAuth(),
|
||||||
|
response={200: TimetableSchema},
|
||||||
|
url_name="save",
|
||||||
|
)
|
||||||
|
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
|
||||||
|
user = self.context.request.user
|
||||||
|
if not user.has_perm("pedagogy.add_timetable") and not (
|
||||||
|
user.has_perm("timetable.add_self_timetable") and user.id == user_id
|
||||||
|
):
|
||||||
|
raise PermissionDenied
|
||||||
|
timetable, created = Timetable.objects.get_or_create(
|
||||||
|
semester=get_semester_code(), user_id=user_id
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
timetable.slots.all().delete()
|
||||||
|
if user.preferences.show_my_timetable != data.is_viewable:
|
||||||
|
user.preferences.show_my_timetable = data.is_viewable
|
||||||
|
user.preferences.save()
|
||||||
|
slots = TimetableSlot.objects.bulk_create(
|
||||||
|
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
|
||||||
|
)
|
||||||
|
return TimetableSchema(
|
||||||
|
id=timetable.id, semester=timetable.semester, slots=slots
|
||||||
|
)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Generated by Django 5.2.17 on 2026-10-03 21:43
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.utils
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Timetable",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"semester",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default=core.utils.get_semester_code,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.RegexValidator("(A|P)\\d{2}")
|
||||||
|
],
|
||||||
|
verbose_name="semester",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="timetables",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "timetable",
|
||||||
|
"verbose_name_plural": "timetables",
|
||||||
|
"permissions": [("add_self_timetable", "Can add its own timetable")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TimetableSlot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"weekday",
|
||||||
|
models.SmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Monday"),
|
||||||
|
(2, "Tuesday"),
|
||||||
|
(3, "Wednesday"),
|
||||||
|
(4, "Thursday"),
|
||||||
|
(5, "Friday"),
|
||||||
|
(6, "Saturday"),
|
||||||
|
(7, "Sunday"),
|
||||||
|
],
|
||||||
|
verbose_name="weekday",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"start_at",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
validators=[django.core.validators.MaxValueValidator(95)],
|
||||||
|
verbose_name="start at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"end_at",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
validators=[django.core.validators.MaxValueValidator(95)],
|
||||||
|
verbose_name="end at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"course_type",
|
||||||
|
models.CharField(max_length=12, verbose_name="course type"),
|
||||||
|
),
|
||||||
|
("room", models.CharField(max_length=32, verbose_name="room")),
|
||||||
|
("ue", models.CharField(max_length=32, verbose_name="ue")),
|
||||||
|
(
|
||||||
|
"week_group",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[("A", "A"), ("B", "B")],
|
||||||
|
default="",
|
||||||
|
max_length=1,
|
||||||
|
verbose_name="week group",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timetable",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="slots",
|
||||||
|
to="timetable.timetable",
|
||||||
|
verbose_name="timetable",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "timetable slot",
|
||||||
|
"verbose_name_plural": "timetable slots",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="timetable",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("user", "semester"), name="timetable_unique_user_semester"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="timetableslot",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("end_at__gt", models.F("start_at"))),
|
||||||
|
name="timetable_slot_end_after_start",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1 +1,102 @@
|
|||||||
# Create your models here.
|
# Create your models here.
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.validators import MaxValueValidator, RegexValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import F, Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from core.utils import get_semester_code
|
||||||
|
|
||||||
|
|
||||||
|
class Timetable(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
verbose_name=_("user"),
|
||||||
|
related_name="timetables",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
semester = models.CharField(
|
||||||
|
_("semester"),
|
||||||
|
default=get_semester_code,
|
||||||
|
validators=[RegexValidator(r"(A|P)\d{2}")],
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("timetable")
|
||||||
|
verbose_name_plural = _("timetables")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "semester"), name="timetable_unique_user_semester"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
permissions = [("add_self_timetable", "Can add its own timetable")]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.user} {self.semester}"
|
||||||
|
|
||||||
|
|
||||||
|
class TimetableSlot(models.Model):
|
||||||
|
MINUTES_PER_SLOT = 15
|
||||||
|
|
||||||
|
class WeekDay(models.IntegerChoices):
|
||||||
|
MONDAY = 1
|
||||||
|
TUESDAY = 2
|
||||||
|
WEDNESDAY = 3
|
||||||
|
THURSDAY = 4
|
||||||
|
FRIDAY = 5
|
||||||
|
SATURDAY = 6
|
||||||
|
SUNDAY = 7
|
||||||
|
|
||||||
|
class WeekGroup(models.TextChoices):
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
|
BOTH = ""
|
||||||
|
|
||||||
|
timetable = models.ForeignKey(
|
||||||
|
Timetable,
|
||||||
|
verbose_name=_("timetable"),
|
||||||
|
related_name="slots",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay)
|
||||||
|
# there are 4 * 24 = 96 quarters in a day, and we start counting at 0
|
||||||
|
start_at = models.PositiveSmallIntegerField(
|
||||||
|
_("start at"), validators=[MaxValueValidator(95)]
|
||||||
|
)
|
||||||
|
end_at = models.PositiveSmallIntegerField(
|
||||||
|
_("end at"), validators=[MaxValueValidator(95)]
|
||||||
|
)
|
||||||
|
course_type = models.CharField(_("course type"), max_length=12)
|
||||||
|
room = models.CharField(_("room"), max_length=32)
|
||||||
|
ue = models.CharField(_("ue"), max_length=32)
|
||||||
|
week_group = models.CharField(
|
||||||
|
_("week group"),
|
||||||
|
max_length=1,
|
||||||
|
choices=WeekGroup,
|
||||||
|
default=WeekGroup.BOTH,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("timetable slot")
|
||||||
|
verbose_name_plural = _("timetable slots")
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
condition=Q(end_at__gt=F("start_at")),
|
||||||
|
name="timetable_slot_end_after_start",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_hour(self):
|
||||||
|
return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end_hour(self):
|
||||||
|
return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from annotated_types import Ge, Le
|
||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from timetable.models import Timetable, TimetableSlot
|
||||||
|
|
||||||
|
|
||||||
|
class TimetableSlotSchema(ModelSchema):
|
||||||
|
weekday: TimetableSlot.WeekDay = Field(
|
||||||
|
description=(
|
||||||
|
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
|
||||||
|
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
start_at: Annotated[int, Ge(0), Le(96)]
|
||||||
|
end_at: Annotated[int, Ge(0), Le(96)]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TimetableSlot
|
||||||
|
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTimetableSchema(Schema):
|
||||||
|
is_viewable: bool
|
||||||
|
slots: list[TimetableSlotSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class TimetableSchema(ModelSchema):
|
||||||
|
slots: list[TimetableSlotSchema]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Timetable
|
||||||
|
fields = ["id", "semester"]
|
||||||
+89
-33
@@ -1,4 +1,6 @@
|
|||||||
import html2canvas from "html2canvas";
|
import html2canvas from "html2canvas";
|
||||||
|
import { AlertMessage } from "#core:utils/alert-message";
|
||||||
|
import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi";
|
||||||
|
|
||||||
// see https://regex101.com/r/QHSaPM/3
|
// see https://regex101.com/r/QHSaPM/3
|
||||||
const TIMETABLE_ROW_RE: RegExp =
|
const TIMETABLE_ROW_RE: RegExp =
|
||||||
@@ -76,17 +78,82 @@ function parseSlots(s: string): TimetableSlot[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
|
/**
|
||||||
|
* Alpine data that can parse a timetable, save it and export it to png.
|
||||||
|
*/
|
||||||
Alpine.data("timetableGenerator", () => ({
|
Alpine.data("timetableGenerator", () => ({
|
||||||
content: DEFAULT_TIMETABLE,
|
content: DEFAULT_TIMETABLE,
|
||||||
error: null as { incorrectRow?: string },
|
error: null as { incorrectRow?: string } | null,
|
||||||
displayedWeekdays: [] as WeekDay[],
|
|
||||||
courses: [] as TimetableSlot[],
|
courses: [] as TimetableSlot[],
|
||||||
|
edtPublic: false,
|
||||||
|
saving: false,
|
||||||
|
timetableSavedMessage: new AlertMessage(),
|
||||||
|
|
||||||
|
generate() {
|
||||||
|
try {
|
||||||
|
this.courses = parseSlots(this.content);
|
||||||
|
this.error = null;
|
||||||
|
} catch (err) {
|
||||||
|
type ParseRowError = { cause: { row: string } };
|
||||||
|
this.error = { incorrectRow: (err as ParseRowError)?.cause?.row };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePng() {
|
||||||
|
const elem = document.getElementById("timetable");
|
||||||
|
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
|
||||||
|
const downloadLink = document.createElement("a");
|
||||||
|
downloadLink.href = img;
|
||||||
|
downloadLink.download = "edt.png";
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
downloadLink.remove();
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSith() {
|
||||||
|
this.saving = true;
|
||||||
|
const res = await timetableSaveTimetable({
|
||||||
|
body: {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||||
|
is_viewable: this.edtPublic,
|
||||||
|
slots: this.courses.map((c) =>
|
||||||
|
Object.assign({}, c, {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||||
|
start_at: c.startSlot,
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
||||||
|
end_at: c.endSlot,
|
||||||
|
weekday: WEEKDAYS.indexOf(c.weekday) + 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.response?.ok) {
|
||||||
|
this.timetableSavedMessage.display(
|
||||||
|
gettext("This timetable has been saved in your profile"),
|
||||||
|
{ success: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.timetableSavedMessage.display(
|
||||||
|
interpolate(gettext("Error %d: timetable save failed"), res.response?.status),
|
||||||
|
{ success: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.saving = false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alpine data dedicated to timetable display only.
|
||||||
|
* It can interact with the `timetableGenerator` data with x-model and x-modelable
|
||||||
|
* (for example of this, see timetable/generator.jinja)
|
||||||
|
*/
|
||||||
|
Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({
|
||||||
|
displayedWeekdays: [] as WeekDay[],
|
||||||
startSlot: 0,
|
startSlot: 0,
|
||||||
endSlot: 0,
|
endSlot: 0,
|
||||||
table: {
|
table: { height: 0, width: 0 },
|
||||||
height: 0,
|
slots: slots,
|
||||||
width: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
colors: {} as Record<string, string>,
|
colors: {} as Record<string, string>,
|
||||||
colorPalette: [
|
colorPalette: [
|
||||||
@@ -103,18 +170,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
"#1b5e20",
|
"#1b5e20",
|
||||||
],
|
],
|
||||||
|
|
||||||
generate() {
|
init() {
|
||||||
try {
|
this.$watch("slots", () => this.display());
|
||||||
this.courses = parseSlots(this.content);
|
},
|
||||||
this.error = null;
|
|
||||||
} catch (err) {
|
|
||||||
this.error = { incorrectRow: err?.cause?.row };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
display() {
|
||||||
// color each UE
|
// color each UE
|
||||||
let colorIndex = 0;
|
let colorIndex = 0;
|
||||||
for (const slot of this.courses) {
|
for (const slot of this.slots) {
|
||||||
if (!this.colors[slot.ueCode]) {
|
if (!this.colors[slot.ueCode]) {
|
||||||
this.colors[slot.ueCode] =
|
this.colors[slot.ueCode] =
|
||||||
this.colorPalette[colorIndex % this.colorPalette.length];
|
this.colorPalette[colorIndex % this.colorPalette.length];
|
||||||
@@ -123,13 +186,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
||||||
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
|
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
|
||||||
);
|
);
|
||||||
this.startSlot = this.courses.reduce(
|
this.startSlot = this.slots.reduce(
|
||||||
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
||||||
25 * 4,
|
25 * 4,
|
||||||
);
|
);
|
||||||
this.endSlot = this.courses.reduce(
|
this.endSlot = this.slots.reduce(
|
||||||
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
@@ -138,7 +201,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStyle(slot: TimetableSlot) {
|
getStyle(slot: TimetableSlot) {
|
||||||
const hasWeekGroup = slot.weekGroup !== undefined;
|
const hasWeekGroup = !!slot.weekGroup;
|
||||||
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
||||||
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
||||||
return {
|
return {
|
||||||
@@ -150,15 +213,19 @@ document.addEventListener("alpine:init", () => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getHours(): [string, object][] {
|
/**
|
||||||
|
* Return the hours that should be displayed on this timetable,
|
||||||
|
* with their `top` placing
|
||||||
|
*/
|
||||||
|
getHours(): [string, { top: `${number}px` }][] {
|
||||||
let hour: number = Number.parseInt(
|
let hour: number = Number.parseInt(
|
||||||
this.courses
|
this.slots
|
||||||
.map((c: TimetableSlot) => c.startHour)
|
.map((c: TimetableSlot) => c.startHour)
|
||||||
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
||||||
.split(":")[0],
|
.split(":")[0],
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const res: [string, object][] = [];
|
const res: [string, { top: `${number}px` }][] = [];
|
||||||
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
||||||
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
||||||
hour += 1;
|
hour += 1;
|
||||||
@@ -169,16 +236,5 @@ document.addEventListener("alpine:init", () => {
|
|||||||
getWidth() {
|
getWidth() {
|
||||||
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
|
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
|
||||||
},
|
},
|
||||||
|
|
||||||
async savePng() {
|
|
||||||
const elem = document.getElementById("timetable");
|
|
||||||
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = img;
|
|
||||||
downloadLink.download = "edt.png";
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
downloadLink.remove();
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@import "core/static/core/colors";
|
@import "core/static/core/colors";
|
||||||
|
|
||||||
#timetable {
|
.timetable {
|
||||||
--hour-side-width: 60px;
|
--hour-side-width: 60px;
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
{% extends 'core/base.jinja' %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% from "timetable/macros.jinja" import display_timetable %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -60,44 +62,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
||||||
</form>
|
</form>
|
||||||
<div
|
{{ display_timetable("courses") }}
|
||||||
id="timetable"
|
<template x-if="courses.length > 0">
|
||||||
x-show="table.height > 0 && table.width > 0"
|
<section>
|
||||||
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
|
{% if user.has_perm("timetable.add_self_timetable") %}
|
||||||
>
|
<div id="timetable-save" class="margin-bottom">
|
||||||
<div class="header">
|
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
|
||||||
<template x-for="weekday in displayedWeekdays">
|
<form>
|
||||||
<span x-text="weekday"></span>
|
<fieldset>
|
||||||
</template>
|
<input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
|
||||||
</div>
|
<label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
|
||||||
<div class="content">
|
<span class="helptext">
|
||||||
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
|
{% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
|
||||||
<template x-for="[hour, style] in getHours()">
|
You can change this at any time in <a href="{{ url }}">your preferences</a>
|
||||||
<div class="hour" :style="style">
|
{% endtrans %}
|
||||||
<div x-text="hour"></div>
|
</span>
|
||||||
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
|
</fieldset>
|
||||||
</div>
|
<button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
|
||||||
</template>
|
{% trans %}Save timetable{% endtrans %}
|
||||||
</div>
|
</button>
|
||||||
<div class="courses">
|
<p
|
||||||
<template x-for="course in courses">
|
class="alert"
|
||||||
<div class="slot" :style="getStyle(course)">
|
:class="timetableSavedMessage.success ? 'alert-green' : 'alert-red'"
|
||||||
<span class="course-type" x-text="course.courseType"></span>
|
x-show="timetableSavedMessage.open"
|
||||||
<span x-text="course.ueCode"></span>
|
x-transition.duration.500ms
|
||||||
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
|
x-text="timetableSavedMessage.content"
|
||||||
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
|
></p>
|
||||||
<span x-text="course.room"></span>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
{% endif %}
|
||||||
</div>
|
<h3 class="margin-bottom">{% trans %}Export{% endtrans %}</h3>
|
||||||
</div>
|
<button class="margin-bottom btn btn-blue" @click="savePng">
|
||||||
</div>
|
{% trans %}Export to PNG{% endtrans %}
|
||||||
<button
|
</button>
|
||||||
class="margin-bottom btn btn-blue"
|
</section>
|
||||||
@click="savePng"
|
</template>
|
||||||
x-show="table.height > 0 && table.width > 0"
|
|
||||||
>
|
|
||||||
{% trans %}Save to PNG{% endtrans %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{# HTML+AlpineJS to display a timetable
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ues: either a JS variable name or an array of object
|
||||||
|
satisfying the TimetableSlot interface
|
||||||
|
reactive: if True and `ues` is a variable used in an Alpine context,
|
||||||
|
then this timetable will react to modifications and
|
||||||
|
change its own display
|
||||||
|
#}
|
||||||
|
{% macro display_timetable(ues) %}
|
||||||
|
<div
|
||||||
|
x-data="timetableDisplay({{ ues }})"
|
||||||
|
x-model="{{ ues }}"
|
||||||
|
x-modelable="slots"
|
||||||
|
class="timetable"
|
||||||
|
x-show="slots.length > 0"
|
||||||
|
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
|
||||||
|
>
|
||||||
|
<div class="header">
|
||||||
|
<template x-for="weekday in displayedWeekdays">
|
||||||
|
<span x-text="weekday"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
|
||||||
|
<template x-for="[hour, style] in getHours()">
|
||||||
|
<div class="hour" :style="style">
|
||||||
|
<div x-text="hour"></div>
|
||||||
|
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="courses">
|
||||||
|
<template x-for="course in slots">
|
||||||
|
<div class="slot" :style="getStyle(course)">
|
||||||
|
<span class="course-type" x-text="course.courseType"></span>
|
||||||
|
<span x-text="course.ueCode"></span>
|
||||||
|
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
|
||||||
|
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
|
||||||
|
<span x-text="course.room"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% from "timetable/macros.jinja" import display_timetable %}
|
||||||
|
|
||||||
|
|
||||||
|
{%- block additional_css -%}
|
||||||
|
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block additional_js -%}
|
||||||
|
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% for timetable in timetables %}
|
||||||
|
<details
|
||||||
|
class="accordion"
|
||||||
|
name="{{ timetable.semester }}"
|
||||||
|
{% if loop.first %}open{% endif %}
|
||||||
|
>
|
||||||
|
<summary>{{ timetable.semester }}</summary>
|
||||||
|
<div class="accordion-content">
|
||||||
|
{{ display_timetable(timetable.semester) }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{%- block script -%}
|
||||||
|
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
|
||||||
|
<script>
|
||||||
|
{%- for timetable in timetables -%}
|
||||||
|
const {{ timetable.semester }} = [
|
||||||
|
{%- for slot in timetable.slots.all() -%}
|
||||||
|
{
|
||||||
|
courseType: "{{ slot.course_type }}",
|
||||||
|
room: "{{ slot.room }}",
|
||||||
|
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
|
||||||
|
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
|
||||||
|
startSlot: {{ slot.start_at }},
|
||||||
|
endSlot: {{ slot.end_at }},
|
||||||
|
ueCode: "{{ slot.ue }}",
|
||||||
|
{%- if slot.week_group -%}
|
||||||
|
weekGroup: "{{ slot.week_group }}",
|
||||||
|
{%- endif -%}
|
||||||
|
weekday: "{{ weekdays[slot.weekday] }}"
|
||||||
|
},
|
||||||
|
{%- endfor -%}
|
||||||
|
];
|
||||||
|
{%- endfor -%}
|
||||||
|
</script>
|
||||||
|
{%- endblock -%}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Create your tests here.
|
|
||||||
Whitespace-only changes.
@@ -0,0 +1,20 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from timetable.models import TimetableSlot
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveTimetable(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls) -> None:
|
||||||
|
cls.user = subscriber_user.make()
|
||||||
|
cls.payload = {
|
||||||
|
"is_viewable": True,
|
||||||
|
"slots": [
|
||||||
|
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
|
||||||
|
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
|
||||||
|
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize("can_save", [True, False])
|
||||||
|
def test_generator_ok(client: Client, can_save):
|
||||||
|
user = baker.make(User)
|
||||||
|
if can_save:
|
||||||
|
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
res = client.get(reverse("timetable:generator"))
|
||||||
|
assert res.status_code == 200
|
||||||
|
soup = BeautifulSoup(res.text, "lxml")
|
||||||
|
elem = soup.find(id="timetable-save")
|
||||||
|
|
||||||
|
if can_save:
|
||||||
|
assert elem is not None
|
||||||
|
else:
|
||||||
|
assert elem is None
|
||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from timetable.views import GeneratorView
|
from timetable.views import GeneratorView, UserTimetableView
|
||||||
|
|
||||||
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
|
urlpatterns = [
|
||||||
|
path("", GeneratorView.as_view(), name="generator"),
|
||||||
|
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
|
||||||
|
]
|
||||||
+50
-2
@@ -1,6 +1,54 @@
|
|||||||
# Create your views here.
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
from django.views.generic.detail import DetailView
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from core.views import TabedViewMixin
|
||||||
|
|
||||||
|
|
||||||
class GeneratorView(TemplateView):
|
class TimetableTabsMixin(TabedViewMixin):
|
||||||
|
tabs_title = _("Timetables")
|
||||||
|
|
||||||
|
def get_list_of_tabs(self):
|
||||||
|
res = [
|
||||||
|
{
|
||||||
|
"url": reverse("timetable:generator"),
|
||||||
|
"slug": "generator",
|
||||||
|
"name": _("Generator"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if self.request.user.timetables.exists():
|
||||||
|
res.append(
|
||||||
|
{
|
||||||
|
"url": reverse(
|
||||||
|
"core:user_timetable", kwargs={"user_id": self.request.user.id}
|
||||||
|
),
|
||||||
|
"slug": "saved",
|
||||||
|
"name": _("My timetables"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorView(TimetableTabsMixin, TemplateView):
|
||||||
template_name = "timetable/generator.jinja"
|
template_name = "timetable/generator.jinja"
|
||||||
|
current_tab = "generator"
|
||||||
|
|
||||||
|
|
||||||
|
class UserTimetableView(UserPassesTestMixin, TimetableTabsMixin, DetailView):
|
||||||
|
model = User
|
||||||
|
pk_url_kwarg = "user_id"
|
||||||
|
template_name = "timetable/user_timetable.jinja"
|
||||||
|
current_tab = "saved"
|
||||||
|
|
||||||
|
def test_func(self) -> bool | None:
|
||||||
|
return self.request.user.id == self.kwargs[
|
||||||
|
"user_id"
|
||||||
|
] or self.request.user.has_perm("timetable.view_timetable")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
return super().get_context_data(**kwargs) | {
|
||||||
|
"timetables": list(self.object.timetables.prefetch_related("slots"))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user