Author SHA1 Message Date
dependabot[bot] 1df2277f3c Bump virtualenv from 21.14.4 to 21.14.13
Bumps [virtualenv](https://github.com/pypa/virtualenv) from 21.14.4 to 21.14.13.
- [Release notes](https://github.com/pypa/virtualenv/releases)
- [Changelog](https://github.com/pypa/virtualenv/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pypa/virtualenv/compare/21.14.4...21.14.13)

---
updated-dependencies:
- dependency-name: virtualenv
  dependency-version: 21.14.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-24 00:02:51 +00:00
thomas girod 86730d4f7a Merge pull request #1509 from ae-utbm/taiste
Third-party auth, UE guide style, CGU, and bugfixes
2026-10-02 18:51:20 +02:00
thomas girod 6a6ed5462f Merge pull request #1491 from ae-utbm/taiste
Python 3.14, HTMX4, client-side markdown, pedagogy rework and others
2026-09-18 19:19:48 +02:00
thomas girod d914fa0658 Merge pull request #1463 from ae-utbm/taiste
Pre school year release
2026-08-25 22:37:49 +02:00
94 changed files with 2920 additions and 2781 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.17.2
rev: v0.16.10
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.6.3"]
additional_dependencies: ["@biomejs/biome@2.5.15"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.11
hooks:
+113 -2
View File
@@ -31,10 +31,20 @@ from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property
from django.utils.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.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.schemas import SaleFilterSchema
@@ -121,6 +131,107 @@ class ClubAdminEditForm(ClubEditForm):
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):
begin_date = forms.DateTimeField(
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"),
]
+167 -3
View File
@@ -28,7 +28,8 @@ from functools import reduce
from typing import Iterable, Self
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.models import Exists, F, OuterRef, Q
from django.urls import reverse
@@ -40,7 +41,7 @@ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
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):
@@ -237,7 +238,7 @@ class Club(models.Model):
def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user."""
return user.has_perm("club.change_club") or self.has_rights_in_club(user)
return self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
@@ -632,6 +633,169 @@ class Membership(models.Model):
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):
"""A link type, in order to group links and give them icons.
-9
View File
@@ -26,15 +26,6 @@
{% endblock %}
{% block content %}
{% if user.is_authenticated and user.can_edit(club) %}
<a
href="{{ url("core:page_edit", page_name=club.page.get_full_name()) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-pen-to-square"></i> {% trans %}Edit{% endtrans %}
</a>
{% endif %}
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
-79
View File
@@ -1,79 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if club.has_rights_in_club(user) %}
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i> {% trans %}Create news{% endtrans %}
</a>
{% endif %}
{% for semester_code, semester_events in events %}
<h3>{{ semester_code }}</h3>
{% for event in semester_events %}
<details class="accordion">
<summary>{{ event.title }}</summary>
<div class="accordion-content">
{# Events that occur weekly from the start to the end of the semester
would take a lot of space if shown by default.
Thus, if the event has more than one date, those are
hidden by a details element.
If there is only one date, it is directly displayed. #}
{% set dates = event.dates.all()|list %}
{% if dates|length == 1 %}
<em>
<time datetime="{{ dates[0].start_date.isoformat(timespec="seconds") }}">
{{ dates[0].start_date|localtime|date(DATETIME_FORMAT) }},
{{ dates[0].start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ dates[0].end_date.isoformat(timespec="seconds") }}">
{{ dates[0].end_date|localtime|date(DATETIME_FORMAT) }},
{{ dates[0].end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</em>
{% else %}
<details>
<summary>
{% trans trimmed nb_occurrences=dates|length %}
Dates ({{ nb_occurrences }} occurrences)
{% endtrans %}
</summary>
<ul>
{% for d in dates %}
<li>
<time datetime="{{ d.start_date.isoformat(timespec="seconds") }}">
{{ d.start_date|localtime|date(DATETIME_FORMAT) }},
{{ d.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ d.end_date.isoformat(timespec="seconds") }}">
{{ d.end_date|localtime|date(DATETIME_FORMAT) }},
{{ d.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<h4>{% trans %}Permalink{% endtrans %}</h4>
<p>{{ request.build_absolute_uri(event.get_absolute_url())|urlize }}</p>
<h4>{% trans %}Summary{% endtrans %}</h4>
{% if event.summary %}
{{ event.summary|markdown }}
{% else %}
<p><em>{% trans %}No summary{% endtrans %}</em></p>
{% endif %}
<h4>{% trans %}Description{% endtrans %}</h4>
{% if event.content %}
{{ event.content|markdown }}
{% else %}
<p><em>{% trans %}No detailed description{% endtrans %}</em></p>
{% endif %}
</div>
</details>
{% endfor %}
<br />
{% endfor %}
{% endblock %}
+3
View File
@@ -15,6 +15,9 @@
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</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 %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
+108
View File
@@ -0,0 +1,108 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import select_all_checkbox %}
{% block title %}
{% trans %}Mailing lists{% endtrans %}
{% endblock %}
{% block content %}
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
{% if mailings_not_moderated %}
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
<ul>
{% for mailing in mailings_not_moderated %}
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if mailings_moderated %}
{% for mailing in mailings_moderated %}
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
{%- if user.is_owner(mailing) -%}
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
{%- endif -%}
</h2>
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
</form>
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
{% if form_mailing_removal.field.choices %}
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
{% csrf_token %}
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
<table>
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Email{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for widget in form_mailing_removal.subwidgets %}
{% set user = ms[widget.data.value.value][0] %}
<tr>
<td>{{ user.get_username }}</td>
<td>{{ user.get_email }}</td>
<td>{{ widget.tag() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ form_mailing_removal.errors }}
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
</form>
{% else %}
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
{% endif %}
{% endfor %}
{% else %}
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
{% endif %}
<p>{{ form.non_field_errors() }}</p>
{% if mailings_moderated %}
<h2>{% trans %}New member{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.subscription_mailing.errors }}
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
{{ form.subscription_mailing }}
</p>
<p>
{{ form.subscription_users.errors }}
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
{{ form.subscription_users }}
<span class="helptext">{{ form.subscription_users.help_text }}</span>
</p>
<p>
{{ form.subscription_email.errors }}
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
{{ form.subscription_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
</form>
{% endif %}
<h2>{% trans %}New mailing{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.mailing_email.errors }}
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
{{ form.mailing_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
</form>
{% endblock %}
-60
View File
@@ -1,60 +0,0 @@
from datetime import datetime
from django.test import RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import get_current_timezone
from model_bakery import baker
from club.models import Club
from club.views import ClubNewsView
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import User
class TestClubNews(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.news = baker.make(
News, club=cls.club, is_published=True, author=baker.make(User), _quantity=5
)
d = datetime(year=2025, month=10, day=1, tzinfo=get_current_timezone())
dates = [
baker.prepare(NewsDate, news=cls.news[0], start_date=d),
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=8)),
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=15)),
baker.prepare(NewsDate, news=cls.news[1], start_date=d.replace(month=5)),
baker.prepare(NewsDate, news=cls.news[2], start_date=d.replace(month=4)),
baker.prepare(NewsDate, news=cls.news[3], start_date=d.replace(year=2024)),
baker.prepare(NewsDate, news=cls.news[4], start_date=d.replace(year=2020)),
]
NewsDate.objects.bulk_create(dates)
cls.url = reverse("club:news_list", kwargs={"club_id": cls.club.id})
def test_load_page(self):
"""Just test that the page loads."""
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert res.status_code == 200
def test_page_data(self):
"""Test that we display the good events."""
# For this test, the object we want to test
# is built with `itertools.groupby`.
# However, it is consumed when rendering the template,
# making it unusable for later testing purposes,
# so we must use lower level django APIs in this test.
request = RequestFactory().get(self.url)
request.user = subscriber_user.make()
response = ClubNewsView.as_view()(request, club_id=self.club.id)
events = [
(semester_code, list(dates))
for semester_code, dates in response.context_data["events"]
]
assert events == [
("A25", [self.news[0]]),
("P25", [self.news[1], self.news[2]]),
("A24", [self.news[3]]),
("A20", [self.news[4]]),
]
+328
View File
@@ -0,0 +1,328 @@
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership
from core.models import User
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.rbatsbak = User.objects.get(username="rbatsbak")
cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root")
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
Membership(
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True),
).save()
def test_mailing_list_add_no_moderation(self):
# Test with Communication admin
self.client.force_login(self.comunity)
response = self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
self.assertRedirects(response, self.mail_url)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion foyer@utbm.fr" in response.text
# Test with Root
self.client.force_login(self.root)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion mde@utbm.fr" in response.text
def test_mailing_list_add_moderation(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "Liste de diffusion mde@utbm.fr" not in content
assert "<p>Listes de diffusions en attente de modération</p>" in content
assert "<li>mde@utbm.fr" in content
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(self.mail_url)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.force_login(self.krophil)
response = self.client.get(self.mail_url)
assert response.status_code == 403
def test_add_new_subscription_fail_not_moderated(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" not in response.text
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" in response.text
# Add multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
# Add arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
# Add user and arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "more.arbitrary@git.an",
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
assert "more.arbitrary@git.an" in content
assert "krophil@git.an" in content
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# No mailing specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
assert response.status_code == 200
assert _("This field is required") in response.text
# One of the selected users doesn't exist
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# An user has no email address
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("One of the selected users doesn't have an email address"),
response.text,
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("This email is already suscribed in this mailing"),
response.text,
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" in content
# Delete one user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" not in content
# Delete multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: [
user.id
for user in mde.subscriptions.filter(
user__in=[self.rbatsbak, self.comunity]
).all()
],
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" not in content
assert "richard@git.an" not in content
assert "krophil@git.an" not in content
+9 -57
View File
@@ -1,9 +1,8 @@
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
@@ -17,20 +16,13 @@ if TYPE_CHECKING:
@pytest.mark.django_db
@pytest.mark.parametrize("user_factory", [lambda: None, subscriber_user.make])
def test_page_display_on_club_main_page(
client: Client, user_factory: Callable[[], User | None]
):
def test_page_display_on_club_main_page(client: Client):
"""Test the club Page is properly displayed on the club main view"""
club = baker.make(Club)
# create another previous revision, to test that we display the most recent one
baker.make(PageRev, page=club.page, revision=1)
content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=2, content=content)
if user := user_factory():
client.force_login(user)
baker.make(PageRev, page=club.page, revision=1, content=content)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page").find(class_="markdown")
@@ -38,60 +30,20 @@ def test_page_display_on_club_main_page(
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "can_view_tabs", "can_view_edit_button"),
[
(lambda _: None, False, False),
(lambda _: baker.make(User), False, False),
(lambda _: subscriber_user.make(), True, False),
(
# admin user
lambda _: subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_club")]
),
True,
True,
),
(
# club board member
lambda club: (
baker.make(
Membership,
club=club,
role=baker.make(ClubRole, is_board=True),
user=subscriber_user.make(),
).user
),
True,
True,
),
],
ids=["anonymous", "public", "subscriber", "admin", "club board"],
)
@pytest.mark.django_db
def test_club_main_page_displayed_elements(
client: Client, user_factory, can_view_tabs, can_view_edit_button
):
"""Test that the appropriate elements are displayed depending on user status."""
def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty"""
club = baker.make(Club)
if user := user_factory(club):
client.force_login(user)
club.page.revisions.all().delete()
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
tabs = soup.find(class_="tool_bar")
assert tabs is not None if can_view_tabs else tabs is None
edit_btn = soup.find(
"a",
href=reverse("core:page_edit", kwargs={"page_name": club.page.get_full_name()}),
)
assert edit_btn is not None if can_view_edit_button else edit_btn is None
detail_html = soup.find(id="club-page")
assert detail_html.find_all("markdown") == []
@pytest.mark.django_db
def test_page_revision(client: Client):
"""Test that accessing a previous revision of the club page works."""
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
+20 -2
View File
@@ -29,8 +29,8 @@ from club.views import (
ClubCreateView,
ClubEditView,
ClubListView,
ClubMailingView,
ClubMembersView,
ClubNewsView,
ClubOldMembersView,
ClubPageEditView,
ClubPageHistView,
@@ -43,6 +43,9 @@ from club.views import (
ClubSellingView,
ClubToolsView,
ClubView,
MailingAutoGenerationView,
MailingDeleteView,
MailingSubscriptionDeleteView,
MembershipDeleteView,
MembershipSetOldView,
PosterCreateView,
@@ -93,6 +96,22 @@ urlpatterns = [
"<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>/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(
"membership/<int:membership_id>/set_old/",
MembershipSetOldView.as_view(),
@@ -117,5 +136,4 @@ urlpatterns = [
PosterDeleteView.as_view(),
name="poster_delete",
),
path("<int:club_id>/news/", ClubNewsView.as_view(), name="news_list"),
]
+217 -58
View File
@@ -34,9 +34,9 @@ from django.contrib.auth.mixins import (
UserPassesTestMixin,
)
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.db.models import F, Prefetch, Q, Sum
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.functions import Length
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
@@ -59,6 +59,7 @@ from club.forms import (
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
)
from club.models import (
@@ -66,9 +67,11 @@ from club.models import (
ClubLink,
ClubRole,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from com.models import NewsDate, Poster
from com.models import Poster
from com.views import (
PosterCreateBaseView,
PosterDeleteBaseView,
@@ -77,7 +80,6 @@ from com.views import (
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.utils import get_semester_code
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import (
AllowFragment,
@@ -104,40 +106,42 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name()
def get_list_of_tabs(self):
if not self.request.user.has_perm("club.view_club"):
# The user will still be able to see the club main page,
# but nothing else.
# It's useless to display a tab with only one item, so return nothing.
return []
tab_list = [
{
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos",
"name": _("Infos"),
},
{
"url": reverse("club:club_members", kwargs={"club_id": self.object.id}),
"slug": "members",
"name": _("Members"),
},
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
},
{
"url": reverse("club:club_hist", kwargs={"club_id": self.object.id}),
"slug": "history",
"name": _("History"),
},
{
"url": reverse("club:news_list", kwargs={"club_id": self.object.id}),
"slug": "events",
"name": _("Events"),
},
}
]
if self.request.user.has_perm("club.view_club"):
tab_list.extend(
[
{
"url": reverse(
"club:club_members", kwargs={"club_id": self.object.id}
),
"slug": "members",
"name": _("Members"),
},
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
},
]
)
if self.object.page:
tab_list.append(
{
"url": reverse(
"club:club_hist", kwargs={"club_id": self.object.id}
),
"slug": "history",
"name": _("History"),
}
)
if self.request.user.can_edit(self.object):
tab_list.extend(
[
@@ -155,6 +159,21 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "edit",
"name": _("Edit"),
},
]
)
if self.object.page and self.request.user.can_edit(self.object.page):
tab_list.append(
{
"url": reverse(
"core:page_edit",
kwargs={"page_name": self.object.page.get_full_name()},
),
"slug": "page_edit",
"name": _("Edit club page"),
}
)
tab_list.extend(
[
{
"url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id}
@@ -162,6 +181,13 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "sellings",
"name": _("Sellings"),
},
{
"url": reverse(
"club:mailing", kwargs={"club_id": self.object.id}
),
"slug": "mailing",
"name": _("Mailing list"),
},
{
"url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id}
@@ -716,6 +742,166 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
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(
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
):
@@ -771,30 +957,3 @@ class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
class ClubNewsView(PermissionRequiredMixin, ClubTabsMixin, DetailView):
pk_url_kwarg = "club_id"
model = Club
permission_required = "club.view_club"
template_name = "club/club_news_list.jinja"
current_tab = "events"
def get_context_data(self, **kwargs: Any):
events = list(
self.object.news.viewable_by(self.request.user)
.annotate(
first_date=Subquery(
NewsDate.objects.filter(news_id=OuterRef("pk"))
.order_by("start_date")
.values("start_date")[:1]
),
)
.prefetch_related("dates")
.order_by("-first_date")
)
return super().get_context_data(**kwargs) | {
"events": itertools.groupby(
events, key=lambda event: get_semester_code(event.first_date.date())
),
}
+4 -1
View File
@@ -259,7 +259,10 @@ class Weekmail(models.Model):
return f"Weekmail {self.id} (sent: {self.sent}) - {self.title}"
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 = [
i[0]
for i in Preferences.objects.filter(receive_weekmail=True).values_list(
+1
View File
@@ -5,6 +5,7 @@
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
+8 -8
View File
@@ -32,7 +32,7 @@
h3 {
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: var(--bg-yellow);
background: lighten($second-color, 5%);
box-shadow: var(--box-shadow);
padding: .75rem;
margin: 0 0 0.5em 0;
@@ -85,7 +85,7 @@
#links_content {
overflow: auto;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding: 1em;
@@ -111,7 +111,7 @@
}
#birthdays_content {
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
@@ -123,7 +123,7 @@
padding: 0.5em;
&:nth-child(even) {
background: var(--bg-off-white);
background: $secondary-neutral-light-color;
}
}
@@ -141,7 +141,7 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 0;
margin-bottom: 0.5em;
@@ -153,8 +153,8 @@
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: var(--deepblue);
color: var(--text-white);
background: $primary-neutral-dark-color;
color: $white-color;
text-transform: uppercase;
text-align: center;
font-weight: bold;
@@ -180,7 +180,7 @@
}
.news_event:nth-of-type(even) {
background: var(--bg-white);
background: $primary-neutral-light-color;
}
.news_event {
+45
View File
@@ -0,0 +1,45 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Mailing lists administration{% endtrans %}
{% endblock %}
{% macro display_mailings(list) %}
<table>
<tr>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Club{%endtrans%}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
{% for mailing in list %}
<tr>
<td>{{ mailing.email_full }}</td>
<td><a href="{{ url('club:mailing', club_id=mailing.club.id) }}">{{ mailing.club }}</a></td>
<td>
<a href="{{ url('com:mailing_delete', mailing_id=mailing.id) }}">{% trans %}Delete{% endtrans %}</a> - {% if not mailing.is_moderated %}<a href="{{ url('com:mailing_moderate', mailing_id=mailing.id) }}">{% trans %}Moderate{% endtrans %}</a>{% else %}{% trans user=mailing.moderator %}Moderated by {{ user }}{% endtrans %}{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% block content %}
<h1>{% trans %}This page lists all mailing lists{% endtrans %}</h1>
{% if has_unmoderated %}
<h2>{% trans %}Not moderated mailing lists{% endtrans %}</h2>
{{ display_mailings(unmoderated) }}
{% endif %}
<h2>{% trans %}Moderated mailing lists{% endtrans %}</h2>
{% if has_moderated %}
{{ display_mailings(moderated) }}
{% else %}
<p>{% trans %}No mailing list existing{% endtrans %}</p>
{% endif %}
{% endblock %}
+14
View File
@@ -15,9 +15,12 @@
from django.urls import path
from club.views import MailingDeleteView
from com.views import (
AlertMsgEditView,
InfoMsgEditView,
MailingListAdminView,
MailingModerateView,
NewsAdminListView,
NewsCreateView,
NewsDeleteView,
@@ -79,6 +82,17 @@ urlpatterns = [
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
),
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/create/", PosterCreateView.as_view(), name="poster_create"),
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
+41 -3
View File
@@ -30,9 +30,11 @@ from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta
from django.conf import settings
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.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, Max, OuterRef, Value
from django.forms.models import modelform_factory
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.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.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -84,6 +86,11 @@ class ComTabsMixin(TabedViewMixin):
"slug": "alert",
"name": _("Alert message"),
},
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
},
{
"url": reverse("com:poster_list"),
"slug": "posters",
@@ -526,6 +533,37 @@ class WeekmailArticleDeleteView(CanEditPropMixin, DeleteView):
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):
"""List communication posters."""
+16
View File
@@ -1,15 +1,18 @@
from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Mailing
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
@@ -45,6 +48,19 @@ class UploadController(ControllerBase):
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")
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
+13 -15
View File
@@ -864,11 +864,7 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(
perms.filter(
codename__in=["add_news", "add_uecomment", "add_self_timetable"]
)
)
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
@@ -897,16 +893,18 @@ class Command(BaseCommand):
accounting_admin.permissions.add(
*list(
perms.filter(
codename__in=[
"view_customer",
"view_product",
"change_product",
"add_product",
"view_producttype",
"change_producttype",
"add_producttype",
"delete_selling",
]
Q(
codename__in=[
"view_customer",
"view_product",
"change_product",
"add_product",
"view_producttype",
"change_producttype",
"add_producttype",
"delete_selling",
]
)
).values_list("pk", flat=True)
)
)
@@ -1,22 +0,0 @@
# 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",
),
),
]
-8
View File
@@ -799,14 +799,6 @@ class Preferences(models.Model):
),
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(
_("get a notification for every click"), default=False
)
@@ -60,7 +60,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewClass: ["editor-preview", "markdown"], // default class + our own markdown class
previewRender: (plainText) => markdown(plainText),
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
+123
View File
@@ -0,0 +1,123 @@
/*--------------------------------RESET--------------------------------*/
/*--------------------------------RESET--------------------------------*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
/* list-style: none;*/
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+29 -25
View File
@@ -1,37 +1,41 @@
/**
* Sith theme variables
*/
:root {
--bg-yellow: #ffe270;
--bg-white: #f9fafb;
--bg-off-white: #f0f0f0;
--bg-darkened-off-white: #e3e3e3;
--bg-lightgrey: #d4d4d4;
$first-color: hsl(220, 100%, 50%);
$second-color: hsl(48, 100%, 67%);
$primary-color: hsl(219.9, 53.7%, 50%);
$secondary-color: hsl(204, 64%, 44%);
$primary-color-text: hsl(0, 0%, 100%);
$secondary-color-text: hsla(0, 0%, 0%, 0.87);
--text-white: #f9fafb;
--text-black: #2b2b2b;
--text-red: #eb2f06;
$primary-light-color: hsl(219.8, 46.4%, 64.9%);
$primary-dark-color: hsl(203, 75%, 40%);
--blue: #3b69c4;
--lightblue: #1a78b3;
// darkened-deepblue is 10% darker than deepblue
// This is explicitely declared, because the `darken` scss function
// isn't called at compile-time somehow.
--deepblue: #354a5f;
--darkened-deepblue: #23313e;
}
$secondary-light-color: hsl(40, 68%, 65%);
$secondary-dark-color: hsl(40, 68%, 35%);
$primary-neutral-color: hsl(219.6, 20.8%, 50%);
$primary-neutral-light-color: hsl(0, 0%, 94%);
$primary-neutral-dark-color: hsl(210, 29%, 29%);
$secondary-neutral-color: hsl(204, 64%, 44%);
$secondary-neutral-light-color: hsl(0, 0%, 91%);
$secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
/**
* Brand colours.
* Those are officially defined by their respective companies,
* and so are more appropriate as scss variables.
*/
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
+3 -3
View File
@@ -17,7 +17,7 @@
}
.card {
background-color: var(--bg-off-white);
background-color: $primary-neutral-light-color;
border-radius: 5px;
position: relative;
box-sizing: border-box;
@@ -30,12 +30,12 @@
gap: 20px;
&:disabled {
background-color: var(--bg-darkened-off-white);
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: var(--bg-darkened-off-white);
background-color: darken($primary-neutral-light-color, 5%);
}
&.selected {
+4 -8
View File
@@ -11,7 +11,7 @@ ui-tab-group {
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: var(--bg-off-white);
background-color: $primary-neutral-light-color;
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
@@ -25,27 +25,23 @@ ui-tab-group {
background-color: unset;
position: relative;
&:hover {
background-color: var(--bg-lightgrey);
}
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid var(--bg-lightgrey);
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
filter: brightness(85%);
border-bottom-color: darken($primary-neutral-light-color, 20%);
}
&.active:after {
border-bottom-color: var(--lightblue);
border-bottom-color: $primary-dark-color;
}
}
}
+34 -17
View File
@@ -2,75 +2,92 @@
@import "devices";
footer.bottom-links {
> section > a {
>section>a {
text-align: center;
}
text-align: center;
hr {
border: none;
}
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: var(--deepblue);
background-color: $primary-neutral-dark-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25em;
> section {
>section {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.8em;
}
a {
color: var(--text-white);
color: $white-color;
width: auto;
&:hover {
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
.fa-github {
color: var(--text-white);
color: $white-color;
}
hr {
border-top: var(--bg-white) 1px solid;
width: 100%;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
}
}
@media (min-width: $small-devices) {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: var(--deepblue);
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
color: var(--text-white);
&:hover {
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
}
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
}
}
+14 -11
View File
@@ -16,6 +16,7 @@
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -23,7 +24,7 @@
color: black;
&:not(.link-like):not(:disabled):hover {
background: var(--bg-lightgrey);
background: hsl(0, 0%, 83%);
}
}
@@ -52,7 +53,7 @@
.ts-control {
border: none;
text-decoration: none;
//background-color: $background-button-color;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -63,7 +64,7 @@
textarea {
border: none;
text-decoration: none;
//background-color: $background-button-color;
background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
@@ -74,7 +75,7 @@
border: none;
text-decoration: none;
font-size: 1.2em;
//background-color: $background-button-color;
background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
@@ -82,14 +83,14 @@
a:not(.button) {
text-decoration: none;
color: var(--lightblue);
color: $primary-dark-color;
&:hover {
color: var(--blue);
color: $primary-light-color;
}
&:active {
color: var(--blue);
color: $primary-color;
}
}
}
@@ -160,7 +161,6 @@ form {
fieldset {
margin-bottom: 1rem;
border: 0;
}
.row > label {
@@ -762,13 +762,16 @@ form {
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: var(--lightblue);
color: $primary-dark-color;
&:hover {
color: var(--blue);
color: $primary-light-color;
}
&.link-red {
color: var(--text-red);
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
+4 -4
View File
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
.header {
box-sizing: border-box;
background-color: var(--deepblue);
background-color: $deepblue;
box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
@@ -96,7 +96,7 @@ $background-color-hovered: #283747;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: var(--deepblue);
background-color: $deepblue;
width: 45px;
height: 25px;
padding: 0;
@@ -212,7 +212,7 @@ $background-color-hovered: #283747;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
background-color: var(--deepblue);
background-color: $deepblue;
}
>.options {
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
width: 10px;
height: 10px;
padding: 5px;
background-color: var(--text-red);
background-color: $red-text-color;
border-radius: 50%;
position: absolute;
top: -50%;
+5 -11
View File
@@ -1,4 +1,7 @@
.markdown {
--bg-lightgrey: rgb(240, 241, 245);
--border-lightgrey: rgb(215, 216, 220);
h1,
h2,
h3,
@@ -25,20 +28,11 @@
font-size: 14px;
tab-size: 4;
border-radius: 4px;
border: 1px solid var(--bg-lightgrey);
background-color: var(--bg-off-white);
border: 1px solid var(--border-lightgrey);
background-color: var(--bg-lightgrey);
padding: 1px 2px;
}
/**
* Some restyling rules for the cases where our own md css
* conflicts with the easymde editor preview css
*/
&.editor-preview {
pre {
background: unset;
}
}
pre code {
// code that is not inlined
margin: 15px;
+1 -1
View File
@@ -3,7 +3,7 @@
$desktop-size: 500px;
nav.navbar {
background-color: var(--lightblue);
background-color: $primary-dark-color;
margin: 1em;
color: white;
border-radius: 0.6em;
+10 -4
View File
@@ -6,21 +6,27 @@
margin: 30px;
button {
background-color: var(--bg-off-white);
background-color: $secondary-neutral-light-color;
min-width: 37px;
&:hover {
background-color: var(--bg-lightgrey);
background-color: darken($secondary-neutral-light-color, 10%);
}
&:disabled {
color: #fff;
background-color: darken($secondary-neutral-light-color, 5%);
}
&.active,
&.active:hover {
background-color: var(--deepblue);
background-color: $primary-neutral-color;
color: white;
border-radius: 50%;
}
.pagination-arrow {
&:first-of-type,
&:last-of-type {
// previous and next buttons
&:disabled {
// Hide the arrows when they are disabled, without
+101 -65
View File
@@ -2,21 +2,12 @@
@import "forms";
@import "devices";
/*----- RESET -----*/
* {
margin: 0;
padding: 0;
}
/*--------------------------------GENERAL------------------------------*/
body {
position: relative;
width: 100%;
font-family: sans-serif;
background-color: var(--bg-white);
line-height: 1;
}
[aria-busy] {
@@ -66,7 +57,7 @@ body {
.collapse-header {
color: white;
background-color: var(--deepblue);
background-color: $deepblue;
padding: 5px 10px;
display: flex;
align-items: center;
@@ -118,12 +109,6 @@ body {
display: none !important;
}
hr {
display: block;
margin: .5em auto;
color: gray;
}
/*--------------------------------HEADER-------------------------------*/
#info_boxes {
@@ -156,7 +141,7 @@ hr {
}
#info_box {
background: var(--bg-off-white);
background: $primary-neutral-light-color;
&:before {
content: "\f05a";
@@ -165,11 +150,11 @@ hr {
}
#alert_box {
background: var(--bg-yellow);
background: $second-color;
&:before {
content: "\f06a";
color: var(--text-white);
color: $white-color;
}
}
}
@@ -204,10 +189,10 @@ hr {
border-radius: 5px;
&.btn-blue {
background-color: var(--deepblue);
background-color: $deepblue;
&:not(:disabled):hover {
background-color: var(--darkened-deepblue);
background-color: darken($deepblue, 10%);
}
&:disabled {
@@ -230,7 +215,7 @@ hr {
&.btn-green {
$bg-color: rgba(0, 210, 83, 0.4);
background-color: $bg-color;
color: var(--text-black);
color: $black-color;
&:not(:disabled):hover {
background-color: darken($bg-color, 15%);
@@ -287,8 +272,8 @@ hr {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1.5em 3%;
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
background: var(--bg-white);
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
}
@@ -358,7 +343,7 @@ hr {
display: flex;
flex-wrap: wrap;
float: right;
background: var(--bg-off-white);
background: $primary-neutral-light-color;
padding: 5px;
border-radius: 6px;
text-align: center;
@@ -366,7 +351,7 @@ hr {
a {
padding: 7px;
display: inline-block;
color: var(--text-black);
color: $black-color;
font-weight: bold;
margin: 2px;
flex: 1;
@@ -374,14 +359,14 @@ hr {
white-space: nowrap;
&.selected_tab {
background: var(--blue);
color: var(--text-white);
background: $primary-color;
color: $white-color;
border-radius: 6px;
}
&:hover {
background: var(--blue);
color: var(--text-white);
background: $primary-color;
color: $white-color;
border-radius: 6px;
}
}
@@ -413,33 +398,28 @@ hr {
}
}
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
.fa-facebook {
color: $faceblue;
}
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
.fa-github {
color: $githubblack;
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
}
@media screen and (max-width: $small-devices) {
@@ -501,6 +481,16 @@ dt {
margin-top: 25px;
}
.edit-bar {
display: block;
margin: 4px;
a {
display: inline-block;
margin: 4px;
}
}
.important {
font-size: 1.2em;
font-weight: bold;
@@ -513,6 +503,7 @@ table {
border-collapse: collapse;
border-spacing: 0;
border-radius: 5px;
-moz-border-radius: 5px;
overflow: hidden;
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
@@ -524,21 +515,25 @@ table {
}
}
th {
padding: 4px;
}
td,
th {
vertical-align: middle;
text-align: center;
padding: 5px 10px;
> input[type="checkbox"] {
>input[type="checkbox"] {
padding: unset;
}
> ul {
>ul {
margin-top: 0;
}
> input[type="checkbox"] {
>input[type="checkbox"] {
padding: unset;
}
}
@@ -548,6 +543,10 @@ td {
border-collapse: collapse;
overflow: hidden;
text-overflow: ellipsis;
>ul {
margin-top: 0;
}
}
th,
@@ -557,23 +556,23 @@ thead td {
}
thead {
background-color: var(--deepblue);
background-color: $deepblue;
color: white;
}
tbody > tr {
tbody>tr {
&:nth-child(even):not(.highlight) {
background: var(--bg-off-white);
background: $primary-neutral-light-color;
}
&.clickable:hover {
cursor: pointer;
background: var(--bg-lightgrey);
background: $secondary-neutral-light-color;
width: 100%;
}
&.highlight {
color: var(--lightblue);
color: $primary-dark-color;
font-style: italic;
}
}
@@ -599,7 +598,7 @@ em {
}
.highlight {
background: var(--bg-yellow);
background: $second-color;
font-weight: bold;
}
@@ -701,15 +700,15 @@ u,
height: 18em;
overflow: hidden;
border: 1px solid black;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
box-shadow: $shadow-color 1px 1px 1px;
&:hover {
box-shadow: 1px 1px 5px var(--bg-yellow);
box-shadow: 1px 1px 5px $second-color;
}
}
.matmat_user a {
color: var(--deepblue);
color: $primary-neutral-dark-color;
height: 100%;
width: 100%;
margin: 0;
@@ -724,6 +723,7 @@ u,
display: block;
margin: 10px;
padding: 10px;
background: $white-color;
}
textarea {
@@ -731,7 +731,43 @@ textarea {
margin-top: 10px;
}
.ui-dialog .ui-dialog-buttonpane {
bottom: 0;
position: absolute;
width: 97%;
}
#cash_summary_form label,
.inline {
display: inline;
}
.form_button {
width: 150px;
height: 120px;
padding: 2px;
display: inline-block;
font-size: 0.8em;
span {
width: 70px;
float: right;
}
img {
max-width: 50px;
max-height: 50px;
float: left;
}
strong {
font-weight: bold;
font-size: 1.2em;
}
button {
vertical-align: middle;
}
}
+1
View File
@@ -21,6 +21,7 @@ body {
flex-direction: column;
padding: 10px;
box-shadow: none;
background-color: white;
margin: 0;
.alert {
+1 -1
View File
@@ -71,7 +71,7 @@ main {
border-radius: 50%;
justify-content: center;
align-items: center;
background-color: var(--bg-white);
background-color: $primary-neutral-light-color;
> span {
font-size: small;
+1 -1
View File
@@ -62,7 +62,7 @@
}
&:enabled>.fa {
background-color: var(--deepblue);
background-color: #354a5f;
color: white;
}
+1
View File
@@ -23,6 +23,7 @@
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
@@ -29,13 +29,5 @@
</span>
{{ form.show_my_stats.errors }}
</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 %}">
</form>
+4 -10
View File
@@ -102,7 +102,6 @@
@click.prevent="{{ page }}--"
:disabled="{{ page }} <= 1"
@keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-left"></i>
</button>
@@ -113,7 +112,6 @@
@click.prevent="{{ page }}++"
:disabled="{{ page }} >= {{ nb_pages }}"
@keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-right"></i>
</button>
@@ -165,14 +163,12 @@
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button class="pagination-arrow">
<button>
<i class="fa fa-caret-left"></i>
</button>
</a>
{% else %}
<button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-left"></i>
</button>
<button disabled="disabled"><i class="fa fa-caret-left"></i></button>
{% endif %}
{% for i in paginator.get_elided_page_range(current_page.number) %}
{% if i == current_page.number %}
@@ -205,14 +201,12 @@
{%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}"
{%- endif -%}
><button class="pagination-arrow">
><button>
<i class="fa fa-caret-right"></i>
</button>
</a>
{% else %}
<button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-right"></i>
</button>
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
{% endif %}
</nav>
{% endmacro %}
+10
View File
@@ -79,5 +79,15 @@
{% endfor %}
</tbody>
</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 %}
+2 -2
View File
@@ -31,10 +31,10 @@
{% for sum in purchase_sums %}
<div>
<span>{{ sum["counter__name"] }}</span>
<span>{{ "%.2f"|format(sum["total"]) }} €</span>
<span>{{ sum["total"] }} €</span>
</div>
{% endfor %}
<div><b>Total : </b><b>{{ "%.2f"|format(total_purchases) }} €</b></div>
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
</div>
</div>
</div>
+1
View File
@@ -133,6 +133,7 @@
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
<li><a href="{{ url('com: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('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:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
{% endif %}
-6
View File
@@ -84,7 +84,6 @@ from core.views import (
notification,
send_file,
)
from timetable.views import UserTimetableView
register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm")
@@ -185,11 +184,6 @@ urlpatterns = [
name="user_account_detail",
),
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(
"user/<int:user_id>/gift/create/",
GiftCreateView.as_view(),
+10 -12
View File
@@ -21,7 +21,7 @@ from wsgiref.util import FileWrapper
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef
from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse
@@ -233,12 +233,6 @@ class FileEditPropForm(forms.ModelForm):
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
def clean_parent(self):
parent = self.cleaned_data["parent"]
if not self.instance.owner.can_edit(parent):
raise ValidationError(_("You cannot move this file there"))
return parent
class FileEditPropView(CanEditPropMixin, UpdateView):
model = SithFile
@@ -259,7 +253,10 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
return ret
def get_success_url(self):
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id},
)
class FileView(CanViewMixin, DetailView, FormMixin):
@@ -272,7 +269,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
form_class = AddFilesForm
@staticmethod
def handle_clipboard(request, obj: SithFile):
def handle_clipboard(request, obj):
"""Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from
@@ -286,9 +283,10 @@ class FileView(CanViewMixin, DetailView, FormMixin):
where you want to paste the clipboard
"""
if "delete" in request.POST:
file_ids = request.POST.getlist("file_list")
for file in obj.children.filter(id__in=file_ids):
file.delete()
for f_id in request.POST.getlist("file_list"):
file = SithFile.objects.filter(id=f_id).first()
if file:
file.delete()
if "clear" in request.POST:
request.session["clipboard"] = []
if "cut" in request.POST:
+2 -10
View File
@@ -336,14 +336,10 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats", "show_my_timetable"],
widgets={
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
},
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
)
show_my_stats = __preferences_fields["show_my_stats"]
show_my_timetable = __preferences_fields["show_my_timetable"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
@@ -351,16 +347,12 @@ class UserVisibilityForm(forms.ModelForm):
if instance:
initial = initial or {}
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)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
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()
return instance
+2 -16
View File
@@ -1,14 +1,6 @@
import copy
import inspect
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
LiteralString,
Protocol,
TypedDict,
Unpack,
)
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string
@@ -20,17 +12,11 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeString
class TabItem(TypedDict):
url: str
slug: str
name: str
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list[TabItem] | None] = None
list_of_tabs: ClassVar[list | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self):
+3 -2
View File
@@ -413,7 +413,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
kwargs = super().get_context_data(**kwargs)
kwargs["perm_time"] = list(
self.object.permanencies.filter(end__isnull=False)
self.object.permanencies.filter(end__isnull=False, counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
.order_by("-total")
@@ -422,7 +422,8 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
)
kwargs["purchase_sums"] = list(
self.object.customer.buyings.values("counter", "counter__name")
self.object.customer.buyings.filter(counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("unit_price") * F("quantity")))
.order_by("-total")
)
+1 -7
View File
@@ -130,14 +130,8 @@ class GetUserForm(forms.Form):
elif cleaned_data["id"]:
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
if customer is None:
if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found"))
if not customer.can_buy:
raise forms.ValidationError(_("This user cannot buy items on counters."))
if customer.user.is_banned_counter:
raise forms.ValidationError(
_("This user is banned from AE places. Take him/her out if you can.")
)
cleaned_data["user_id"] = customer.user_id
cleaned_data["user"] = customer.user
return cleaned_data
+11
View File
@@ -647,6 +647,13 @@ class Counter(models.Model):
or user in self.sellers.all()
)
def gen_token(self) -> None:
"""Generate a new token for this counter."""
self.token = "".join(
random.choice(string.ascii_letters + string.digits) for _ in range(30)
)
self.save()
@cached_property
def barmen_list(self) -> list[User]:
"""Returns the barman list as list of User."""
@@ -654,6 +661,10 @@ class Counter(models.Model):
p.user for p in self.permanencies.filter(end=None).select_related("user")
]
def get_random_barman(self) -> User:
"""Return a random user being currently a barman."""
return random.choice(self.barmen_list)
def update_activity(self) -> None:
"""Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now())
@@ -71,7 +71,7 @@
padding: 0.5em;
margin: 0.2em;
height: 100%;
background: var(--bg-off-white);
background: $secondary-neutral-light-color;
img {
max-width: 70%;
+14 -26
View File
@@ -83,17 +83,8 @@
{% endmacro %}
{% block content %}
<div class="alert alert-yellow">
<div class="alert-main">
{%- trans trimmed -%}
You can see product details.
However, you cannot edit it, thus you won't be able
to submit this form.
{%- endtrans -%}
</div>
</div>
{% if object %}
<h2>{{ object }}</h2>
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %}
@@ -136,17 +127,19 @@
{{ form.price_formset.management_form }}
<div x-ref="formContainer">
{%- for form in form.price_formset.forms -%}
<div>{{ price_form(form) }}</div>
<div>
{{ price_form(form) }}
</div>
{%- endfor -%}
</div>
<template x-ref="formTemplate">
<div>{{ price_form(form.price_formset.empty_form) }}</div>
<div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template>
{% if user.has_perm("counter.change_product") %}
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
{% endif %}
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
</div>
<br />
@@ -172,16 +165,11 @@
<template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }}
</template>
{% if user.has_perm("counter.change_product") %}
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
{% endif %}
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
{% if user.has_perm("counter.change_product") %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% endif %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
+3 -5
View File
@@ -80,11 +80,9 @@
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom">
{% if user.has_perm("counter.add_product") %}
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
<button
class="btn btn-blue"
@click="downloadCsv()"
@@ -13,12 +13,12 @@
{% endblock %}
{% block content %}
{% if user.has_perm("counter.add_producttype") %}
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
<p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i>
</a>
{% endif %}
</p>
{% if product_types %}
<aside>
<p>
+3 -5
View File
@@ -21,8 +21,7 @@ import pytest
from bs4 import BeautifulSoup
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.contrib.auth.models import Permission, make_password
from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.shortcuts import resolve_url
from django.test import Client, TestCase
@@ -66,9 +65,8 @@ class TestFullClickBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = subscriber_user.make()
password = make_password("plop")
cls.barmen = subscriber_user.make(password=password)
cls.board_admin = board_user.make(password=password)
cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.board_admin = board_user.make(password=make_password("plop"))
cls.club_admin = subscriber_user.make()
cls.root = baker.make(User, is_superuser=True)
cls.subscriber = subscriber_user.make()
+2 -6
View File
@@ -6,10 +6,8 @@ from django.urls import resolve
if TYPE_CHECKING:
from django.http import HttpRequest
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
def is_logged_in_counter(request: HttpRequest) -> bool:
"""Check if the request is sent from a device logged to a counter.
The request must also be sent within the frame of a counter's activity.
@@ -32,6 +30,4 @@ def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -
if not referer_ok and request.resolver_match.app_name != "counter":
return False
if counter is None:
return bool(request.barmen)
return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))
return bool(request.barmen)
+49 -52
View File
@@ -48,7 +48,7 @@ from counter.models import (
Selling,
)
from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminTabsMixin
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
@@ -84,18 +84,17 @@ class CounterEditView(
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit a counter's main infos."""
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit a counter's main informations (for the counter's admin)."""
model = Counter
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja"
current_tab = "counters"
permission_required = "counter.change_counter"
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create a counter (for the admins)."""
model = Counter
@@ -106,10 +105,9 @@ class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateVi
)
template_name = "core/create.jinja"
current_tab = "counters"
permission_required = "counter.add_counter"
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
"""Delete a counter (for the admins)."""
model = Counter
@@ -117,33 +115,30 @@ class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteVi
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("counter:admin_list")
current_tab = "counters"
permission_required = "counter.delete_counter"
# Product management
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins."""
model = ProductType
template_name = "counter/product_type_list.jinja"
current_tab = "product_types"
context_object_name = "product_types"
permission_required = "counter.view_producttype"
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins."""
model = ProductType
fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja"
current_tab = "products"
permission_required = "counter.add_producttype"
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins."""
model = ProductType
@@ -151,26 +146,23 @@ class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, Update
fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id"
current_tab = "products"
permission_required = "counter.change_producttype"
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
current_tab = "products"
template_name = "counter/product_list.jinja"
permission_required = "counter.view_product"
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins."""
model = Product
form_class = ProductForm
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.add_product"
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins."""
model = Product
@@ -178,13 +170,6 @@ class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.change_product"
def has_permission(self):
return self.request.user.has_perm("counter.change_product") or (
self.request.method == "GET"
and self.request.user.has_perm("counter.view_product")
)
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
@@ -306,41 +291,54 @@ class ReturnableProductDeleteView(
}
class CounterTransactionDeletionView(DeleteView):
class RefillingDeleteView(DeleteView):
"""Delete a refilling (for the admins)."""
model = Refilling
pk_url_kwarg = "refilling_id"
template_name = "core/delete_confirm.jinja"
def get_queryset(self):
return super().get_queryset().select_related("counter", "customer")
def dispatch(self, request, *args, **kwargs):
self.object: Selling | Refilling = self.get_object()
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request, counter=self.object.counter):
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter_id}
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user_id}
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class RefillingDeleteView(CounterTransactionDeletionView):
"""Delete a refilling (for the admins)."""
model = Refilling
pk_url_kwarg = "refilling_id"
class SellingDeleteView(CounterTransactionDeletionView):
class SellingDeleteView(DeleteView):
"""Delete a selling (for the admins)."""
model = Selling
pk_url_kwarg = "selling_id"
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class CounterStatView(PermissionRequiredMixin, DetailView):
@@ -378,31 +376,30 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
return kwargs
class CounterRefillingListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""List of refillings on a counter."""
model = Refilling
template_name = "counter/refilling_list.jinja"
current_tab = "counters"
paginate_by = 30
permission_required = "counter.view_refilling"
def get_queryset(self):
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
return Refilling.objects.filter(counter=self.counter).order_by("-date")
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"counter": self.counter}
kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.counter
return kwargs
class RefoundAccountView(UserPassesTestMixin, SuccessMessageMixin, FormView):
class RefoundAccountView(UserPassesTestMixin, FormView):
"""Create a selling with the same amount as the current user money."""
template_name = "counter/refound_account.jinja"
form_class = CloseCustomerAccountForm
success_message = _("%(user)s's AE account has been emptied")
def test_func(self):
return self.request.user.is_root or self.request.user.is_in_group(
+7 -3
View File
@@ -242,11 +242,15 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not self.customer.can_buy:
raise Http404
if not is_logged_in_counter(request):
raise PermissionDenied
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
if (
not is_logged_in_counter(request, counter=self.counter)
or not self.counter.can_refill
if not (
request.barmen
and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill
):
raise PermissionDenied
+5 -8
View File
@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
@@ -21,30 +21,28 @@ from django.views.generic.edit import CreateView, UpdateView
from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm
from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminTabsMixin
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins."""
model = Eticket
template_name = "counter/eticket_list.jinja"
ordering = ["id"]
current_tab = "etickets"
permission_required = "counter.view_eticket"
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create an eticket."""
model = Eticket
template_name = "core/create.jinja"
form_class = EticketForm
current_tab = "etickets"
permission_required = "counter.add_eticket"
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit an eticket."""
model = Eticket
@@ -52,7 +50,6 @@ class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
form_class = EticketForm
pk_url_kwarg = "eticket_id"
current_tab = "etickets"
permission_required = "counter.change_eticket"
class EticketPDFView(CanViewMixin, DetailView):
+9 -8
View File
@@ -16,9 +16,9 @@ from datetime import timedelta
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
@@ -36,7 +36,6 @@ from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.safestring import SafeString
@@ -156,7 +155,7 @@ class CounterMain(
return super().form_valid(form)
class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailView):
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
"""Provide the last operations to allow barmen to delete them."""
model = Counter
@@ -164,12 +163,14 @@ class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailVie
template_name = "counter/last_ops.jinja"
current_tab = "last_ops"
def test_func(self):
def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling."""
self.object = self.get_object()
return is_logged_in_counter(
self.request, counter=self.object
) or self.request.user.has_perms(
["counter.view_selling", "counter.view_refilling"]
if is_logged_in_counter(request) and self.object.barmen_list:
return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id})
+ "?bad_location"
)
def get_context_data(self, **kwargs):
+69 -73
View File
@@ -13,16 +13,41 @@
#
#
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views.mixins import TabedViewMixin
from counter.utils import is_logged_in_counter
class CounterAdminMixin(View):
"""Protect counter admin section."""
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
edit_club = []
def _test_group(self, user):
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
def _test_club(self, user):
return any(c.can_be_edited_by(user) for c in self.edit_club)
def dispatch(self, request, *args, **kwargs):
if not (
request.user.is_root
or self._test_group(request.user)
or self._test_club(request.user)
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return self.object.name
return self.object
def get_list_of_tabs(self):
if self.object.type != "BAR":
@@ -46,11 +71,7 @@ class CounterTabsMixin(TabedViewMixin):
"name": _("Cash summary"),
}
)
if is_logged_in_counter(
self.request, counter=self.object
) or self.request.user.has_perms(
["counter.view_selling", "counter.view_refilling"]
):
if is_logged_in_counter(self.request):
tab_list.append(
{
"url": reverse(
@@ -68,70 +89,45 @@ class CounterTabsMixin(TabedViewMixin):
class CounterAdminTabsMixin(TabedViewMixin):
tabs_title = _("Counter administration")
def get_list_of_tabs(self):
user = self.request.user
res = [
{
"url": reverse_lazy("counter:admin_list"),
"slug": "counters",
"name": _("Counters"),
}
]
if user.has_perm("counter.view_product"):
res.append(
{
"url": reverse_lazy("counter:product_list"),
"slug": "products",
"name": _("Products"),
}
)
if user.has_perm("counter.view_productformula"):
res.append(
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
}
)
if user.has_perm("counter.view_producttype"):
res.append(
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
}
)
if user.has_perm("counter.view_returnableproduct"):
res.append(
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
}
)
if user.has_perm("counter.view_cashregistersummary"):
res.append(
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
}
)
if user.has_perm("counter.view_invoicecall"):
res.append(
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
}
)
if user.has_perm("counter.view_eticket"):
res.append(
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
}
)
return res
list_of_tabs = [
{
"url": reverse_lazy("counter:admin_list"),
"slug": "counters",
"name": _("Counters"),
},
{
"url": reverse_lazy("counter:product_list"),
"slug": "products",
"name": _("Products"),
},
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
},
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
},
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
},
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
},
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
},
]
+7 -5
View File
@@ -48,7 +48,7 @@ des scripts complets mais qui restent lisibles,
```
Ok, c'est de la triche, on installe un package externe.
Mais bon, ça marche, et l'équivalent pur bash
serait un pur enfer.
serait carrément plus moche.
=== "nu"
@@ -59,18 +59,20 @@ des scripts complets mais qui restent lisibles,
Nombre de lignes total :
```nu
ls **/*.py | par-each { open $in.name | lines | length } | math sum
ls **/*.py | insert linecount { get name | open | lines | length } | math sum
```
Vous pouvez aussi exlure les lignes vides
et les les lignes de commentaire :
```nu
ls **/*.py |
par-each {
open $in.name |
insert linecount {
get name |
open |
lines |
each { str trim } |
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
filter { |l| not ($l | str starts-with "#") } | # commentaires
filter { |l| ($l | str length) > 0 } | # lignes vides
length
} |
math sum
+16 -40
View File
@@ -18,8 +18,7 @@ il n'est pas possible de développer le site sur ce système d'exploitation.
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
OS ni de mettre un dual boot sur votre ordinateur : `WSL`.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou
Windows 11.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11.
- **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install)
```shell
@@ -44,7 +43,7 @@ il suffit d'utiliser la commande suivante :
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```
!!!success
!!!note
À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
@@ -102,10 +101,10 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
ln -s /usr/bin/valkey-server /usr/bin/redis-server
```
=== "macOS"
Pour installer les dépendances, il est fortement recommandé
d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Il est également nécessaire d'avoir installé xcode
```bash
@@ -117,15 +116,12 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!note
Si vous rencontrez des erreurs lors de votre configuration,
n'hésitez pas à vérifier l'état de votre installation homebrew
avec :code:`brew doctor`
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
!!!note
Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. Il est cependant parfois nécessaire d'installer
les headers Python nécessaire à la compilation de certains paquets.
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
## Finaliser l'installation
@@ -149,6 +145,7 @@ uv run ./manage.py install_xapian
de texte à l'écran.
C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables
d'environnement.
Ces variables sont stockées dans un fichier `.env`.
@@ -177,8 +174,8 @@ uv run ./manage.py compilemessages
## Démarrer le serveur de développement
Il faut toujours avoir préalablement activé
l'environnement virtuel comme fait plus haut
Il faut toujours avoir préalablement activé
l'environnement virtuel comme fait plus haut
et se placer à la racine du projet.
Il suffit ensuite d'utiliser cette commande :
@@ -186,32 +183,11 @@ Il suffit ensuite d'utiliser cette commande :
uv run ./manage.py runserver
```
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
!!!note
Pour vous connecter, vous pouvez utiliser un des comptes utilisateur
créés durant l'installation :
| username | description |
|------------------|----------------------------------------------------------------------------|
| `root` | super-utilisateur<br/>il peut tout faire et tout voir |
| `subscriber` | simple cotisant |
| `old_subscriber` | ancien cotisant |
| `public` | non-cotisant |
| `krophil` | cotisant<br/>barman Foyer<br/>président du Troll Penché |
| `sli` | cotisant<br/>VP Troll |
| `skia` | cotisant<br/>barman MDE<br/>membre du bureau AE<br/>ancien membre du Troll |
| `comunity` | cotisant<br/>membre du bureau PdF<br/>admin communication |
| `counter` | cotisant<br/>admin comptoirs |
| `comptable` | cotisant<br/>admin comptabilité |
| `rbatsbak` | cotisant<br/>membre du Troll |
En plus de ces dernier, quelques centaines d'utilisateurs
supplémentaires sont générés procéduralement,
afin de rajouter un peu de volume de données.
Tous les utilisateurs ont le mot de passe `plop`.
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
!!!note
@@ -252,10 +228,10 @@ Tous les utilisateurs ont le mot de passe `plop`.
La documentation est automatiquement mise en ligne à chaque envoi de code sur GitHub.
Pour l'utiliser en local ou globalement pour la modifier,
il existe une commande du site qui génère
il existe une commande du site qui génère
la documentation et lance un serveur la rendant
accessible à l'adresse [http://localhost:8080](http://localhost:8000).
Cette commande génère la documentation à
Cette commande génère la documentation à
chacune de ses modifications,
inutile de relancer le serveur à chaque fois.
@@ -265,7 +241,7 @@ uv run mkdocs serve
## Lancer les tests
Pour lancer les tests, il suffit d'utiliser
Pour lancer les tests, il suffit d'utiliser
la commande suivante :
```bash
+1 -1
View File
@@ -67,7 +67,7 @@
.fa-plus,
.fa-minus {
cursor: pointer;
background-color: var(--deepblue);
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
+1 -1
View File
@@ -288,7 +288,7 @@ $min_col_width: 100px;
border: none;
color: black;
text-decoration: none;
background-color: var(--bg-white);
background-color: $primary-neutral-light-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.18em;
+8 -9
View File
@@ -13,8 +13,7 @@
}
.topic {
border: solid var(--darkened-deepblue) 1px;
border-radius: 5px;
border: solid $primary-neutral-color 1px;
padding: 1px;
margin: 1px;
@@ -24,7 +23,7 @@
}
a {
color: var(--text-black);
color: $black-color;
}
a:hover {
@@ -57,7 +56,7 @@
}
.forum {
background: var(--bg-white);
background: $primary-neutral-light-color;
padding: 1px;
margin: 1px;
@@ -67,7 +66,7 @@
}
a {
color: var(--text-black);
color: $black-color;
}
a:hover {
text-decoration: underline;
@@ -92,7 +91,7 @@
.category {
margin-top: 5px;
background: var(--deepblue);
background: $secondary-color;
color: white;
border-radius: 10px 10px 0 0;
.title {
@@ -106,12 +105,12 @@
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-off-white);
background: $secondary-neutral-light-color;
border-radius: 5px;
border: 1px var(--bg-lightgrey) solid;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
&:nth-child(odd) {
background: var(--bg-white);
background: $primary-neutral-light-color;
}
.message-header {
+2 -5
View File
@@ -346,16 +346,13 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
return kwargs
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
class ForumMessageView(SingleObjectMixin, RedirectView):
model = ForumMessage
pk_url_kwarg = "message_id"
permanent = False
def test_func(self) -> bool:
self.object = self.get_object()
return self.request.user.can_view(self.object)
def get_redirect_url(self, *args, **kwargs):
self.object = self.get_object()
return self.object.get_url()
+261 -221
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-27 23:26+0100\n"
"POT-Creation-Date: 2026-10-02 12:56+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -192,11 +192,51 @@ msgstr ""
"arrivée durant l'interaction avec l'application tierce. Veuillez contacter "
"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
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
msgid "End date"
msgstr "Date de fin"
@@ -354,8 +394,7 @@ msgstr ""
"de la présidence."
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py timetable/models.py
#: trombi/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
msgid "user"
msgstr "utilisateur"
@@ -375,6 +414,60 @@ msgstr "rôle"
msgid "past member"
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
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"
@@ -424,22 +517,6 @@ msgstr "Url dupliquée"
msgid "This link doesn't match with the url base of its type."
msgstr "Ce lien ne correspond pas à l'url de base de son type."
#: club/templates/club/club_detail.jinja club/views.py
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja
#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file.jinja core/templates/core/group_list.jinja
#: core/templates/core/page/base.jinja core/templates/core/user_tools.jinja
#: core/views/user.py counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/templates/club/club_detail.jinja com/templates/com/news_list.jinja
msgid "Links"
msgstr "Liens"
@@ -477,13 +554,6 @@ msgstr "Membres du club"
msgid "Manage roles"
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_old_members.jinja
#: core/templates/core/user_clubs.jinja
@@ -494,7 +564,6 @@ msgid "Role"
msgstr "Rôle"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_news_list.jinja
#: club/templates/club/club_old_members.jinja
#: core/templates/core/group_list.jinja core/templates/core/user_clubs.jinja
msgid "Description"
@@ -509,34 +578,6 @@ msgstr "Depuis"
msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_news_list.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news"
msgstr "Créer une nouvelle"
#: club/templates/club/club_news_list.jinja
#, python-format
msgid "Dates (%(nb_occurrences)s occurrences)"
msgstr "Dates (%(nb_occurrences)s occurrences)"
#: club/templates/club/club_news_list.jinja
msgid "Permalink"
msgstr "Permalien"
#: club/templates/club/club_news_list.jinja
#: com/templates/com/news_admin_list.jinja
msgid "Summary"
msgstr "Résumé"
#: club/templates/club/club_news_list.jinja
msgid "No summary"
msgstr "Pas de Résumé"
#: club/templates/club/club_news_list.jinja
msgid "No detailed description"
msgstr "Pas de description détaillée"
#: club/templates/club/club_old_members.jinja
msgid "Club old members"
msgstr "Anciens membres du club"
@@ -776,7 +817,8 @@ msgstr "Total"
msgid "Payment method"
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/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file_detail.jinja
@@ -902,6 +944,60 @@ msgstr "Ajouter"
msgid "Join"
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
msgid "Edit page"
msgstr "Éditer la page"
@@ -918,20 +1014,39 @@ msgstr "Anciens membres"
msgid "History"
msgstr "Historique"
#: club/views.py com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja
msgid "Events"
msgstr "Événements"
#: club/views.py core/templates/core/base/header.jinja core/views/user.py
#: sas/templates/sas/picture.jinja trombi/views.py
msgid "Tools"
msgstr "Outils"
#: club/views.py com/templates/com/news_admin_list.jinja
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
#: com/templates/com/weekmail.jinja core/templates/core/file.jinja
#: core/templates/core/group_list.jinja core/templates/core/page/base.jinja
#: core/templates/core/user_tools.jinja core/views/user.py
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/views.py
msgid "Edit club page"
msgstr "Éditer la page de club"
#: club/views.py
msgid "Sellings"
msgstr "Vente"
#: club/views.py
msgid "Mailing list"
msgstr "Listes de diffusion"
#: club/views.py
msgid "You are now a member of this club."
msgstr "Vous êtes maintenant membre de ce club."
@@ -978,7 +1093,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py
#: com/forms.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
@@ -1062,10 +1177,6 @@ msgstr "auteur"
msgid "is published"
msgstr "est publié"
#: com/models.py
msgid "moderator"
msgstr "modérateur"
#: com/models.py
msgid "news"
msgstr "nouvelle"
@@ -1126,10 +1237,6 @@ msgstr "fichier"
msgid "display time"
msgstr "temps d'affichage"
#: com/models.py core/models.py
msgid "is moderated"
msgstr "est modéré"
#: com/models.py
msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin"
@@ -1165,6 +1272,46 @@ msgstr "Nouvelle publiée"
msgid "News deleted"
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
msgid "News admin"
msgstr "Administration des nouvelles"
@@ -1174,6 +1321,11 @@ msgstr "Administration des nouvelles"
msgid "News"
msgstr "Nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news"
msgstr "Créer une nouvelle"
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies"
msgstr "Événements hebdomadaires"
@@ -1188,14 +1340,9 @@ msgstr "Événements hebdomadaires affichées"
msgid "Title"
msgstr "Titre"
#: 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
msgid "Summary"
msgstr "Résumé"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja sas/models.py
@@ -1211,11 +1358,6 @@ msgstr "Modérateur"
msgid "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
#: core/templates/core/page/base.jinja
msgid "View"
@@ -1229,6 +1371,11 @@ msgstr "Dépublier"
msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer"
#: com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja
msgid "Events"
msgstr "Événements"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed events"
msgstr "Événements affichés"
@@ -1371,13 +1518,6 @@ msgstr "Créer"
msgid "Click to expand"
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
msgid "No posters"
msgstr "Aucune affiche"
@@ -1733,7 +1873,7 @@ msgstr "département"
msgid "dpt option"
msgstr "Filière"
#: core/models.py pedagogy/models.py timetable/models.py
#: core/models.py pedagogy/models.py
msgid "semester"
msgstr "semestre"
@@ -1858,18 +1998,6 @@ msgstr ""
"Autorise les cotisants (ou les personnes whitelistées, si votre profil est "
"caché) à accéder aux statistiques de votre compte AE"
#: core/models.py
msgid "show your timetable to others"
msgstr "montrez votre emploi du temps aux autres"
#: core/models.py
msgid ""
"Allow subscribers to see your timetable and to compare it to theirs (if you "
"have filled it)"
msgstr ""
"Permet aux cotisants de voir votre emploi du temps et de le comparer au leur "
"(si vous l'avez enregistré)"
#: core/models.py
msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click"
@@ -2700,6 +2828,14 @@ msgstr "Clubs actuels : "
msgid "Old club(s) :"
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
#, python-format
msgid "%(user_name)s's profile"
@@ -3110,10 +3246,6 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement"
#: core/views/files.py
msgid "You cannot move this file there"
msgstr "Vous ne pouvez pas déplacer ce fichier là"
#: core/views/forms.py
msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur"
@@ -3185,10 +3317,6 @@ msgstr "%s est déjà votre fillot/fillote"
msgid "Users to add to group"
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
msgid "Users to remove from group"
msgstr "Utilisateurs à retirer du groupe"
@@ -3231,14 +3359,6 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "This user cannot buy items on counters."
msgstr "Cet utilisateur ne peut pas faire d'achats sur les comptoirs."
#: counter/forms.py
msgid "This user is banned from AE places. Take him/her out if you can."
msgstr "Cette personne est ban comptoir. Faites-la sortir si vous le pouvez."
#: counter/forms.py
msgid "You are not a barman of this counter."
msgstr "Vous n'êtes pas barman sur ce comptoir."
@@ -3296,10 +3416,6 @@ msgstr ""
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
@@ -3986,6 +4102,7 @@ msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go"
msgstr "Valider"
@@ -4120,12 +4237,9 @@ msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
msgid ""
"You can see product details. However, you cannot edit it, thus you won't be "
"able to submit this form."
msgstr ""
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
"ne serez donc pas en mesure de soumettre le formulaire."
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
msgid "Creation date"
@@ -4297,11 +4411,6 @@ msgstr ""
msgid "returnable product : %(returnable)s -> %(returned)s"
msgstr "produit consigné : %(returnable)s -> %(returned)s"
#: counter/views/admin.py
#, python-format
msgid "%(user)s's AE account has been emptied"
msgstr "Le compte de %(user)s a été vidé"
#: counter/views/cash.py
msgid "10 cents"
msgstr "10 centimes"
@@ -5089,7 +5198,7 @@ msgstr "heures THE"
msgid "hours TE"
msgstr "heures TE"
#: pedagogy/models.py timetable/models.py
#: pedagogy/models.py
msgid "ue"
msgstr "UE"
@@ -5751,6 +5860,10 @@ msgstr "Cursus alternant (-20%)"
msgid "A new poster needs to be moderated"
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
msgid "A new pedagogy comment has been signaled for moderation"
msgstr ""
@@ -5872,61 +5985,21 @@ msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/stats.jinja
msgid "All subscriptions"
msgstr "Toutes les cotisations"
msgid "Total subscriptions"
msgstr "Cotisations totales"
#: subscription/templates/subscription/stats.jinja
msgid "Subscriptions by type"
msgstr "Cotisations par type"
#: subscription/templates/subscription/subscription.jinja
msgid "Existing member"
msgstr "Membre existant"
#: subscription/templates/subscription/subscription.jinja
msgid "New member"
msgstr "Nouveau membre"
#: subscription/views.py
msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations"
#: timetable/models.py
msgid "timetable"
msgstr "emploi du temps"
#: timetable/models.py
msgid "timetables"
msgstr "emplois du temps"
#: timetable/models.py
msgid "weekday"
msgstr "jour de la semaine"
#: timetable/models.py
msgid "start at"
msgstr "début"
#: timetable/models.py
msgid "end at"
msgstr "fin"
#: timetable/models.py
msgid "course type"
msgstr "type de cours"
#: timetable/models.py
msgid "room"
msgstr "salle"
#: timetable/models.py
msgid "week group"
msgstr "groupe de semaine"
#: timetable/models.py
msgid "timetable slot"
msgstr "créneau d'emploi du temps"
#: timetable/models.py
msgid "timetable slots"
msgstr "créneaux d'emploi du temps"
#: timetable/templates/timetable/generator.jinja
msgid "Timetable generator"
msgstr "Générateur d'emploi du temps"
@@ -5957,45 +6030,8 @@ msgid "Generate"
msgstr "Générer"
#: timetable/templates/timetable/generator.jinja
msgid "Save on your profile"
msgstr "Enregistrer sur votre profil"
#: timetable/templates/timetable/generator.jinja
msgid "Make this timetable public"
msgstr "Rendre cet emploi du temps publique"
#: timetable/templates/timetable/generator.jinja
#, python-format
msgid ""
"You can change this at any time in <a href=\"%(url)s\">your preferences</a>"
msgstr ""
"Vous pouvez changer ceci n'importe quand dans <a href=\"%(url)s\">vos "
"préférences</a>"
#: timetable/templates/timetable/generator.jinja
msgid "Save timetable"
msgstr "Enregistrer l'emploi du temps"
#: timetable/templates/timetable/generator.jinja
#: trombi/templates/trombi/detail.jinja
msgid "Export"
msgstr "Export"
#: timetable/templates/timetable/generator.jinja
msgid "Export to PNG"
msgstr "Exporter en PNG"
#: timetable/views.py
msgid "Timetables"
msgstr "Emplois du temps"
#: timetable/views.py
msgid "Generator"
msgstr "Générateur"
#: timetable/views.py
msgid "My timetables"
msgstr "Mes emplois du temps"
msgid "Save to PNG"
msgstr "Sauver en PNG"
#: trombi/models.py
msgid "subscription deadline"
@@ -6110,6 +6146,10 @@ msgstr "Fin des inscriptions : "
msgid "Comment deadline: "
msgstr "Fin des commentaires : "
#: trombi/templates/trombi/detail.jinja
msgid "Export"
msgstr "Exporter"
#: trombi/templates/trombi/detail.jinja
msgid "Add user"
msgstr "Ajouter une personne"
+396 -426
View File
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -24,24 +24,24 @@
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^8.0.9",
"@babel/preset-env": "^8.0.7",
"@biomejs/biome": "^2.6.3",
"@babel/core": "^8.0.6",
"@babel/preset-env": "^8.0.6",
"@biomejs/biome": "^2.5.15",
"@hey-api/openapi-ts": "^0.99.0",
"@types/alpinejs": "^3.17.0",
"@types/alpinejs__sort": "^3.13.0",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.6.5",
"@types/node": "^26.6.4",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.3.5"
"vite": "^8.3.2"
},
"dependencies": {
"@ae_utbm/aemark": "^0.1.4",
"@alpinejs/sort": "^3.17.5",
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.17.4",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
"@fortawesome/fontawesome-free": "^7.3.1",
@@ -49,13 +49,13 @@
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/icalendar": "^6.1.21",
"@fullcalendar/list": "^6.1.21",
"@sentry/browser": "^11.9.0",
"@zip.js/zip.js": "^2.24.0",
"@sentry/browser": "^11.3.0",
"@zip.js/zip.js": "^2.22.0",
"3d-force-graph": "^1.80.1",
"alpinejs": "^3.17.5",
"chart.js": "^4.6.1",
"alpinejs": "^3.17.4",
"chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.10",
"cytoscape": "^3.34.4",
"cytoscape": "^3.34.3",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
@@ -66,7 +66,7 @@
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
"temporal-polyfill": "^1.0.6",
"temporal-polyfill": "^1.0.5",
"three": "^0.186.1",
"three-spritetext": "^1.10.0",
"tom-select": "^2.6.2"
+6 -2
View File
@@ -23,6 +23,7 @@ $pedagogy-white-text: #f0f0f0;
overflow-wrap: break-word;
.closed td.title {
color: lighten($black-color, 10%);
font-style: italic;
}
@@ -68,6 +69,9 @@ $pedagogy-white-text: #f0f0f0;
min-width: 300px;
}
.info-container {
// Darken the background color just enough to make this part
// stand out a little, while not being too perceptible
background: darken($white-color, 1%);
display: grid;
grid-template-columns: 1fr 1fr;
border-radius: 5px;
@@ -154,9 +158,9 @@ $pedagogy-white-text: #f0f0f0;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-off-white);
background: $primary-neutral-light-color;
border-radius: 5px;
border: 1px var(--bg-lightgrey) solid;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
&.reported {
border: #fc8181 1px solid;
+19 -19
View File
@@ -19,39 +19,39 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.14"
dependencies = [
"django>=5.2.18,<6.0.0",
"django>=5.2.17,<6.0.0",
"django-ninja>=1.7.1,<2.0.0",
"django-ninja-extra>=0.31.7",
"Pillow>=13.0.0,<14",
"aemark>=0.1.4",
"Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1",
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=50.0.2,<51.0.0",
"django-phonenumber-field>=9.0.0,<10",
"phonenumbers>=9.0.41,<10.0.0",
"reportlab>=5.1.0,<6.0.0",
"django-phonenumber-field>=8.5.0,<9.0.0",
"phonenumbers>=9.0.40,<10.0.0",
"reportlab>=5.0.1,<6.0.0",
"django-haystack>=3.4.0,<4.0.0",
"xapian-haystack>=4.0.0,<5.0.0",
"libsass>=0.23.0,<1.0.0",
"django-ordered-model>=3.7.4,<4.0.0",
"django-simple-captcha>=0.7.0,<1.0.0",
"python-dateutil>=3.0.0,<4",
"sentry-sdk>=2.73.0,<3.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0",
"sentry-sdk>=2.71.0,<3.0.0",
"jinja2>=3.1.6,<4.0.0",
"django-countries>=9.1.0,<10.0.0",
"dict2xml>=1.7.8,<2.0.0",
"Sphinx>=9.2.0,<10", # Used by xapian during installation
"tomli>=2.5.0,<3.0.0",
"Sphinx>=9.1.0,<10", # Used by xapian during installation
"tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=14.3.1,<15",
"ical>=14.2.0",
"redis[hiredis]>=8.1.0,<9",
"environs[django]>=15.2.0,<16",
"requests>=2.34.2,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.3,<7",
"django-celery-results>=2.6.0,<3",
"django-celery-beat>=2.9.0,<3",
"django-celery-results>=2.6.0",
"django-celery-beat>=2.9.0",
]
[project.urls]
@@ -60,23 +60,23 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]>=3.3.6,<4.0.0",
"psycopg[c]>=3.3.4,<4.0.0",
]
dev = [
"django-debug-toolbar>=8.0.0,<9",
"ipython>=9.17.1,<10.0.0",
"pre-commit>=4.7.0,<5.0.0",
"ruff>=0.17.2,<1.0.0",
"pre-commit>=4.6.2,<5.0.0",
"ruff>=0.16.10,<1.0.0",
"djhtml>=3.0.11,<4.0.0",
"faker>=40.43.0,<41.0.0",
"rjsmin>=1.3.0,<2.0.0",
"faker>=40.40.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=9.1.1,<10.0.0",
"pytest-cov>=7.1.0,<8.0.0",
"pytest-django>=4.14.0,<5.0.0",
"model-bakery>=1.24.2,<2.0.0",
"model-bakery>=1.24.1,<2.0.0",
"beautifulsoup4>=4.15.0,<5",
"lxml>=6.1.3,<7",
]
+4 -3
View File
@@ -184,9 +184,10 @@ class DeleteAllForumUserMessagesView(FormView):
form_class = SelectUserForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_root:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
res = super().dispatch(request, *args, **kwargs)
if request.user.is_root:
return res
raise PermissionDenied
def form_valid(self, form):
self.user = form.cleaned_data["user"]
+2 -2
View File
@@ -27,7 +27,7 @@ main {
font-size: 1.2em;
line-height: 1.2em;
color: black;
background-color: var(--bg-white);
background-color: $primary-neutral-light-color;
border-radius: 5px;
font-weight: bold;
@@ -36,7 +36,7 @@ main {
}
&:disabled {
background-color: var(--bg-white);
background-color: $primary-neutral-light-color;
color: #d4d4d4;
}
}
+1 -1
View File
@@ -239,7 +239,7 @@
>.tools {
flex: 1;
.btn {
background-color: var(--bg-white);
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
align-items: center;
+1
View File
@@ -621,6 +621,7 @@ with open(
SITH_NOTIFICATIONS = [
("POSTER_MODERATION", _("A new poster needs to be moderated")),
("MAILING_MODERATION", _("A new mailing list needs to be moderated")),
(
"PEDAGOGY_MODERATION",
_("A new pedagogy comment has been signaled for moderation"),
-16
View File
@@ -1,17 +1 @@
# 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",)
-37
View File
@@ -1,37 +0,0 @@
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
)
-141
View File
@@ -1,141 +0,0 @@
# 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",
),
),
]
-101
View File
@@ -1,102 +1 @@
# 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)
-35
View File
@@ -1,35 +0,0 @@
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"]
@@ -1,6 +1,4 @@
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
const TIMETABLE_ROW_RE: RegExp =
@@ -78,88 +76,17 @@ function parseSlots(s: string): TimetableSlot[] {
}
document.addEventListener("alpine:init", () => {
/**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", (userId: number) => ({
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string } | null,
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({
// biome-ignore lint/style/useNamingConvention: api is snake case
path: { user_id: userId },
body: {
// biome-ignore lint/style/useNamingConvention: api is snake case
is_viewable: this.edtPublic,
slots: this.courses.map((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) as ApiWeekDay,
// biome-ignore lint/style/useNamingConvention: api is snake case
week_group: c.weekGroup,
// biome-ignore lint/style/useNamingConvention: api is snake case
course_type: c.courseType,
room: c.room,
ue: c.ueCode,
})),
},
});
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[]) => ({
error: null as { incorrectRow?: string },
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0,
endSlot: 0,
table: { height: 0, width: 0 },
slots: slots,
table: {
height: 0,
width: 0,
},
colors: {} as Record<string, string>,
colorPalette: [
@@ -176,14 +103,18 @@ document.addEventListener("alpine:init", () => {
"#1b5e20",
],
init() {
this.$watch("slots", () => this.display());
},
generate() {
try {
this.courses = parseSlots(this.content);
this.error = null;
} catch (err) {
this.error = { incorrectRow: err?.cause?.row };
return;
}
display() {
// color each UE
let colorIndex = 0;
for (const slot of this.slots) {
for (const slot of this.courses) {
if (!this.colors[slot.ueCode]) {
this.colors[slot.ueCode] =
this.colorPalette[colorIndex % this.colorPalette.length];
@@ -192,13 +123,13 @@ document.addEventListener("alpine:init", () => {
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.slots.reduce(
this.startSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4,
);
this.endSlot = this.slots.reduce(
this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1,
);
@@ -207,7 +138,7 @@ document.addEventListener("alpine:init", () => {
},
getStyle(slot: TimetableSlot) {
const hasWeekGroup = !!slot.weekGroup;
const hasWeekGroup = slot.weekGroup !== undefined;
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
return {
@@ -219,19 +150,15 @@ document.addEventListener("alpine:init", () => {
};
},
/**
* Return the hours that should be displayed on this timetable,
* with their `top` placing
*/
getHours(): [string, { top: `${number}px` }][] {
getHours(): [string, object][] {
let hour: number = Number.parseInt(
this.slots
this.courses
.map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0],
10,
);
const res: [string, { top: `${number}px` }][] = [];
const res: [string, object][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
hour += 1;
@@ -242,5 +169,16 @@ document.addEventListener("alpine:init", () => {
getWidth() {
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,12 +1,12 @@
@import "core/static/core/colors";
.timetable {
#timetable {
--hour-side-width: 60px;
display: block;
margin: 2em auto;
.header {
background-color: var(--bg-white);
background-color: $white-color;
font-weight: bold;
box-shadow: none;
width: calc(100% - var(--hour-side-width) - 10px);
+42 -40
View File
@@ -1,13 +1,11 @@
{% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{% extends 'core/base.jinja' %}
{%- 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>
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
@@ -34,7 +32,7 @@
{% endblock %}
{% block content %}
<div x-data="timetableGenerator({{ user.id }})">
<div x-data="timetableGenerator">
<form @submit.prevent="generate()">
<h1>{% trans %}Timetable generator{% endtrans %}</h1>
<template x-if="error !== null" x-cloak>
@@ -62,40 +60,44 @@
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form>
{{ display_timetable("courses") }}
<template x-if="courses.length > 0">
<section>
{% if user.has_perm("timetable.add_self_timetable") %}
<div id="timetable-save" class="margin-bottom">
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
<form>
<fieldset>
<input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
<label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
<span class="helptext">
{% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
You can change this at any time in <a href="{{ url }}">your preferences</a>
{% endtrans %}
</span>
</fieldset>
<button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
{% trans %}Save timetable{% endtrans %}
</button>
<p
class="alert"
:class="timetableSavedMessage.success ? 'alert-green' : 'alert-red'"
x-show="timetableSavedMessage.open"
x-transition.duration.500ms
x-text="timetableSavedMessage.content"
></p>
</form>
</div>
{% endif %}
<h3 class="margin-bottom">{% trans %}Export{% endtrans %}</h3>
<button class="margin-bottom btn btn-blue" @click="savePng">
{% trans %}Export to PNG{% endtrans %}
</button>
</section>
</template>
<div
id="timetable"
x-show="table.height > 0 && table.width > 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 courses">
<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>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button>
</div>
{% endblock content %}
@@ -1,46 +0,0 @@
{# 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 %}
@@ -1,52 +0,0 @@
{% 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
View File
@@ -0,0 +1 @@
# Create your tests here.
View File
Whitespace-only changes.
-20
View File
@@ -1,20 +0,0 @@
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})
-31
View File
@@ -1,31 +0,0 @@
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
+2 -5
View File
@@ -1,8 +1,5 @@
from django.urls import path
from timetable.views import GeneratorView, UserTimetableView
from timetable.views import GeneratorView
urlpatterns = [
path("", GeneratorView.as_view(), name="generator"),
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
]
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
+2 -50
View File
@@ -1,54 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
# Create your views here.
from django.views.generic import TemplateView
from django.views.generic.detail import DetailView
from core.models import User
from core.views import TabedViewMixin
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):
class GeneratorView(TemplateView):
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"))
}
Generated
+447 -601
View File
File diff suppressed because it is too large. Load diff