mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-30 14:19:35 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc980113a | ||
|
|
83e8001eac | ||
|
|
52a70054f7 | ||
|
|
143a9849cd | ||
|
|
01edf1f688 | ||
|
|
366551559d | ||
|
|
59cdebbf4f | ||
|
|
f5a5782bac | ||
|
|
f11ac5d3be | ||
|
|
c6f518fffb | ||
|
|
c5015f4448 | ||
|
|
9bc6518d95 | ||
|
|
c66af5eaff | ||
|
|
c188dd0c18 | ||
|
|
4f5af29d43 | ||
|
|
80f1a7e574 | ||
|
|
7548c76d5e | ||
|
|
d7e1cdafde | ||
|
|
93b6238c75 | ||
|
|
91865fe7a1 | ||
|
|
1e189f7b2b | ||
|
|
1798406dde | ||
|
|
20fc7d587a | ||
|
|
94b108d288 | ||
|
|
059d5210ac | ||
|
|
562f75f410 | ||
|
|
32c30da0fc | ||
|
|
d3f3b18660 | ||
|
|
e50b1fd71e | ||
|
|
78376eaca4 | ||
|
|
8d56c72054 | ||
|
|
a794f02a8d | ||
|
|
cb0a2f866d | ||
|
|
4b4c5a73b6 | ||
|
|
b37b98b932 | ||
|
|
ed888834d6 | ||
|
|
0657888076 | ||
|
|
3f135777cd | ||
|
|
73ebe9c674 | ||
|
|
816cbe3a2c | ||
|
|
a33a4da143 | ||
|
|
2c77ed3c54 | ||
|
|
3312a6214e | ||
|
|
87533a7327 | ||
|
|
2db46e3958 | ||
|
|
f2469873fd | ||
|
|
68c05b5b1d | ||
|
|
2d492e0157 | ||
|
|
67cfcb66b6 | ||
|
|
b560c6860b | ||
|
|
15c5a37c99 | ||
|
|
07648f596e | ||
|
|
0c6594b3cd | ||
|
|
22b510e25b |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.16.10
|
||||
rev: v0.17.2
|
||||
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.5.15"]
|
||||
additional_dependencies: ["@biomejs/biome@2.6.3"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.11
|
||||
hooks:
|
||||
|
||||
+2
-113
@@ -31,20 +31,10 @@ 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,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from club.models import Club, ClubLink, ClubRole, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelectMultipleUser,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectUser
|
||||
from counter.models import Counter, Selling
|
||||
from counter.schemas import SaleFilterSchema
|
||||
|
||||
@@ -131,107 +121,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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"),
|
||||
]
|
||||
+3
-167
@@ -28,8 +28,7 @@ from functools import reduce
|
||||
from typing import Iterable, Self
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import RegexValidator, validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import ProgrammingError, models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
@@ -41,7 +40,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, Notification, Page, SithFile, User
|
||||
from core.models import Group, Page, SithFile, User
|
||||
|
||||
|
||||
class ClubQuerySet(models.QuerySet):
|
||||
@@ -238,7 +237,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 self.has_rights_in_club(user)
|
||||
return user.has_perm("club.change_club") or 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"""
|
||||
@@ -633,169 +632,6 @@ 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.
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
{% 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">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% 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 %}
|
||||
@@ -15,9 +15,6 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import select_all_checkbox %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Mailing lists{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
|
||||
|
||||
{% if mailings_not_moderated %}
|
||||
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
|
||||
<ul>
|
||||
{% for mailing in mailings_not_moderated %}
|
||||
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if mailings_moderated %}
|
||||
{% for mailing in mailings_moderated %}
|
||||
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
|
||||
{%- if user.is_owner(mailing) -%}
|
||||
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
|
||||
{%- endif -%}
|
||||
</h2>
|
||||
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
|
||||
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
|
||||
</form>
|
||||
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
|
||||
{% if form_mailing_removal.field.choices %}
|
||||
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
|
||||
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
|
||||
{% csrf_token %}
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Email{% endtrans %}</td>
|
||||
<td>{% trans %}Delete{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for widget in form_mailing_removal.subwidgets %}
|
||||
{% set user = ms[widget.data.value.value][0] %}
|
||||
<tr>
|
||||
<td>{{ user.get_username }}</td>
|
||||
<td>{{ user.get_email }}</td>
|
||||
<td>{{ widget.tag() }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ form_mailing_removal.errors }}
|
||||
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>{{ form.non_field_errors() }}</p>
|
||||
{% if mailings_moderated %}
|
||||
<h2>{% trans %}New member{% endtrans %}</h2>
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.subscription_mailing.errors }}
|
||||
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
|
||||
{{ form.subscription_mailing }}
|
||||
</p>
|
||||
<p>
|
||||
{{ form.subscription_users.errors }}
|
||||
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
|
||||
{{ form.subscription_users }}
|
||||
<span class="helptext">{{ form.subscription_users.help_text }}</span>
|
||||
</p>
|
||||
<p>
|
||||
{{ form.subscription_email.errors }}
|
||||
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
|
||||
{{ form.subscription_email }}
|
||||
</p>
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
|
||||
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans %}New mailing{% endtrans %}</h2>
|
||||
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{{ form.mailing_email.errors }}
|
||||
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
|
||||
{{ form.mailing_email }}
|
||||
</p>
|
||||
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
|
||||
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
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]]),
|
||||
]
|
||||
@@ -1,328 +0,0 @@
|
||||
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
|
||||
+57
-9
@@ -1,8 +1,9 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
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
|
||||
@@ -16,13 +17,20 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_page_display_on_club_main_page(client: Client):
|
||||
@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]
|
||||
):
|
||||
"""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=1, content=content)
|
||||
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
|
||||
baker.make(PageRev, page=club.page, revision=2, content=content)
|
||||
|
||||
if user := user_factory():
|
||||
client.force_login(user)
|
||||
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")
|
||||
@@ -30,20 +38,60 @@ def test_page_display_on_club_main_page(client: Client):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_club_main_page_without_content(client: Client):
|
||||
"""Test the club view works, even if the club page is empty"""
|
||||
@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."""
|
||||
club = baker.make(Club)
|
||||
club.page.revisions.all().delete()
|
||||
if user := user_factory(club):
|
||||
client.force_login(user)
|
||||
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")
|
||||
assert detail_html.find_all("markdown") == []
|
||||
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
|
||||
|
||||
|
||||
@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"])
|
||||
|
||||
+2
-20
@@ -29,8 +29,8 @@ from club.views import (
|
||||
ClubCreateView,
|
||||
ClubEditView,
|
||||
ClubListView,
|
||||
ClubMailingView,
|
||||
ClubMembersView,
|
||||
ClubNewsView,
|
||||
ClubOldMembersView,
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
@@ -43,9 +43,6 @@ from club.views import (
|
||||
ClubSellingView,
|
||||
ClubToolsView,
|
||||
ClubView,
|
||||
MailingAutoGenerationView,
|
||||
MailingDeleteView,
|
||||
MailingSubscriptionDeleteView,
|
||||
MembershipDeleteView,
|
||||
MembershipSetOldView,
|
||||
PosterCreateView,
|
||||
@@ -96,22 +93,6 @@ 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(),
|
||||
@@ -136,4 +117,5 @@ urlpatterns = [
|
||||
PosterDeleteView.as_view(),
|
||||
name="poster_delete",
|
||||
),
|
||||
path("<int:club_id>/news/", ClubNewsView.as_view(), name="news_list"),
|
||||
]
|
||||
+58
-217
@@ -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,7 +59,6 @@ from club.forms import (
|
||||
ClubRoleFormSet,
|
||||
ClubSearchForm,
|
||||
JoinClubForm,
|
||||
MailingForm,
|
||||
SellingsForm,
|
||||
)
|
||||
from club.models import (
|
||||
@@ -67,11 +66,9 @@ from club.models import (
|
||||
ClubLink,
|
||||
ClubRole,
|
||||
LinkType,
|
||||
Mailing,
|
||||
MailingSubscription,
|
||||
Membership,
|
||||
)
|
||||
from com.models import Poster
|
||||
from com.models import NewsDate, Poster
|
||||
from com.views import (
|
||||
PosterCreateBaseView,
|
||||
PosterDeleteBaseView,
|
||||
@@ -80,6 +77,7 @@ 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,
|
||||
@@ -106,42 +104,40 @@ 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(
|
||||
[
|
||||
@@ -159,21 +155,6 @@ 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}
|
||||
@@ -181,13 +162,6 @@ 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}
|
||||
@@ -742,166 +716,6 @@ 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
|
||||
):
|
||||
@@ -957,3 +771,30 @@ 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())
|
||||
),
|
||||
}
|
||||
+1
-4
@@ -259,10 +259,7 @@ 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.
|
||||
|
||||
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
|
||||
"""
|
||||
"""Send the weekmail to all users with the receive weekmail option opt-in."""
|
||||
dest = [
|
||||
i[0]
|
||||
for i in Preferences.objects.filter(receive_weekmail=True).values_list(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
margin-top: 20px;
|
||||
padding: 0.4em;
|
||||
width: 80%;
|
||||
background: $white-color;
|
||||
|
||||
h4 {
|
||||
margin-top: 1em;
|
||||
|
||||
@@ -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: lighten($second-color, 5%);
|
||||
background: var(--bg-yellow);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: .75rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
min-height: 20em;
|
||||
padding: 1em;
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
#birthdays_content {
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
padding: 1em;
|
||||
|
||||
ul.birthdays_year {
|
||||
@@ -123,7 +123,7 @@
|
||||
padding: 0.5em;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||
.news_events_group {
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -153,8 +153,8 @@
|
||||
display: table-cell;
|
||||
padding: 0.6em;
|
||||
vertical-align: middle;
|
||||
background: $primary-neutral-dark-color;
|
||||
color: $white-color;
|
||||
background: var(--deepblue);
|
||||
color: var(--text-white);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.news_event:nth-of-type(even) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
.news_event {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Mailing lists administration{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_mailings(list) %}
|
||||
<table>
|
||||
<tr>
|
||||
<th>{% trans %}Email{% endtrans %}</th>
|
||||
<th>{% trans %}Club{%endtrans%}</th>
|
||||
<th>{% trans %}Actions{% endtrans %}</th>
|
||||
</tr>
|
||||
{% for mailing in list %}
|
||||
<tr>
|
||||
<td>{{ mailing.email_full }}</td>
|
||||
<td><a href="{{ url('club:mailing', club_id=mailing.club.id) }}">{{ mailing.club }}</a></td>
|
||||
<td>
|
||||
<a href="{{ url('com:mailing_delete', mailing_id=mailing.id) }}">{% trans %}Delete{% endtrans %}</a> - {% if not mailing.is_moderated %}<a href="{{ url('com:mailing_moderate', mailing_id=mailing.id) }}">{% trans %}Moderate{% endtrans %}</a>{% else %}{% trans user=mailing.moderator %}Moderated by {{ user }}{% endtrans %}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans %}This page lists all mailing lists{% endtrans %}</h1>
|
||||
|
||||
{% if has_unmoderated %}
|
||||
<h2>{% trans %}Not moderated mailing lists{% endtrans %}</h2>
|
||||
{{ display_mailings(unmoderated) }}
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans %}Moderated mailing lists{% endtrans %}</h2>
|
||||
{% if has_moderated %}
|
||||
{{ display_mailings(moderated) }}
|
||||
{% else %}
|
||||
<p>{% trans %}No mailing list existing{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
-14
@@ -15,12 +15,9 @@
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from club.views import MailingDeleteView
|
||||
from com.views import (
|
||||
AlertMsgEditView,
|
||||
InfoMsgEditView,
|
||||
MailingListAdminView,
|
||||
MailingModerateView,
|
||||
NewsAdminListView,
|
||||
NewsCreateView,
|
||||
NewsDeleteView,
|
||||
@@ -82,17 +79,6 @@ 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"),
|
||||
|
||||
+3
-41
@@ -30,11 +30,9 @@ 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 PermissionDenied, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, Max, OuterRef, Value
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -46,7 +44,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, Mailing, Membership
|
||||
from club.models import Club, 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
|
||||
@@ -86,11 +84,6 @@ 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",
|
||||
@@ -533,37 +526,6 @@ 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
@@ -1,18 +1,15 @@
|
||||
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,
|
||||
@@ -48,19 +45,6 @@ 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])
|
||||
|
||||
@@ -864,7 +864,11 @@ class Command(BaseCommand):
|
||||
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=["add_news", "add_uecomment", "add_self_timetable"]
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers.permissions.add(
|
||||
@@ -893,18 +897,16 @@ class Command(BaseCommand):
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
"change_product",
|
||||
"add_product",
|
||||
"view_producttype",
|
||||
"change_producttype",
|
||||
"add_producttype",
|
||||
"delete_selling",
|
||||
]
|
||||
)
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
"change_product",
|
||||
"add_product",
|
||||
"view_producttype",
|
||||
"change_producttype",
|
||||
"add_producttype",
|
||||
"delete_selling",
|
||||
]
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0051_user_cgu_approved_at")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="preferences",
|
||||
name="show_my_timetable",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"Allow subscribers to see your timetable and "
|
||||
"to compare it to theirs (if you have filled it)"
|
||||
),
|
||||
verbose_name="show your timetable to others",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -799,6 +799,14 @@ class Preferences(models.Model):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
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,6 +60,7 @@ 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: {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/*--------------------------------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;
|
||||
}
|
||||
@@ -1,41 +1,37 @@
|
||||
$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);
|
||||
/**
|
||||
* Sith theme variables
|
||||
*/
|
||||
:root {
|
||||
--bg-yellow: #ffe270;
|
||||
--bg-white: #f9fafb;
|
||||
--bg-off-white: #f0f0f0;
|
||||
--bg-darkened-off-white: #e3e3e3;
|
||||
--bg-lightgrey: #d4d4d4;
|
||||
|
||||
$primary-light-color: hsl(219.8, 46.4%, 64.9%);
|
||||
$primary-dark-color: hsl(203, 75%, 40%);
|
||||
--text-white: #f9fafb;
|
||||
--text-black: #2b2b2b;
|
||||
--text-red: #eb2f06;
|
||||
|
||||
$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;
|
||||
--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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@@ -30,12 +30,12 @@
|
||||
gap: 20px;
|
||||
|
||||
&:disabled {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
opacity: 65%;
|
||||
}
|
||||
|
||||
&.clickable:not(:disabled):hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
||||
@@ -11,7 +11,7 @@ ui-tab-group {
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
padding: 3px 12px 12px;
|
||||
column-gap: 20px;
|
||||
border-top-left-radius: 5px;
|
||||
@@ -25,23 +25,27 @@ 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 darken($primary-neutral-light-color, 10%);
|
||||
border-bottom: 4px solid var(--bg-lightgrey);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
&.active:after {
|
||||
border-bottom-color: $primary-dark-color;
|
||||
border-bottom-color: var(--lightblue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,92 +2,75 @@
|
||||
@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: $primary-neutral-dark-color;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
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: $white-color;
|
||||
width: auto;
|
||||
color: var(--text-white);
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $white-color;
|
||||
color: var(--text-white);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
border: none;
|
||||
border-top: 0.5px solid $white-color;
|
||||
border-top: var(--bg-white) 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: var(--text-white);
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-14
@@ -16,7 +16,6 @@
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
@@ -24,7 +23,7 @@
|
||||
color: black;
|
||||
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
background: var(--bg-lightgrey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +52,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;
|
||||
@@ -64,7 +63,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;
|
||||
@@ -75,7 +74,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;
|
||||
@@ -83,14 +82,14 @@
|
||||
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: $primary-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +160,7 @@ form {
|
||||
|
||||
fieldset {
|
||||
margin-bottom: 1rem;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.row > label {
|
||||
@@ -762,16 +762,13 @@ form {
|
||||
// a submit input that should look like a regular <a>
|
||||
input[type="submit"], button {
|
||||
&.link-like {
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&.link-red {
|
||||
color: $red-text-color;
|
||||
&:hover {
|
||||
color: $hovered-red-text-color;
|
||||
}
|
||||
color: var(--text-red);
|
||||
}
|
||||
font-weight: normal;
|
||||
font-size: 100%;
|
||||
|
||||
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--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: $deepblue;
|
||||
background-color: var(--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: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
}
|
||||
|
||||
>.options {
|
||||
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 5px;
|
||||
background-color: $red-text-color;
|
||||
background-color: var(--text-red);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
.markdown {
|
||||
--bg-lightgrey: rgb(240, 241, 245);
|
||||
--border-lightgrey: rgb(215, 216, 220);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -28,11 +25,20 @@
|
||||
font-size: 14px;
|
||||
tab-size: 4;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-lightgrey);
|
||||
background-color: var(--bg-lightgrey);
|
||||
border: 1px solid var(--bg-lightgrey);
|
||||
background-color: var(--bg-off-white);
|
||||
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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
$desktop-size: 500px;
|
||||
|
||||
nav.navbar {
|
||||
background-color: $primary-dark-color;
|
||||
background-color: var(--lightblue);
|
||||
margin: 1em;
|
||||
color: white;
|
||||
border-radius: 0.6em;
|
||||
|
||||
@@ -6,27 +6,21 @@
|
||||
margin: 30px;
|
||||
|
||||
button {
|
||||
background-color: $secondary-neutral-light-color;
|
||||
background-color: var(--bg-off-white);
|
||||
min-width: 37px;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($secondary-neutral-light-color, 10%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #fff;
|
||||
background-color: darken($secondary-neutral-light-color, 5%);
|
||||
background-color: var(--bg-lightgrey);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&.active:hover {
|
||||
background-color: $primary-neutral-color;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
.pagination-arrow {
|
||||
// previous and next buttons
|
||||
&:disabled {
|
||||
// Hide the arrows when they are disabled, without
|
||||
|
||||
+65
-101
@@ -2,12 +2,21 @@
|
||||
@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] {
|
||||
@@ -57,7 +66,7 @@ body {
|
||||
|
||||
.collapse-header {
|
||||
color: white;
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,6 +118,12 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
margin: .5em auto;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
/*--------------------------------HEADER-------------------------------*/
|
||||
|
||||
#info_boxes {
|
||||
@@ -141,7 +156,7 @@ body {
|
||||
}
|
||||
|
||||
#info_box {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
|
||||
&:before {
|
||||
content: "\f05a";
|
||||
@@ -150,11 +165,11 @@ body {
|
||||
}
|
||||
|
||||
#alert_box {
|
||||
background: $second-color;
|
||||
background: var(--bg-yellow);
|
||||
|
||||
&:before {
|
||||
content: "\f06a";
|
||||
color: $white-color;
|
||||
color: var(--text-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,10 +204,10 @@ body {
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($deepblue, 10%);
|
||||
background-color: var(--darkened-deepblue);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -215,7 +230,7 @@ body {
|
||||
&.btn-green {
|
||||
$bg-color: rgba(0, 210, 83, 0.4);
|
||||
background-color: $bg-color;
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($bg-color, 15%);
|
||||
@@ -272,8 +287,8 @@ body {
|
||||
/*--------------------------------CONTENT------------------------------*/
|
||||
#content {
|
||||
padding: 1.5em 3%;
|
||||
box-shadow: $shadow-color 0 5px 10px;
|
||||
background: $white-color;
|
||||
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
|
||||
background: var(--bg-white);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -343,7 +358,7 @@ body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
float: right;
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
@@ -351,7 +366,7 @@ body {
|
||||
a {
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
font-weight: bold;
|
||||
margin: 2px;
|
||||
flex: 1;
|
||||
@@ -359,14 +374,14 @@ body {
|
||||
white-space: nowrap;
|
||||
|
||||
&.selected_tab {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
@@ -398,28 +413,33 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Fontawesome icons */
|
||||
.fa-brands, .fa-link {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-facebook {
|
||||
color: $faceblue;
|
||||
}
|
||||
/* Fontawesome icons */
|
||||
.fa-brands, .fa-link {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.fa-discord {
|
||||
color: $discordblurple;
|
||||
}
|
||||
.fa-facebook {
|
||||
color: $faceblue;
|
||||
}
|
||||
|
||||
.fa-square-instagram::before, .fa-instagram::before {
|
||||
background: $instagradient;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.fa-discord {
|
||||
color: $discordblurple;
|
||||
}
|
||||
|
||||
.fa-bluesky, .fa-square-bluesky {
|
||||
color: #0f73ff;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-devices) {
|
||||
@@ -481,16 +501,6 @@ dt {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.edit-bar {
|
||||
display: block;
|
||||
margin: 4px;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.important {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
@@ -503,7 +513,6 @@ 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;
|
||||
@@ -515,25 +524,21 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -543,10 +548,6 @@ td {
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
>ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -556,23 +557,23 @@ thead td {
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: $deepblue;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
tbody>tr {
|
||||
tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-lightgrey);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
color: $primary-dark-color;
|
||||
color: var(--lightblue);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -598,7 +599,7 @@ em {
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: $second-color;
|
||||
background: var(--bg-yellow);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -700,15 +701,15 @@ u,
|
||||
height: 18em;
|
||||
overflow: hidden;
|
||||
border: 1px solid black;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 1px 1px 5px $second-color;
|
||||
box-shadow: 1px 1px 5px var(--bg-yellow);
|
||||
}
|
||||
}
|
||||
|
||||
.matmat_user a {
|
||||
color: $primary-neutral-dark-color;
|
||||
color: var(--deepblue);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -723,7 +724,6 @@ u,
|
||||
display: block;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
background: $white-color;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -731,43 +731,7 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ body {
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
box-shadow: none;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
|
||||
.alert {
|
||||
|
||||
@@ -71,7 +71,7 @@ main {
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
|
||||
> span {
|
||||
font-size: small;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
&:enabled>.fa {
|
||||
background-color: #354a5f;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<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,5 +29,13 @@
|
||||
</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>
|
||||
@@ -102,6 +102,7 @@
|
||||
@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>
|
||||
@@ -112,6 +113,7 @@
|
||||
@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>
|
||||
@@ -163,12 +165,14 @@
|
||||
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
<button class="pagination-arrow">
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled"><i class="fa fa-caret-left"></i></button>
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<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 %}
|
||||
@@ -201,12 +205,14 @@
|
||||
{%- else -%}
|
||||
href="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
><button class="pagination-arrow">
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -79,15 +79,5 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
{% for sum in purchase_sums %}
|
||||
<div>
|
||||
<span>{{ sum["counter__name"] }}</span>
|
||||
<span>{{ sum["total"] }} €</span>
|
||||
<span>{{ "%.2f"|format(sum["total"]) }} €</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
|
||||
<div><b>Total : </b><b>{{ "%.2f"|format(total_purchases) }} €</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
<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 %}
|
||||
|
||||
@@ -84,6 +84,7 @@ from core.views import (
|
||||
notification,
|
||||
send_file,
|
||||
)
|
||||
from timetable.views import UserTimetableView
|
||||
|
||||
register_converter(FourDigitYearConverter, "yyyy")
|
||||
register_converter(TwoDigitMonthConverter, "mm")
|
||||
@@ -184,6 +185,11 @@ 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(),
|
||||
|
||||
+12
-10
@@ -21,7 +21,7 @@ from wsgiref.util import FileWrapper
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.forms.models import modelform_factory
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
@@ -233,6 +233,12 @@ 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
|
||||
@@ -253,10 +259,7 @@ 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):
|
||||
@@ -269,7 +272,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
form_class = AddFilesForm
|
||||
|
||||
@staticmethod
|
||||
def handle_clipboard(request, obj):
|
||||
def handle_clipboard(request, obj: SithFile):
|
||||
"""Handle the clipboard in the view.
|
||||
|
||||
This method can fail, since it does not catch the exceptions coming from
|
||||
@@ -283,10 +286,9 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
where you want to paste the clipboard
|
||||
"""
|
||||
if "delete" in request.POST:
|
||||
for f_id in request.POST.getlist("file_list"):
|
||||
file = SithFile.objects.filter(id=f_id).first()
|
||||
if file:
|
||||
file.delete()
|
||||
file_ids = request.POST.getlist("file_list")
|
||||
for file in obj.children.filter(id__in=file_ids):
|
||||
file.delete()
|
||||
if "clear" in request.POST:
|
||||
request.session["clipboard"] = []
|
||||
if "cut" in request.POST:
|
||||
|
||||
+10
-2
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
|
||||
|
||||
__preferences_fields = forms.fields_for_model(
|
||||
Preferences,
|
||||
["show_my_stats"],
|
||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
||||
["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 = __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
|
||||
@@ -347,12 +351,16 @@ 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
|
||||
|
||||
|
||||
+16
-2
@@ -1,6 +1,14 @@
|
||||
import copy
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
LiteralString,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Unpack,
|
||||
)
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import render_to_string
|
||||
@@ -12,11 +20,17 @@ 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 | None] = None
|
||||
list_of_tabs: ClassVar[list[TabItem] | None] = None
|
||||
tabs_title: ClassVar[str | None] = None
|
||||
|
||||
def get_tabs_title(self):
|
||||
|
||||
+2
-3
@@ -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, counter__type="BAR")
|
||||
self.object.permanencies.filter(end__isnull=False)
|
||||
.values("counter", "counter__name")
|
||||
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
|
||||
.order_by("-total")
|
||||
@@ -422,8 +422,7 @@ 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.filter(counter__type="BAR")
|
||||
.values("counter", "counter__name")
|
||||
self.object.customer.buyings.values("counter", "counter__name")
|
||||
.annotate(total=Sum(F("unit_price") * F("quantity")))
|
||||
.order_by("-total")
|
||||
)
|
||||
|
||||
+7
-1
@@ -130,8 +130,14 @@ class GetUserForm(forms.Form):
|
||||
elif cleaned_data["id"]:
|
||||
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
if customer is None:
|
||||
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
|
||||
|
||||
@@ -647,13 +647,6 @@ 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."""
|
||||
@@ -661,10 +654,6 @@ 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: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
|
||||
@@ -83,8 +83,17 @@
|
||||
{% 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>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||
<h2>{{ object }}</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 %}
|
||||
@@ -127,19 +136,17 @@
|
||||
{{ 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>
|
||||
<button class="btn btn-grey" @click.prevent="addForm()">
|
||||
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
|
||||
</button>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -165,11 +172,16 @@
|
||||
<template x-ref="formTemplate">
|
||||
{{ action_form(form.action_formset.empty_form) }}
|
||||
</template>
|
||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||
</button>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
|
||||
{% if user.has_perm("counter.change_product") %}
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -80,9 +80,11 @@
|
||||
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
|
||||
|
||||
<div class="row margin-bottom">
|
||||
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
||||
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
||||
</a>
|
||||
{% 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 %}
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
@click="downloadCsv()"
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
|
||||
{% if user.has_perm("counter.add_producttype") %}
|
||||
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
|
||||
{% trans %}New product type{% endtrans %}
|
||||
<i class="fa fa-plus"></i>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if product_types %}
|
||||
<aside>
|
||||
<p>
|
||||
|
||||
@@ -21,7 +21,8 @@ import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.messages import DEFAULT_LEVELS, get_messages
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
@@ -65,8 +66,9 @@ class TestFullClickBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.customer = subscriber_user.make()
|
||||
cls.barmen = subscriber_user.make(password=make_password("plop"))
|
||||
cls.board_admin = board_user.make(password=make_password("plop"))
|
||||
password = make_password("plop")
|
||||
cls.barmen = subscriber_user.make(password=password)
|
||||
cls.board_admin = board_user.make(password=password)
|
||||
cls.club_admin = subscriber_user.make()
|
||||
cls.root = baker.make(User, is_superuser=True)
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
+6
-2
@@ -6,8 +6,10 @@ 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) -> bool:
|
||||
|
||||
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> 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.
|
||||
@@ -30,4 +32,6 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
||||
if not referer_ok and request.resolver_match.app_name != "counter":
|
||||
return False
|
||||
|
||||
return bool(request.barmen)
|
||||
if counter is None:
|
||||
return bool(request.barmen)
|
||||
return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))
|
||||
+61
-58
@@ -48,7 +48,7 @@ from counter.models import (
|
||||
Selling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||
from counter.views.mixins import CounterAdminTabsMixin
|
||||
|
||||
|
||||
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||
@@ -84,17 +84,18 @@ class CounterEditView(
|
||||
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
|
||||
|
||||
|
||||
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
"""Edit a counter's main informations (for the counter's admin)."""
|
||||
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit a counter's main infos."""
|
||||
|
||||
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, CounterAdminMixin, CreateView):
|
||||
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
|
||||
"""Create a counter (for the admins)."""
|
||||
|
||||
model = Counter
|
||||
@@ -105,9 +106,10 @@ class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
)
|
||||
template_name = "core/create.jinja"
|
||||
current_tab = "counters"
|
||||
permission_required = "counter.add_counter"
|
||||
|
||||
|
||||
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
||||
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
|
||||
"""Delete a counter (for the admins)."""
|
||||
|
||||
model = Counter
|
||||
@@ -115,30 +117,33 @@ class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
||||
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, CounterAdminMixin, ListView):
|
||||
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, 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, CounterAdminMixin, CreateView):
|
||||
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, 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, CounterAdminMixin, UpdateView):
|
||||
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = ProductType
|
||||
@@ -146,23 +151,26 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
fields = ["name", "description", "comment", "icon"]
|
||||
pk_url_kwarg = "type_id"
|
||||
current_tab = "products"
|
||||
permission_required = "counter.change_producttype"
|
||||
|
||||
|
||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
|
||||
current_tab = "products"
|
||||
template_name = "counter/product_list.jinja"
|
||||
permission_required = "counter.view_product"
|
||||
|
||||
|
||||
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, 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, CounterAdminMixin, UpdateView):
|
||||
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""An edit view for the admins."""
|
||||
|
||||
model = Product
|
||||
@@ -170,6 +178,13 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, 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):
|
||||
@@ -291,54 +306,41 @@ class ReturnableProductDeleteView(
|
||||
}
|
||||
|
||||
|
||||
class RefillingDeleteView(DeleteView):
|
||||
class CounterTransactionDeletionView(DeleteView):
|
||||
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()
|
||||
if timezone.now() - self.object.date <= timedelta(
|
||||
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||
) and is_logged_in_counter(request, counter=self.object.counter):
|
||||
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 RefillingDeleteView(CounterTransactionDeletionView):
|
||||
"""Delete a refilling (for the admins)."""
|
||||
|
||||
model = Refilling
|
||||
pk_url_kwarg = "refilling_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 SellingDeleteView(DeleteView):
|
||||
class SellingDeleteView(CounterTransactionDeletionView):
|
||||
"""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):
|
||||
@@ -376,30 +378,31 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class CounterRefillingListView(
|
||||
CounterAdminTabsMixin, PermissionRequiredMixin, 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 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_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 get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["counter"] = self.counter
|
||||
return kwargs
|
||||
return super().get_context_data(**kwargs) | {"counter": self.counter}
|
||||
|
||||
|
||||
class RefoundAccountView(UserPassesTestMixin, FormView):
|
||||
class RefoundAccountView(UserPassesTestMixin, SuccessMessageMixin, 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(
|
||||
|
||||
@@ -242,15 +242,11 @@ 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 (
|
||||
request.barmen
|
||||
and request.barmen.issubset(self.counter.barmen_list)
|
||||
and self.counter.can_refill
|
||||
if (
|
||||
not is_logged_in_counter(request, counter=self.counter)
|
||||
or not self.counter.can_refill
|
||||
):
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
@@ -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,28 +21,30 @@ 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 CounterAdminMixin, CounterAdminTabsMixin
|
||||
from counter.views.mixins import CounterAdminTabsMixin
|
||||
|
||||
|
||||
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, 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, CounterAdminMixin, CreateView):
|
||||
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, 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, CounterAdminMixin, UpdateView):
|
||||
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit an eticket."""
|
||||
|
||||
model = Eticket
|
||||
@@ -50,6 +52,7 @@ class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
form_class = EticketForm
|
||||
pk_url_kwarg = "eticket_id"
|
||||
current_tab = "etickets"
|
||||
permission_required = "counter.change_eticket"
|
||||
|
||||
|
||||
class EticketPDFView(CanViewMixin, DetailView):
|
||||
|
||||
@@ -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,6 +36,7 @@ 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
|
||||
|
||||
|
||||
@@ -155,7 +156,7 @@ class CounterMain(
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailView):
|
||||
"""Provide the last operations to allow barmen to delete them."""
|
||||
|
||||
model = Counter
|
||||
@@ -163,14 +164,12 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
template_name = "counter/last_ops.jinja"
|
||||
current_tab = "last_ops"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here again a very particular right handling."""
|
||||
def test_func(self):
|
||||
self.object = self.get_object()
|
||||
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"
|
||||
return is_logged_in_counter(
|
||||
self.request, counter=self.object
|
||||
) or self.request.user.has_perms(
|
||||
["counter.view_selling", "counter.view_refilling"]
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
+73
-69
@@ -13,41 +13,16 @@
|
||||
#
|
||||
#
|
||||
|
||||
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
|
||||
return self.object.name
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if self.object.type != "BAR":
|
||||
@@ -71,7 +46,11 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
"name": _("Cash summary"),
|
||||
}
|
||||
)
|
||||
if is_logged_in_counter(self.request):
|
||||
if is_logged_in_counter(
|
||||
self.request, counter=self.object
|
||||
) or self.request.user.has_perms(
|
||||
["counter.view_selling", "counter.view_refilling"]
|
||||
):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse(
|
||||
@@ -89,45 +68,70 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
|
||||
class CounterAdminTabsMixin(TabedViewMixin):
|
||||
tabs_title = _("Counter administration")
|
||||
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"),
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
@@ -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 carrément plus moche.
|
||||
serait un pur enfer.
|
||||
|
||||
=== "nu"
|
||||
|
||||
@@ -59,20 +59,18 @@ des scripts complets mais qui restent lisibles,
|
||||
|
||||
Nombre de lignes total :
|
||||
```nu
|
||||
ls **/*.py | insert linecount { get name | open | lines | length } | math sum
|
||||
ls **/*.py | par-each { open $in.name | lines | length } | math sum
|
||||
```
|
||||
|
||||
Vous pouvez aussi exlure les lignes vides
|
||||
et les les lignes de commentaire :
|
||||
```nu
|
||||
ls **/*.py |
|
||||
insert linecount {
|
||||
get name |
|
||||
open |
|
||||
par-each {
|
||||
open $in.name |
|
||||
lines |
|
||||
each { str trim } |
|
||||
filter { |l| not ($l | str starts-with "#") } | # commentaires
|
||||
filter { |l| ($l | str length) > 0 } | # lignes vides
|
||||
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
|
||||
length
|
||||
} |
|
||||
math sum
|
||||
|
||||
+40
-16
@@ -18,7 +18,8 @@ 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
|
||||
@@ -43,7 +44,7 @@ il suffit d'utiliser la commande suivante :
|
||||
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
```
|
||||
|
||||
!!!note
|
||||
!!!success
|
||||
|
||||
À 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
|
||||
@@ -101,10 +102,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
|
||||
@@ -116,12 +117,15 @@ 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
|
||||
|
||||
@@ -145,7 +149,6 @@ 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`.
|
||||
@@ -174,8 +177,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 :
|
||||
|
||||
@@ -183,11 +186,32 @@ Il suffit ensuite d'utiliser cette commande :
|
||||
uv run ./manage.py runserver
|
||||
```
|
||||
|
||||
!!!note
|
||||
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/).
|
||||
|
||||
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/).
|
||||
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`.
|
||||
|
||||
!!!note
|
||||
|
||||
@@ -228,10 +252,10 @@ uv run ./manage.py runserver
|
||||
|
||||
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.
|
||||
|
||||
@@ -241,7 +265,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
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.fa-plus,
|
||||
.fa-minus {
|
||||
cursor: pointer;
|
||||
background-color: #354a5f;
|
||||
background-color: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 5px;
|
||||
|
||||
@@ -288,7 +288,7 @@ $min_col_width: 100px;
|
||||
border: none;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.18em;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
}
|
||||
|
||||
.topic {
|
||||
border: solid $primary-neutral-color 1px;
|
||||
border: solid var(--darkened-deepblue) 1px;
|
||||
border-radius: 5px;
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@@ -56,7 +57,7 @@
|
||||
}
|
||||
|
||||
.forum {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: $black-color;
|
||||
color: var(--text-black);
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
.category {
|
||||
margin-top: 5px;
|
||||
background: $secondary-color;
|
||||
background: var(--deepblue);
|
||||
color: white;
|
||||
border-radius: 10px 10px 0 0;
|
||||
.title {
|
||||
@@ -105,12 +106,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: $secondary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
border-radius: 5px;
|
||||
border: 1px darken($secondary-neutral-light-color, 10%) solid;
|
||||
border: 1px var(--bg-lightgrey) solid;
|
||||
|
||||
&:nth-child(odd) {
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
|
||||
+5
-2
@@ -346,13 +346,16 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class ForumMessageView(SingleObjectMixin, RedirectView):
|
||||
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
|
||||
model = ForumMessage
|
||||
pk_url_kwarg = "message_id"
|
||||
permanent = False
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
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):
|
||||
return self.object.get_url()
|
||||
|
||||
|
||||
|
||||
+221
-261
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-02 12:56+0200\n"
|
||||
"POT-Creation-Date: 2026-10-27 23:26+0100\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,51 +192,11 @@ 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 subscription/forms.py
|
||||
#: club/forms.py com/forms.py counter/forms.py
|
||||
msgid "End date"
|
||||
msgstr "Date de fin"
|
||||
|
||||
@@ -394,7 +354,8 @@ 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 trombi/models.py
|
||||
#: election/models.py pedagogy/models.py sas/models.py timetable/models.py
|
||||
#: trombi/models.py
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
@@ -414,60 +375,6 @@ 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"
|
||||
@@ -517,6 +424,22 @@ 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"
|
||||
@@ -554,6 +477,13 @@ 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
|
||||
@@ -564,6 +494,7 @@ 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"
|
||||
@@ -578,6 +509,34 @@ 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"
|
||||
@@ -817,8 +776,7 @@ msgstr "Total"
|
||||
msgid "Payment method"
|
||||
msgstr "Méthode de paiement"
|
||||
|
||||
#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
|
||||
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
|
||||
#: club/templates/club/club_sellings.jinja com/templates/com/macros.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
|
||||
@@ -944,60 +902,6 @@ 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"
|
||||
@@ -1014,39 +918,20 @@ 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."
|
||||
@@ -1093,7 +978,7 @@ msgstr "Prix d'achat"
|
||||
msgid "Format: 16:9 | Resolution: 1920x1080"
|
||||
msgstr "Format : 16:9 | Résolution : 1920x1080"
|
||||
|
||||
#: com/forms.py subscription/forms.py
|
||||
#: com/forms.py
|
||||
msgid "Start date"
|
||||
msgstr "Date de début"
|
||||
|
||||
@@ -1177,6 +1062,10 @@ msgstr "auteur"
|
||||
msgid "is published"
|
||||
msgstr "est publié"
|
||||
|
||||
#: com/models.py
|
||||
msgid "moderator"
|
||||
msgstr "modérateur"
|
||||
|
||||
#: com/models.py
|
||||
msgid "news"
|
||||
msgstr "nouvelle"
|
||||
@@ -1237,6 +1126,10 @@ 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"
|
||||
@@ -1272,46 +1165,6 @@ 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"
|
||||
@@ -1321,11 +1174,6 @@ 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"
|
||||
@@ -1340,9 +1188,14 @@ msgstr "Événements hebdomadaires affichées"
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: com/templates/com/news_admin_list.jinja
|
||||
msgid "Summary"
|
||||
msgstr "Résumé"
|
||||
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
|
||||
#: core/templates/core/user_clubs.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: trombi/templates/trombi/edit_profile.jinja
|
||||
#: trombi/templates/trombi/export.jinja
|
||||
#: trombi/templates/trombi/user_profile.jinja
|
||||
msgid "Club"
|
||||
msgstr "Club"
|
||||
|
||||
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
|
||||
#: forum/templates/forum/forum.jinja sas/models.py
|
||||
@@ -1358,6 +1211,11 @@ 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"
|
||||
@@ -1371,11 +1229,6 @@ 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"
|
||||
@@ -1518,6 +1371,13 @@ 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"
|
||||
@@ -1873,7 +1733,7 @@ msgstr "département"
|
||||
msgid "dpt option"
|
||||
msgstr "Filière"
|
||||
|
||||
#: core/models.py pedagogy/models.py
|
||||
#: core/models.py pedagogy/models.py timetable/models.py
|
||||
msgid "semester"
|
||||
msgstr "semestre"
|
||||
|
||||
@@ -1998,6 +1858,18 @@ 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"
|
||||
@@ -2828,14 +2700,6 @@ 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"
|
||||
@@ -3246,6 +3110,10 @@ 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"
|
||||
@@ -3317,6 +3185,10 @@ 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"
|
||||
@@ -3359,6 +3231,14 @@ 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."
|
||||
@@ -3416,6 +3296,10 @@ 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"
|
||||
@@ -4102,7 +3986,6 @@ 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"
|
||||
|
||||
@@ -4237,9 +4120,12 @@ msgid "Remove price"
|
||||
msgstr "Retirer le prix"
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
#, python-format
|
||||
msgid "Edit product %(name)s"
|
||||
msgstr "Édition du produit %(name)s"
|
||||
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."
|
||||
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
msgid "Creation date"
|
||||
@@ -4411,6 +4297,11 @@ 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"
|
||||
@@ -5198,7 +5089,7 @@ msgstr "heures THE"
|
||||
msgid "hours TE"
|
||||
msgstr "heures TE"
|
||||
|
||||
#: pedagogy/models.py
|
||||
#: pedagogy/models.py timetable/models.py
|
||||
msgid "ue"
|
||||
msgstr "UE"
|
||||
|
||||
@@ -5860,10 +5751,6 @@ 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 ""
|
||||
@@ -5985,21 +5872,61 @@ msgid "Create another subscription"
|
||||
msgstr "Créer une nouvelle cotisation"
|
||||
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Total subscriptions"
|
||||
msgstr "Cotisations totales"
|
||||
|
||||
#: subscription/templates/subscription/stats.jinja
|
||||
msgid "Subscriptions by type"
|
||||
msgstr "Cotisations par type"
|
||||
msgid "All subscriptions"
|
||||
msgstr "Toutes les cotisations"
|
||||
|
||||
#: 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"
|
||||
@@ -6030,8 +5957,45 @@ msgid "Generate"
|
||||
msgstr "Générer"
|
||||
|
||||
#: timetable/templates/timetable/generator.jinja
|
||||
msgid "Save to PNG"
|
||||
msgstr "Sauver en PNG"
|
||||
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"
|
||||
|
||||
#: trombi/models.py
|
||||
msgid "subscription deadline"
|
||||
@@ -6146,10 +6110,6 @@ 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"
|
||||
|
||||
Generated
+426
-396
File diff suppressed because it is too large.
Load diff
+13
-13
@@ -24,24 +24,24 @@
|
||||
"#com:*": "./com/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^8.0.6",
|
||||
"@babel/preset-env": "^8.0.6",
|
||||
"@biomejs/biome": "^2.5.15",
|
||||
"@babel/core": "^8.0.9",
|
||||
"@babel/preset-env": "^8.0.7",
|
||||
"@biomejs/biome": "^2.6.3",
|
||||
"@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.4",
|
||||
"@types/node": "^26.6.5",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"vite": "^8.3.2"
|
||||
"vite": "^8.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ae_utbm/aemark": "^0.1.3",
|
||||
"@alpinejs/sort": "^3.17.4",
|
||||
"@ae_utbm/aemark": "^0.1.4",
|
||||
"@alpinejs/sort": "^3.17.5",
|
||||
"@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.3.0",
|
||||
"@zip.js/zip.js": "^2.22.0",
|
||||
"@sentry/browser": "^11.9.0",
|
||||
"@zip.js/zip.js": "^2.24.0",
|
||||
"3d-force-graph": "^1.80.1",
|
||||
"alpinejs": "^3.17.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"alpinejs": "^3.17.5",
|
||||
"chart.js": "^4.6.1",
|
||||
"country-flag-emoji-polyfill": "^0.1.10",
|
||||
"cytoscape": "^3.34.3",
|
||||
"cytoscape": "^3.34.4",
|
||||
"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.5",
|
||||
"temporal-polyfill": "^1.0.6",
|
||||
"three": "^0.186.1",
|
||||
"three-spritetext": "^1.10.0",
|
||||
"tom-select": "^2.6.2"
|
||||
|
||||
@@ -23,7 +23,6 @@ $pedagogy-white-text: #f0f0f0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
.closed td.title {
|
||||
color: lighten($black-color, 10%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -69,9 +68,6 @@ $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;
|
||||
@@ -158,9 +154,9 @@ $pedagogy-white-text: #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: $primary-neutral-light-color;
|
||||
background: var(--bg-off-white);
|
||||
border-radius: 5px;
|
||||
border: 1px darken($secondary-neutral-light-color, 10%) solid;
|
||||
border: 1px var(--bg-lightgrey) solid;
|
||||
|
||||
&.reported {
|
||||
border: #fc8181 1px solid;
|
||||
|
||||
+19
-19
@@ -19,39 +19,39 @@ authors = [
|
||||
license = { text = "GPL-3.0-only" }
|
||||
requires-python = "<4.0,>=3.14"
|
||||
dependencies = [
|
||||
"django>=5.2.17,<6.0.0",
|
||||
"django>=5.2.18,<6.0.0",
|
||||
"django-ninja>=1.7.1,<2.0.0",
|
||||
"django-ninja-extra>=0.31.7",
|
||||
"Pillow>=12.3.0,<13.0.0",
|
||||
"aemark>=0.1.1",
|
||||
"Pillow>=13.0.0,<14",
|
||||
"aemark>=0.1.4",
|
||||
"django-jinja<3.0.0,>=2.11.0",
|
||||
"cryptography>=50.0.2,<51.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-phonenumber-field>=9.0.0,<10",
|
||||
"phonenumbers>=9.0.41,<10.0.0",
|
||||
"reportlab>=5.1.0,<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>=2.9.0.post0,<3.0.0",
|
||||
"sentry-sdk>=2.71.0,<3.0.0",
|
||||
"python-dateutil>=3.0.0,<4",
|
||||
"sentry-sdk>=2.73.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.1.0,<10", # Used by xapian during installation
|
||||
"tomli>=2.4.1,<3.0.0",
|
||||
"Sphinx>=9.2.0,<10", # Used by xapian during installation
|
||||
"tomli>=2.5.0,<3.0.0",
|
||||
"django-honeypot>=1.3.0,<2",
|
||||
"pydantic-extra-types>=2.11.1,<3.0.0",
|
||||
"ical>=14.2.0",
|
||||
"ical>=14.3.1,<15",
|
||||
"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",
|
||||
"django-celery-beat>=2.9.0",
|
||||
"django-celery-results>=2.6.0,<3",
|
||||
"django-celery-beat>=2.9.0,<3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -60,23 +60,23 @@ documentation = "https://sith-ae.readthedocs.io/"
|
||||
|
||||
[dependency-groups]
|
||||
prod = [
|
||||
"psycopg[c]>=3.3.4,<4.0.0",
|
||||
"psycopg[c]>=3.3.6,<4.0.0",
|
||||
]
|
||||
dev = [
|
||||
"django-debug-toolbar>=8.0.0,<9",
|
||||
"ipython>=9.17.1,<10.0.0",
|
||||
"pre-commit>=4.6.2,<5.0.0",
|
||||
"ruff>=0.16.10,<1.0.0",
|
||||
"pre-commit>=4.7.0,<5.0.0",
|
||||
"ruff>=0.17.2,<1.0.0",
|
||||
"djhtml>=3.0.11,<4.0.0",
|
||||
"faker>=40.40.0,<41.0.0",
|
||||
"rjsmin>=1.2.5,<2.0.0",
|
||||
"faker>=40.43.0,<41.0.0",
|
||||
"rjsmin>=1.3.0,<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.1,<2.0.0",
|
||||
"model-bakery>=1.24.2,<2.0.0",
|
||||
"beautifulsoup4>=4.15.0,<5",
|
||||
"lxml>=6.1.3,<7",
|
||||
]
|
||||
|
||||
+3
-4
@@ -184,10 +184,9 @@ class DeleteAllForumUserMessagesView(FormView):
|
||||
form_class = SelectUserForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
res = super().dispatch(request, *args, **kwargs)
|
||||
if request.user.is_root:
|
||||
return res
|
||||
raise PermissionDenied
|
||||
if not request.user.is_root:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.user = form.cleaned_data["user"]
|
||||
|
||||
@@ -27,7 +27,7 @@ main {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: black;
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -36,7 +36,7 @@ main {
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
color: #d4d4d4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
>.tools {
|
||||
flex: 1;
|
||||
.btn {
|
||||
background-color: $primary-neutral-light-color;
|
||||
background-color: var(--bg-white);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -621,7 +621,6 @@ 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"),
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
# Register your models here.
|
||||
from django.contrib import admin
|
||||
|
||||
from timetable.models import Timetable, TimetableSlot
|
||||
|
||||
|
||||
class TimetableSlotInline(admin.TabularInline):
|
||||
model = TimetableSlot
|
||||
|
||||
|
||||
@admin.register(Timetable)
|
||||
class TimetableAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "semester")
|
||||
search_fields = ("user__nick_name", "user__first_name", "user__last_name")
|
||||
autocomplete_fields = ("user",)
|
||||
inlines = (TimetableSlotInline,)
|
||||
list_select_related = ("user",)
|
||||
@@ -0,0 +1,37 @@
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
|
||||
from core.utils import get_semester_code
|
||||
from timetable.models import Timetable, TimetableSlot
|
||||
from timetable.schemas import CreateTimetableSchema, TimetableSchema
|
||||
|
||||
|
||||
@api_controller("/edt", urls_namespace="timetable")
|
||||
class TimetableController(ControllerBase):
|
||||
@route.put(
|
||||
"/{int:user_id}",
|
||||
auth=SessionAuth(),
|
||||
response={200: TimetableSchema},
|
||||
url_name="save",
|
||||
)
|
||||
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
|
||||
user = self.context.request.user
|
||||
if not user.has_perm("pedagogy.add_timetable") and not (
|
||||
user.has_perm("timetable.add_self_timetable") and user.id == user_id
|
||||
):
|
||||
raise PermissionDenied
|
||||
timetable, created = Timetable.objects.get_or_create(
|
||||
semester=get_semester_code(), user_id=user_id
|
||||
)
|
||||
if not created:
|
||||
timetable.slots.all().delete()
|
||||
if user.preferences.show_my_timetable != data.is_viewable:
|
||||
user.preferences.show_my_timetable = data.is_viewable
|
||||
user.preferences.save()
|
||||
slots = TimetableSlot.objects.bulk_create(
|
||||
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
|
||||
)
|
||||
return TimetableSchema(
|
||||
id=timetable.id, semester=timetable.semester, slots=slots
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-03 21:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Timetable",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"semester",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default=core.utils.get_semester_code,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("(A|P)\\d{2}")
|
||||
],
|
||||
verbose_name="semester",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="timetables",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "timetable",
|
||||
"verbose_name_plural": "timetables",
|
||||
"permissions": [("add_self_timetable", "Can add its own timetable")],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TimetableSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"weekday",
|
||||
models.SmallIntegerField(
|
||||
choices=[
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
],
|
||||
verbose_name="weekday",
|
||||
),
|
||||
),
|
||||
(
|
||||
"start_at",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[django.core.validators.MaxValueValidator(95)],
|
||||
verbose_name="start at",
|
||||
),
|
||||
),
|
||||
(
|
||||
"end_at",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[django.core.validators.MaxValueValidator(95)],
|
||||
verbose_name="end at",
|
||||
),
|
||||
),
|
||||
(
|
||||
"course_type",
|
||||
models.CharField(max_length=12, verbose_name="course type"),
|
||||
),
|
||||
("room", models.CharField(max_length=32, verbose_name="room")),
|
||||
("ue", models.CharField(max_length=32, verbose_name="ue")),
|
||||
(
|
||||
"week_group",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[("A", "A"), ("B", "B")],
|
||||
default="",
|
||||
max_length=1,
|
||||
verbose_name="week group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"timetable",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="slots",
|
||||
to="timetable.timetable",
|
||||
verbose_name="timetable",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "timetable slot",
|
||||
"verbose_name_plural": "timetable slots",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timetable",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "semester"), name="timetable_unique_user_semester"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timetableslot",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("end_at__gt", models.F("start_at"))),
|
||||
name="timetable_slot_end_after_start",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1,102 @@
|
||||
# Create your models here.
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.validators import MaxValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
from core.utils import get_semester_code
|
||||
|
||||
|
||||
class Timetable(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("user"),
|
||||
related_name="timetables",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
semester = models.CharField(
|
||||
_("semester"),
|
||||
default=get_semester_code,
|
||||
validators=[RegexValidator(r"(A|P)\d{2}")],
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("timetable")
|
||||
verbose_name_plural = _("timetables")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "semester"), name="timetable_unique_user_semester"
|
||||
)
|
||||
]
|
||||
permissions = [("add_self_timetable", "Can add its own timetable")]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.semester}"
|
||||
|
||||
|
||||
class TimetableSlot(models.Model):
|
||||
MINUTES_PER_SLOT = 15
|
||||
|
||||
class WeekDay(models.IntegerChoices):
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 3
|
||||
THURSDAY = 4
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
SUNDAY = 7
|
||||
|
||||
class WeekGroup(models.TextChoices):
|
||||
A = "A"
|
||||
B = "B"
|
||||
BOTH = ""
|
||||
|
||||
timetable = models.ForeignKey(
|
||||
Timetable,
|
||||
verbose_name=_("timetable"),
|
||||
related_name="slots",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay)
|
||||
# there are 4 * 24 = 96 quarters in a day, and we start counting at 0
|
||||
start_at = models.PositiveSmallIntegerField(
|
||||
_("start at"), validators=[MaxValueValidator(95)]
|
||||
)
|
||||
end_at = models.PositiveSmallIntegerField(
|
||||
_("end at"), validators=[MaxValueValidator(95)]
|
||||
)
|
||||
course_type = models.CharField(_("course type"), max_length=12)
|
||||
room = models.CharField(_("room"), max_length=32)
|
||||
ue = models.CharField(_("ue"), max_length=32)
|
||||
week_group = models.CharField(
|
||||
_("week group"),
|
||||
max_length=1,
|
||||
choices=WeekGroup,
|
||||
default=WeekGroup.BOTH,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("timetable slot")
|
||||
verbose_name_plural = _("timetable slots")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(end_at__gt=F("start_at")),
|
||||
name="timetable_slot_end_after_start",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}"
|
||||
|
||||
@property
|
||||
def start_hour(self):
|
||||
return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT)
|
||||
|
||||
@property
|
||||
def end_hour(self):
|
||||
return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT)
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import Ge, Le
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field
|
||||
|
||||
from timetable.models import Timetable, TimetableSlot
|
||||
|
||||
|
||||
class TimetableSlotSchema(ModelSchema):
|
||||
weekday: TimetableSlot.WeekDay = Field(
|
||||
description=(
|
||||
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
|
||||
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
|
||||
)
|
||||
)
|
||||
start_at: Annotated[int, Ge(0), Le(96)]
|
||||
end_at: Annotated[int, Ge(0), Le(96)]
|
||||
|
||||
class Meta:
|
||||
model = TimetableSlot
|
||||
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
|
||||
|
||||
|
||||
class CreateTimetableSchema(Schema):
|
||||
is_viewable: bool
|
||||
slots: list[TimetableSlotSchema]
|
||||
|
||||
|
||||
class TimetableSchema(ModelSchema):
|
||||
slots: list[TimetableSlotSchema]
|
||||
|
||||
class Meta:
|
||||
model = Timetable
|
||||
fields = ["id", "semester"]
|
||||
+96
-34
@@ -1,4 +1,6 @@
|
||||
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 =
|
||||
@@ -76,17 +78,88 @@ function parseSlots(s: string): TimetableSlot[] {
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("timetableGenerator", () => ({
|
||||
/**
|
||||
* Alpine data that can parse a timetable, save it and export it to png.
|
||||
*/
|
||||
Alpine.data("timetableGenerator", (userId: number) => ({
|
||||
content: DEFAULT_TIMETABLE,
|
||||
error: null as { incorrectRow?: string },
|
||||
displayedWeekdays: [] as WeekDay[],
|
||||
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[]) => ({
|
||||
displayedWeekdays: [] as WeekDay[],
|
||||
startSlot: 0,
|
||||
endSlot: 0,
|
||||
table: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
table: { height: 0, width: 0 },
|
||||
slots: slots,
|
||||
|
||||
colors: {} as Record<string, string>,
|
||||
colorPalette: [
|
||||
@@ -103,18 +176,14 @@ document.addEventListener("alpine:init", () => {
|
||||
"#1b5e20",
|
||||
],
|
||||
|
||||
generate() {
|
||||
try {
|
||||
this.courses = parseSlots(this.content);
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
this.error = { incorrectRow: err?.cause?.row };
|
||||
return;
|
||||
}
|
||||
init() {
|
||||
this.$watch("slots", () => this.display());
|
||||
},
|
||||
|
||||
display() {
|
||||
// color each UE
|
||||
let colorIndex = 0;
|
||||
for (const slot of this.courses) {
|
||||
for (const slot of this.slots) {
|
||||
if (!this.colors[slot.ueCode]) {
|
||||
this.colors[slot.ueCode] =
|
||||
this.colorPalette[colorIndex % this.colorPalette.length];
|
||||
@@ -123,13 +192,13 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
|
||||
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
||||
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
|
||||
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
|
||||
);
|
||||
this.startSlot = this.courses.reduce(
|
||||
this.startSlot = this.slots.reduce(
|
||||
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
||||
25 * 4,
|
||||
);
|
||||
this.endSlot = this.courses.reduce(
|
||||
this.endSlot = this.slots.reduce(
|
||||
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
||||
1,
|
||||
);
|
||||
@@ -138,7 +207,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
getStyle(slot: TimetableSlot) {
|
||||
const hasWeekGroup = slot.weekGroup !== undefined;
|
||||
const hasWeekGroup = !!slot.weekGroup;
|
||||
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
||||
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
||||
return {
|
||||
@@ -150,15 +219,19 @@ document.addEventListener("alpine:init", () => {
|
||||
};
|
||||
},
|
||||
|
||||
getHours(): [string, object][] {
|
||||
/**
|
||||
* Return the hours that should be displayed on this timetable,
|
||||
* with their `top` placing
|
||||
*/
|
||||
getHours(): [string, { top: `${number}px` }][] {
|
||||
let hour: number = Number.parseInt(
|
||||
this.courses
|
||||
this.slots
|
||||
.map((c: TimetableSlot) => c.startHour)
|
||||
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
||||
.split(":")[0],
|
||||
10,
|
||||
);
|
||||
const res: [string, object][] = [];
|
||||
const res: [string, { top: `${number}px` }][] = [];
|
||||
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
||||
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
||||
hour += 1;
|
||||
@@ -169,16 +242,5 @@ 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: $white-color;
|
||||
background-color: var(--bg-white);
|
||||
font-weight: bold;
|
||||
box-shadow: none;
|
||||
width: calc(100% - var(--hour-side-width) - 10px);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{% extends 'core/base.jinja' %}
|
||||
{% 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/generator-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
@@ -32,7 +34,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="timetableGenerator">
|
||||
<div x-data="timetableGenerator({{ user.id }})">
|
||||
<form @submit.prevent="generate()">
|
||||
<h1>{% trans %}Timetable generator{% endtrans %}</h1>
|
||||
<template x-if="error !== null" x-cloak>
|
||||
@@ -60,44 +62,40 @@
|
||||
</div>
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
||||
</form>
|
||||
<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>
|
||||
{{ 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>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{# HTML+AlpineJS to display a timetable
|
||||
|
||||
Args:
|
||||
ues: either a JS variable name or an array of object
|
||||
satisfying the TimetableSlot interface
|
||||
reactive: if True and `ues` is a variable used in an Alpine context,
|
||||
then this timetable will react to modifications and
|
||||
change its own display
|
||||
#}
|
||||
{% macro display_timetable(ues) %}
|
||||
<div
|
||||
x-data="timetableDisplay({{ ues }})"
|
||||
x-model="{{ ues }}"
|
||||
x-modelable="slots"
|
||||
class="timetable"
|
||||
x-show="slots.length > 0"
|
||||
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
|
||||
>
|
||||
<div class="header">
|
||||
<template x-for="weekday in displayedWeekdays">
|
||||
<span x-text="weekday"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
|
||||
<template x-for="[hour, style] in getHours()">
|
||||
<div class="hour" :style="style">
|
||||
<div x-text="hour"></div>
|
||||
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="courses">
|
||||
<template x-for="course in slots">
|
||||
<div class="slot" :style="getStyle(course)">
|
||||
<span class="course-type" x-text="course.courseType"></span>
|
||||
<span x-text="course.ueCode"></span>
|
||||
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
|
||||
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
|
||||
<span x-text="course.room"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% from "timetable/macros.jinja" import display_timetable %}
|
||||
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{% block content %}
|
||||
{% for timetable in timetables %}
|
||||
<details
|
||||
class="accordion"
|
||||
name="{{ timetable.semester }}"
|
||||
{% if loop.first %}open{% endif %}
|
||||
>
|
||||
<summary>{{ timetable.semester }}</summary>
|
||||
<div class="accordion-content">
|
||||
{{ display_timetable(timetable.semester) }}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{%- block script -%}
|
||||
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
|
||||
<script>
|
||||
{%- for timetable in timetables -%}
|
||||
const {{ timetable.semester }} = [
|
||||
{%- for slot in timetable.slots.all() -%}
|
||||
{
|
||||
courseType: "{{ slot.course_type }}",
|
||||
room: "{{ slot.room }}",
|
||||
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
|
||||
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
|
||||
startSlot: {{ slot.start_at }},
|
||||
endSlot: {{ slot.end_at }},
|
||||
ueCode: "{{ slot.ue }}",
|
||||
{%- if slot.week_group -%}
|
||||
weekGroup: "{{ slot.week_group }}",
|
||||
{%- endif -%}
|
||||
weekday: "{{ weekdays[slot.weekday] }}"
|
||||
},
|
||||
{%- endfor -%}
|
||||
];
|
||||
{%- endfor -%}
|
||||
</script>
|
||||
{%- endblock -%}
|
||||
@@ -1 +0,0 @@
|
||||
# Create your tests here.
|
||||
Whitespace-only changes.
@@ -0,0 +1,20 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from timetable.models import TimetableSlot
|
||||
|
||||
|
||||
class TestSaveTimetable(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls) -> None:
|
||||
cls.user = subscriber_user.make()
|
||||
cls.payload = {
|
||||
"is_viewable": True,
|
||||
"slots": [
|
||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
|
||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
|
||||
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
|
||||
],
|
||||
}
|
||||
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from core.models import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test import Client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("can_save", [True, False])
|
||||
def test_generator_ok(client: Client, can_save):
|
||||
user = baker.make(User)
|
||||
if can_save:
|
||||
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
|
||||
client.force_login(user)
|
||||
|
||||
res = client.get(reverse("timetable:generator"))
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
elem = soup.find(id="timetable-save")
|
||||
|
||||
if can_save:
|
||||
assert elem is not None
|
||||
else:
|
||||
assert elem is None
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from timetable.views import GeneratorView
|
||||
from timetable.views import GeneratorView, UserTimetableView
|
||||
|
||||
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
|
||||
urlpatterns = [
|
||||
path("", GeneratorView.as_view(), name="generator"),
|
||||
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
|
||||
]
|
||||
+50
-2
@@ -1,6 +1,54 @@
|
||||
# Create your views here.
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.detail import DetailView
|
||||
|
||||
from core.models import User
|
||||
from core.views import TabedViewMixin
|
||||
|
||||
|
||||
class GeneratorView(TemplateView):
|
||||
class TimetableTabsMixin(TabedViewMixin):
|
||||
tabs_title = _("Timetables")
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
res = [
|
||||
{
|
||||
"url": reverse("timetable:generator"),
|
||||
"slug": "generator",
|
||||
"name": _("Generator"),
|
||||
}
|
||||
]
|
||||
if self.request.user.timetables.exists():
|
||||
res.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"core:user_timetable", kwargs={"user_id": self.request.user.id}
|
||||
),
|
||||
"slug": "saved",
|
||||
"name": _("My timetables"),
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
class GeneratorView(TimetableTabsMixin, TemplateView):
|
||||
template_name = "timetable/generator.jinja"
|
||||
current_tab = "generator"
|
||||
|
||||
|
||||
class UserTimetableView(UserPassesTestMixin, TimetableTabsMixin, DetailView):
|
||||
model = User
|
||||
pk_url_kwarg = "user_id"
|
||||
template_name = "timetable/user_timetable.jinja"
|
||||
current_tab = "saved"
|
||||
|
||||
def test_func(self) -> bool | None:
|
||||
return self.request.user.id == self.kwargs[
|
||||
"user_id"
|
||||
] or self.request.user.has_perm("timetable.view_timetable")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"timetables": list(self.object.timetables.prefetch_related("slots"))
|
||||
}
|
||||
Reference in new issue
Block a user