Merge pull request #998 from ae-utbm/simpler-com

Rework news creation form
This commit is contained in:
thomas girod 2025-01-11 20:47:21 +01:00 committed by GitHub
commit 4d0d7adce1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 878 additions and 768 deletions

View File

@ -13,17 +13,25 @@
# #
# #
from django.contrib import admin from django.contrib import admin
from django.contrib.admin import TabularInline
from haystack.admin import SearchModelAdmin from haystack.admin import SearchModelAdmin
from com.models import News, Poster, Screen, Sith, Weekmail from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail
class NewsDateInline(TabularInline):
model = NewsDate
extra = 0
@admin.register(News) @admin.register(News)
class NewsAdmin(SearchModelAdmin): class NewsAdmin(SearchModelAdmin):
list_display = ("title", "type", "club", "author") list_display = ("title", "club", "author")
search_fields = ("title", "summary", "content") search_fields = ("title", "summary", "content")
autocomplete_fields = ("author", "moderator") autocomplete_fields = ("author", "moderator")
inlines = [NewsDateInline]
@admin.register(Poster) @admin.register(Poster)
class PosterAdmin(SearchModelAdmin): class PosterAdmin(SearchModelAdmin):

193
com/forms.py Normal file
View File

@ -0,0 +1,193 @@
from datetime import date
from dateutil.relativedelta import relativedelta
from django import forms
from django.db.models import Exists, OuterRef
from django.forms import CheckboxInput
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from club.models import Club
from club.widgets.select import AutoCompleteSelectClub
from com.models import News, NewsDate, Poster
from core.models import User
from core.utils import get_end_of_semester
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
class PosterForm(forms.ModelForm):
class Meta:
model = Poster
fields = [
"name",
"file",
"club",
"screens",
"date_begin",
"date_end",
"display_time",
]
widgets = {"screens": forms.CheckboxSelectMultiple}
help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
date_begin = forms.DateTimeField(
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class NewsDateForm(forms.ModelForm):
"""Form to select the dates of an event."""
required_css_class = "required"
class Meta:
model = NewsDate
fields = ["start_date", "end_date"]
widgets = {"start_date": SelectDateTime, "end_date": SelectDateTime}
is_weekly = forms.BooleanField(
label=_("Weekly event"),
help_text=_("Weekly events will occur each week for a specified timespan."),
widget=CheckboxInput(attrs={"class": "switch"}),
initial=False,
required=False,
)
occurrence_choices = [
*[(str(i), _("%d times") % i) for i in range(2, 7)],
("SEMESTER_END", _("Until the end of the semester")),
]
occurrences = forms.ChoiceField(
label=_("Occurrences"),
help_text=_("How much times should the event occur (including the first one)"),
choices=occurrence_choices,
initial="SEMESTER_END",
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.label_suffix = ""
@classmethod
def get_occurrences(cls, number: int) -> tuple[str, str] | None:
"""Find the occurrence choice corresponding to numeric number of occurrences."""
if number < 2:
# If only 0 or 1 date, there cannot be weekly events
return None
# occurrences have all a numeric value, except "SEMESTER_END"
str_num = str(number)
occurrences = next((c for c in cls.occurrence_choices if c[0] == str_num), None)
if occurrences:
return occurrences
return next((c for c in cls.occurrence_choices if c[0] == "SEMESTER_END"), None)
def save(self, commit: bool = True, *, news: News): # noqa FBT001
# the base save method contains some checks we want to run
# before doing our own logic
super().save(commit=False)
# delete existing dates before creating new ones
news.dates.all().delete()
if not self.cleaned_data.get("is_weekly"):
self.instance.news = news
return super().save(commit=commit)
dates: list[NewsDate] = [self.instance]
occurrences = self.cleaned_data.get("occurrences")
start = self.instance.start_date
end = self.instance.end_date
if occurrences[0].isdigit():
nb_occurrences = int(occurrences[0])
else: # to the end of the semester
start_date = date(start.year, start.month, start.day)
nb_occurrences = (get_end_of_semester(start_date) - start_date).days // 7
dates.extend(
[
NewsDate(
start_date=start + relativedelta(weeks=i),
end_date=end + relativedelta(weeks=i),
)
for i in range(1, nb_occurrences)
]
)
for d in dates:
d.news = news
if not commit:
return dates
return NewsDate.objects.bulk_create(dates)
class NewsForm(forms.ModelForm):
"""Form to create or edit news."""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = News
fields = ["title", "club", "summary", "content"]
widgets = {
"author": forms.HiddenInput,
"summary": MarkdownInput,
"content": MarkdownInput,
}
auto_moderate = forms.BooleanField(
label=_("Automoderation"),
widget=CheckboxInput(attrs={"class": "switch"}),
required=False,
)
def __init__(self, *args, author: User, date_form: NewsDateForm, **kwargs):
super().__init__(*args, **kwargs)
self.author = author
self.date_form = date_form
self.label_suffix = ""
# if the author is an admin, he/she can choose any club,
# otherwise, only clubs for which he/she is a board member can be selected
if author.is_root or author.is_com_admin:
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.all(), widget=AutoCompleteSelectClub
)
else:
active_memberships = author.memberships.board().ongoing()
self.fields["club"] = forms.ModelChoiceField(
queryset=Club.objects.filter(
Exists(active_memberships.filter(club=OuterRef("pk")))
)
)
def is_valid(self):
return super().is_valid() and self.date_form.is_valid()
def full_clean(self):
super().full_clean()
self.date_form.full_clean()
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
if (self.author.is_com_admin or self.author.is_root) and (
self.cleaned_data.get("auto_moderate") is True
):
self.instance.is_moderated = True
self.instance.moderator = self.author
else:
self.instance.is_moderated = False
created_news = super().save(commit=commit)
self.date_form.save(commit=commit, news=created_news)
return created_news

View File

@ -0,0 +1,61 @@
# Generated by Django 4.2.17 on 2025-01-06 21:52
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("com", "0007_alter_news_club_alter_news_content_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="news",
options={
"verbose_name": "news",
"permissions": [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
],
},
),
migrations.AlterModelOptions(
name="newsdate",
options={"verbose_name": "news date", "verbose_name_plural": "news dates"},
),
migrations.AlterModelOptions(
name="poster",
options={"permissions": [("moderate_poster", "Can moderate poster")]},
),
migrations.RemoveField(model_name="news", name="type"),
migrations.AlterField(
model_name="news",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_news",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="newsdate",
name="end_date",
field=models.DateTimeField(verbose_name="end_date"),
),
migrations.AlterField(
model_name="newsdate",
name="start_date",
field=models.DateTimeField(verbose_name="start_date"),
),
migrations.AddConstraint(
model_name="newsdate",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
name="news_date_end_date_after_start_date",
),
),
]

View File

@ -21,13 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from typing import Self
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q from django.db.models import F, Q
from django.shortcuts import render from django.shortcuts import render
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
@ -54,12 +54,21 @@ class Sith(models.Model):
return user.is_com_admin return user.is_com_admin
NEWS_TYPES = [ class NewsQuerySet(models.QuerySet):
("NOTICE", _("Notice")), def moderated(self) -> Self:
("EVENT", _("Event")), return self.filter(is_moderated=True)
("WEEKLY", _("Weekly")),
("CALL", _("Call")), def viewable_by(self, user: User) -> Self:
] """Filter news that the given user can view.
If the user has the `com.view_unmoderated_news` permission,
all news are viewable.
Else the viewable news are those that are either moderated
or authored by the user.
"""
if user.has_perm("com.view_unmoderated_news"):
return self
return self.filter(Q(is_moderated=True) | Q(author_id=user.id))
class News(models.Model): class News(models.Model):
@ -79,9 +88,6 @@ class News(models.Model):
default="", default="",
help_text=_("A more detailed and exhaustive description of the event."), help_text=_("A more detailed and exhaustive description of the event."),
) )
type = models.CharField(
_("type"), max_length=16, choices=NEWS_TYPES, default="EVENT"
)
club = models.ForeignKey( club = models.ForeignKey(
Club, Club,
related_name="news", related_name="news",
@ -93,7 +99,7 @@ class News(models.Model):
User, User,
related_name="owned_news", related_name="owned_news",
verbose_name=_("author"), verbose_name=_("author"),
on_delete=models.CASCADE, on_delete=models.PROTECT,
) )
is_moderated = models.BooleanField(_("is moderated"), default=False) is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey( moderator = models.ForeignKey(
@ -104,19 +110,27 @@ class News(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
objects = NewsQuerySet.as_manager()
class Meta:
verbose_name = _("news")
permissions = [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
]
def __str__(self): def __str__(self):
return "%s: %s" % (self.type, self.title) return self.title
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.is_moderated:
return
for user in User.objects.filter( for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID] groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
): ):
Notification.objects.create( Notification.objects.create(
user=user, user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
) )
def get_absolute_url(self): def get_absolute_url(self):
@ -130,35 +144,31 @@ class News(models.Model):
return False return False
return user.is_com_admin or user == self.author return user.is_com_admin or user == self.author
def can_be_edited_by(self, user): def can_be_edited_by(self, user: User):
return user.is_com_admin return user.is_authenticated and (
self.author_id == user.id or user.has_perm("com.change_news")
)
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):
return self.is_moderated or user.is_com_admin return self.is_moderated or user.has_perm("com.view_unmoderated_news")
def news_notification_callback(notif): def news_notification_callback(notif):
count = ( count = News.objects.filter(
News.objects.filter( dates__start_date__gt=timezone.now(), is_moderated=False
Q(dates__start_date__gt=timezone.now(), is_moderated=False) ).count()
| Q(type="NOTICE", is_moderated=False)
)
.distinct()
.count()
)
if count: if count:
notif.viewed = False notif.viewed = False
notif.param = "%s" % count notif.param = str(count)
notif.date = timezone.now() notif.date = timezone.now()
else: else:
notif.viewed = True notif.viewed = True
class NewsDate(models.Model): class NewsDate(models.Model):
"""A date class, useful for weekly events, or for events that just have no date. """A date associated with news.
This class allows more flexibilty managing the dates related to a news, particularly when this news is weekly, since A [News][] can have multiple dates, for example if it is a recurring event.
we don't have to make copies
""" """
news = models.ForeignKey( news = models.ForeignKey(
@ -167,11 +177,21 @@ class NewsDate(models.Model):
verbose_name=_("news_date"), verbose_name=_("news_date"),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
start_date = models.DateTimeField(_("start_date"), null=True, blank=True) start_date = models.DateTimeField(_("start_date"))
end_date = models.DateTimeField(_("end_date"), null=True, blank=True) end_date = models.DateTimeField(_("end_date"))
class Meta:
verbose_name = _("news date")
verbose_name_plural = _("news dates")
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")),
name="news_date_end_date_after_start_date",
)
]
def __str__(self): def __str__(self):
return "%s: %s - %s" % (self.news.title, self.start_date, self.end_date) return f"{self.news.title}: {self.start_date} - {self.end_date}"
class Weekmail(models.Model): class Weekmail(models.Model):
@ -330,6 +350,9 @@ class Poster(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
class Meta:
permissions = [("moderate_poster", "Can moderate poster")]
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -10,78 +10,13 @@
<p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p> <p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
<hr />
<h4>{% trans %}Notices{% endtrans %}</h4>
{% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %}
<h5>{% trans %}Displayed notices{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Notices to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr /> <hr />
<h4>{% trans %}Weeklies{% endtrans %}</h4> <h4>{% trans %}Weeklies{% endtrans %}</h4>
{% set weeklies = object_list.filter(type="WEEKLY", dates__end_date__gte=timezone.now()).distinct().order_by('id') %} {% set weeklies = object_list.filter(dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed weeklies{% endtrans %}</h5> <h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -94,7 +29,6 @@
<tbody> <tbody>
{% for news in weeklies.filter(is_moderated=True) %} {% for news in weeklies.filter(is_moderated=True) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -124,7 +58,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -136,7 +69,6 @@
<tbody> <tbody>
{% for news in weeklies.filter(is_moderated=False) %} {% for news in weeklies.filter(is_moderated=False) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -161,91 +93,13 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<hr />
<h4>{% trans %}Calls{% endtrans %}</h4>
{% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed calls{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in calls.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Calls to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in calls.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr /> <hr />
<h4>{% trans %}Events{% endtrans %}</h4> <h4>{% trans %}Events{% endtrans %}</h4>
{% set events = object_list.filter(type="EVENT", dates__end_date__gte=timezone.now()).distinct().order_by('id') %} {% set events = object_list.filter(dates__end_date__gte=timezone.now()).order_by('id') %}
<h5>{% trans %}Displayed events{% endtrans %}</h5> <h5>{% trans %}Displayed events{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -259,16 +113,15 @@
<tbody> <tbody>
{% for news in events.filter(is_moderated=True) %} {% for news in events.filter(is_moderated=True) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td> <td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td> <td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
@ -282,7 +135,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -295,15 +147,14 @@
<tbody> <tbody>
{% for news in events.filter(is_moderated=False) %} {% for news in events.filter(is_moderated=False) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td> <td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>

View File

@ -25,10 +25,10 @@
</div> </div>
<h4>{{ news.title }}</h4> <h4>{{ news.title }}</h4>
<p class="date"> <p class="date">
<span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} <span>{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> - {{ date.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} <span>{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span> {{ date.end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p> </p>
<div class="news_content"> <div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div> <div><em>{{ news.summary|markdown }}</em></div>

View File

@ -10,21 +10,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if 'preview' in request.POST.keys() %}
<section class="news_event">
<h4>{{ form.instance.title }}</h4>
<p class="date">
<span>{{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p>
<p><a href="#">{{ form.instance.club or "Club" }}</a></p>
<div>{{ form.instance.summary|markdown }}</div>
<div>{{ form.instance.content|markdown }}</div>
<p>{% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}</p>
</section>
{% endif %}
{% if object %} {% if object %}
<h2>{% trans %}Edit news{% endtrans %}</h2> <h2>{% trans %}Edit news{% endtrans %}</h2>
{% else %} {% else %}
@ -33,103 +18,73 @@
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
{{ form.non_field_errors() }} {{ form.non_field_errors() }}
{{ form.author }} <fieldset>
<p>
{{ form.type.errors }}
<label for="{{ form.type.name }}" class="required">{{ form.type.label }}</label>
<ul>
<li>{% trans %}Notice: Information, election result - no date{% endtrans %}</li>
<li>{% trans %}Event: punctual event, associated with one date{% endtrans %}</li>
<li>
{% trans trimmed%}
Weekly: recurrent event, associated with many dates
(specify the first one, and a deadline)
{% endtrans %}
</li>
<li>
{% trans trimmed %}
Call: long time event, associated with a long date (like election appliance)
{% endtrans %}
</li>
</ul>
{{ form.type }}
</p>
<p class="date">
{{ form.start_date.errors }}
<label for="{{ form.start_date.name }}">{{ form.start_date.label }}</label>
{{ form.start_date }}
</p>
<p class="date">
{{ form.end_date.errors }}
<label for="{{ form.end_date.name }}">{{ form.end_date.label }}</label>
{{ form.end_date }}
</p>
<p class="until">
{{ form.until.errors }}
<label for="{{ form.until.name }}">{{ form.until.label }}</label>
{{ form.until }}
</p>
<p>
{{ form.title.errors }} {{ form.title.errors }}
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label> {{ form.title.label_tag() }}
{{ form.title }} {{ form.title }}
</p> </fieldset>
<p> <fieldset>
{{ form.club.errors }} {{ form.club.errors }}
<label for="{{ form.club.name }}" class="required">{{ form.club.label }}</label> {{ form.club.label_tag() }}
<span class="helptext">{{ form.club.help_text }}</span> <span class="helptext">{{ form.club.help_text }}</span>
{{ form.club }} {{ form.club }}
</p> </fieldset>
<p> {{ form.date_form.non_field_errors() }}
<div
class="row gap-2x"
x-data="{startDate: '{{ form.date_form.start_date.value() }}'}"
>
{# startDate is used to dynamically ensure end_date >= start_date,
whatever the value of start_date #}
<fieldset>
{{ form.date_form.start_date.errors }}
{{ form.date_form.start_date.label_tag() }}
<span class="helptext">{{ form.date_form.start_date.help_text }}</span>
{{ form.date_form.start_date|add_attr("x-model=startDate") }}
</fieldset>
<fieldset>
{{ form.date_form.end_date.errors }}
{{ form.date_form.end_date.label_tag() }}
<span class="helptext">{{ form.date_form.end_date.help_text }}</span>
{{ form.date_form.end_date|add_attr(":min=startDate") }}
</fieldset>
</div>
{# lower to convert True and False to true and false #}
<div x-data="{isWeekly: {{ form.date_form.is_weekly.value()|lower }}}">
<fieldset>
<div class="row gap">
{{ form.date_form.is_weekly|add_attr("x-model=isWeekly") }}
<div>
{{ form.date_form.is_weekly.label_tag() }}
<span class="helptext">{{ form.date_form.is_weekly.help_text }}</span>
</div>
</div>
</fieldset>
<fieldset x-show="isWeekly" x-transition x-cloak>
{{ form.date_form.occurrences.label_tag() }}
<span class="helptext">{{ form.date_form.occurrences.help_text }}</span>
{{ form.date_form.occurrences }}
</fieldset>
</div>
<fieldset>
{{ form.summary.errors }} {{ form.summary.errors }}
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label> {{ form.summary.label_tag() }}
<span class="helptext">{{ form.summary.help_text }}</span> <span class="helptext">{{ form.summary.help_text }}</span>
{{ form.summary }} {{ form.summary }}
</p> </fieldset>
<p> <fieldset>
{{ form.content.errors }} {{ form.content.errors }}
<label for="{{ form.content.name }}">{{ form.content.label }}</label> {{ form.content.label_tag() }}
<span class="helptext">{{ form.content.help_text }}</span> <span class="helptext">{{ form.content.help_text }}</span>
{{ form.content }} {{ form.content }}
</p> </fieldset>
{% if user.is_com_admin %} {% if user.is_root or user.is_com_admin %}
<p> <fieldset>
{{ form.automoderation.errors }} {{ form.auto_moderate.errors }}
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label> {{ form.auto_moderate }}
{{ form.automoderation }} {{ form.auto_moderate.label_tag() }}
</p> </fieldset>
{% endif %} {% endif %}
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
</form> </form>
{% endblock %} {% endblock %}
{% block script %}
{{ super() }}
<script>
$(function () {
let type = $('input[name=type]');
let dates = $('.date');
let until = $('.until');
function update_targets() {
const type_checked = $('input[name=type]:checked');
if (["CALL", "EVENT"].includes(type_checked.val())) {
dates.show();
until.hide();
} else if (type_checked.val() === "WEEKLY") {
dates.show();
until.show();
} else {
dates.hide();
until.hide();
}
}
update_targets();
type.change(update_targets);
});
</script>
{% endblock %}

View File

@ -15,37 +15,21 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if user.is_com_admin %}
<div id="news_admin">
<a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
</div>
<br>
{% endif %}
<div id="news"> <div id="news">
<div id="left_column" class="news_column"> <div id="left_column" class="news_column">
{% for news in object_list.filter(type="NOTICE") %} {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}
<section class="news_notice">
<h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
<section class="news_call">
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
<h3>{% trans %}Events today and the next few days{% endtrans %}</h3> <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
{% endif %}
{% if user.is_com_admin %}
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
<br>
{% endif %}
{% if events_dates %} {% if events_dates %}
{% for d in events_dates %} {% for d in events_dates %}
<div class="news_events_group"> <div class="news_events_group">
@ -57,10 +41,7 @@
</div> </div>
</div> </div>
<div class="news_events_group_items"> <div class="news_events_group_items">
{% for news in object_list.filter(dates__start_date__gte=d, {% for news in object_list.filter(dates__start_date__gte=d,dates__start_date__lte=d+timedelta(days=1)).exclude(dates__end_date__lt=timezone.now()).order_by('dates__start_date') %}
dates__start_date__lte=d+timedelta(days=1),
type="EVENT").exclude(dates__end_date__lt=timezone.now())
.order_by('dates__start_date') %}
<section class="news_event"> <section class="news_event">
<div class="club_logo"> <div class="club_logo">
{% if news.club.logo %} {% if news.club.logo %}
@ -86,20 +67,17 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="news_empty"> <div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em> <em>{% trans %}Nothing to come...{% endtrans %}</em>
</div> </div>
{% endif %} {% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<h3>{% trans %}All coming events{% endtrans %}</h3> <div id="right_column">
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<div id="right_column">
<div id="links"> <div id="links">
<h3>{% trans %}Links{% endtrans %}</h3> <h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content"> <div id="links_content">
@ -161,9 +139,6 @@
{%- endif -%} {%- endif -%}
</div> </div>
</div> </div>
</div>
</div> </div>
</div>
{% endblock %} {% endblock %}

42
com/tests/test_models.py Normal file
View File

@ -0,0 +1,42 @@
import itertools
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from com.models import News
from core.models import User
class TestNewsViewableBy(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
cls.users = baker.make(User, _quantity=3, _bulk_create=True)
# There are six news and six authors.
# Each author has one moderated and one non-moderated news
cls.news = baker.make(
News,
author=itertools.cycle(cls.users),
is_moderated=iter([True, True, True, False, False, False]),
_quantity=6,
_bulk_create=True,
)
def test_admin_can_view_everything(self):
"""Test with a user that can view non moderated news."""
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
)
assert set(News.objects.viewable_by(user)) == set(self.news)
def test_normal_user_can_view_moderated_and_self_news(self):
"""Test that basic users can view moderated news and news they authored."""
user = self.news[0].author
assert set(News.objects.viewable_by(user)) == {
self.news[0],
self.news[1],
self.news[2],
self.news[3],
}

View File

@ -12,6 +12,9 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import timedelta
from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
@ -20,9 +23,12 @@ from django.urls import reverse
from django.utils import html from django.utils import html
from django.utils.timezone import localtime, now from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@ -137,15 +143,8 @@ class TestNews(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity") cls.com_admin = User.objects.get(username="comunity")
new = News.objects.create( cls.new = baker.make(News)
title="dummy new", cls.author = cls.new.author
summary="This is a dummy new",
content="Look at that beautiful dummy new",
author=User.objects.get(username="subscriber"),
club=Club.objects.first(),
)
cls.new = new
cls.author = new.author
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.anonymous = AnonymousUser() cls.anonymous = AnonymousUser()
@ -176,11 +175,11 @@ class TestNews(TestCase):
assert self.new.can_be_viewed_by(self.author) assert self.new.can_be_viewed_by(self.author)
def test_news_editor(self): def test_news_editor(self):
"""Test that only com admins can edit news.""" """Test that only com admins and the original author can edit news."""
assert self.new.can_be_edited_by(self.com_admin) assert self.new.can_be_edited_by(self.com_admin)
assert self.new.can_be_edited_by(self.author)
assert not self.new.can_be_edited_by(self.sli) assert not self.new.can_be_edited_by(self.sli)
assert not self.new.can_be_edited_by(self.anonymous) assert not self.new.can_be_edited_by(self.anonymous)
assert not self.new.can_be_edited_by(self.author)
class TestWeekmailArticle(TestCase): class TestWeekmailArticle(TestCase):
@ -230,3 +229,93 @@ class TestPoster(TestCase):
assert not self.poster.is_owned_by(self.susbcriber) assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli) assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
def setUp(self):
self.client.force_login(self.user)
self.start = now() + timedelta(days=1)
self.end = self.start + timedelta(hours=5)
self.valid_payload = {
"title": "Test news",
"summary": "This is a test news",
"content": "This is a test news",
"club": self.club.pk,
"is_weekly": False,
"start_date": self.start,
"end_date": self.end,
}
def test_create_news(self):
response = self.client.post(reverse("com:news_new"), self.valid_payload)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_moderated
dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}]
def test_create_news_multiple_dates(self):
self.valid_payload["is_weekly"] = True
self.valid_payload["occurrences"] = 2
response = self.client.post(reverse("com:news_new"), self.valid_payload)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
dates = list(
created.dates.values("start_date", "end_date").order_by("start_date")
)
assert dates == [
{"start_date": self.start, "end_date": self.end},
{
"start_date": self.start + timedelta(days=7),
"end_date": self.end + timedelta(days=7),
},
]
def test_edit_news(self):
news = baker.make(News, author=self.user, is_moderated=True)
baker.make(
NewsDate,
news=news,
start_date=self.start + timedelta(hours=1),
end_date=self.end + timedelta(hours=1),
_quantity=2,
)
response = self.client.post(
reverse("com:news_edit", kwargs={"news_id": news.id}), self.valid_payload
)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_moderated
dates = list(created.dates.values("start_date", "end_date"))
assert dates == [{"start_date": self.start, "end_date": self.end}]
def test_ics_updated(self):
"""Test that the internal ICS is updated when news are created"""
# we will just test that the ICS is modified.
# Checking that the ICS is *well* modified is up to the ICS tests
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
self.client.post(reverse("com:news_new"), self.valid_payload)
mocked.assert_called()
# The ICS file should also change after an update
self.valid_payload["is_weekly"] = True
self.valid_payload["occurrences"] = 2
last_news = News.objects.order_by("id").last()
with patch("com.calendar.IcsCalendar.make_internal") as mocked:
self.client.post(
reverse("com:news_edit", kwargs={"news_id": last_news.id}),
self.valid_payload,
)
mocked.assert_called()

View File

@ -25,9 +25,9 @@ from com.views import (
NewsCreateView, NewsCreateView,
NewsDeleteView, NewsDeleteView,
NewsDetailView, NewsDetailView,
NewsEditView,
NewsListView, NewsListView,
NewsModerateView, NewsModerateView,
NewsUpdateView,
PosterCreateView, PosterCreateView,
PosterDeleteView, PosterDeleteView,
PosterEditView, PosterEditView,
@ -75,11 +75,11 @@ urlpatterns = [
path("news/", NewsListView.as_view(), name="news_list"), path("news/", NewsListView.as_view(), name="news_list"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"), path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"), path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"), path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
path( path(
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate" "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
), ),
path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"), path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"), path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path( path(

View File

@ -24,11 +24,15 @@
import itertools import itertools
from datetime import timedelta from datetime import timedelta
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from typing import Any
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import (
AccessMixin,
PermissionRequiredMixin,
)
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, Max, OuterRef from django.db.models import Max
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@ -37,21 +41,19 @@ from django.utils import timezone
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.models import Notification, User from core.models import User
from core.views import ( from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
QuickNotifMixin, QuickNotifMixin,
TabedViewMixin, TabedViewMixin,
) )
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@ -59,92 +61,47 @@ from core.views.widgets.markdown import MarkdownInput
sith = Sith.objects.first sith = Sith.objects.first
class PosterForm(forms.ModelForm):
class Meta:
model = Poster
fields = [
"name",
"file",
"club",
"screens",
"date_begin",
"date_end",
"display_time",
]
widgets = {"screens": forms.CheckboxSelectMultiple}
help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
date_begin = forms.DateTimeField(
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class ComTabsMixin(TabedViewMixin): class ComTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
return _("Communication administration") return _("Communication administration")
def get_list_of_tabs(self): def get_list_of_tabs(self):
tab_list = [] return [
tab_list.append( {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
)
tab_list.append(
{ {
"url": reverse("com:weekmail_destinations"), "url": reverse("com:weekmail_destinations"),
"slug": "weekmail_destinations", "slug": "weekmail_destinations",
"name": _("Weekmail destinations"), "name": _("Weekmail destinations"),
} },
) {
tab_list.append( "url": reverse("com:info_edit"),
{"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")} "slug": "info",
) "name": _("Info message"),
tab_list.append( },
{ {
"url": reverse("com:alert_edit"), "url": reverse("com:alert_edit"),
"slug": "alert", "slug": "alert",
"name": _("Alert message"), "name": _("Alert message"),
} },
)
tab_list.append(
{ {
"url": reverse("com:mailing_admin"), "url": reverse("com:mailing_admin"),
"slug": "mailings", "slug": "mailings",
"name": _("Mailing lists administration"), "name": _("Mailing lists administration"),
} },
)
tab_list.append(
{ {
"url": reverse("com:poster_list"), "url": reverse("com:poster_list"),
"slug": "posters", "slug": "posters",
"name": _("Posters list"), "name": _("Posters list"),
} },
)
tab_list.append(
{ {
"url": reverse("com:screen_list"), "url": reverse("com:screen_list"),
"slug": "screens", "slug": "screens",
"name": _("Screens list"), "name": _("Screens list"),
} },
) ]
return tab_list
class IsComAdminMixin(View): class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin: if not request.user.is_com_admin:
raise PermissionDenied raise PermissionDenied
@ -184,167 +141,86 @@ class WeekmailDestinationEditView(ComEditView):
# News # News
class NewsForm(forms.ModelForm): class NewsCreateView(PermissionRequiredMixin, CreateView):
class Meta: """View to either create or update News."""
model = News model = News
fields = ["type", "title", "club", "summary", "content", "author"] form_class = NewsForm
widgets = { template_name = "com/news_edit.jinja"
"author": forms.HiddenInput, permission_required = "com.add_news"
"type": forms.RadioSelect,
"summary": MarkdownInput, def get_date_form_kwargs(self) -> dict[str, Any]:
"content": MarkdownInput, """Get initial data for NewsDateForm"""
if self.request.method == "POST":
return {"data": self.request.POST}
return {}
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"author": self.request.user,
"date_form": NewsDateForm(**self.get_date_form_kwargs()),
} }
start_date = forms.DateTimeField( def get_initial(self):
label=_("Start date"), widget=SelectDateTime, required=False init = super().get_initial()
) # if the id of a club is provided, select it by default
end_date = forms.DateTimeField( if club_id := self.request.GET.get("club"):
label=_("End date"), widget=SelectDateTime, required=False init["club"] = Club.objects.filter(id=club_id).first()
) return init
until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
def clean(self):
self.cleaned_data = super().clean()
if self.cleaned_data["type"] != "NOTICE":
if not self.cleaned_data["start_date"]:
self.add_error(
"start_date", ValidationError(_("This field is required."))
)
if not self.cleaned_data["end_date"]:
self.add_error(
"end_date", ValidationError(_("This field is required."))
)
if (
not self.has_error("start_date")
and not self.has_error("end_date")
and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
):
self.add_error(
"end_date",
ValidationError(_("An event cannot end before its beginning.")),
)
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data
def save(self, *args, **kwargs):
ret = super().save()
self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL":
NewsDate(
start_date=self.cleaned_data["start_date"],
end_date=self.cleaned_data["end_date"],
news=self.instance,
).save()
elif self.instance.type == "WEEKLY":
start_date = self.cleaned_data["start_date"]
end_date = self.cleaned_data["end_date"]
while start_date <= self.cleaned_data["until"]:
NewsDate(
start_date=start_date, end_date=end_date, news=self.instance
).save()
start_date += timedelta(days=7)
end_date += timedelta(days=7)
return ret
class NewsEditView(CanEditMixin, UpdateView): class NewsUpdateView(UpdateView):
model = News model = News
form_class = NewsForm form_class = NewsForm
template_name = "com/news_edit.jinja" template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
def get_initial(self): def dispatch(self, request, *args, **kwargs):
news_date: NewsDate = self.object.dates.order_by("id").first() if (
if news_date is None: not request.user.has_perm("com.edit_news")
return {"start_date": None, "end_date": None} and self.get_object().author != request.user
return {"start_date": news_date.start_date, "end_date": news_date.end_date} ):
raise PermissionDenied
def post(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs)
form = self.get_form()
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form): def form_valid(self, form):
self.object = form.save() self.object = form.save()
if form.cleaned_data["automoderation"] and self.request.user.is_com_admin: IcsCalendar.make_internal()
self.object.moderator = self.request.user
self.object.is_moderated = True
self.object.save()
else:
self.object.is_moderated = False
self.object.save()
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
Notification.objects.create(
user=user,
url=self.object.get_absolute_url(),
type="NEWS_MODERATION",
)
return super().form_valid(form) return super().form_valid(form)
def get_date_form_kwargs(self) -> dict[str, Any]:
"""Get initial data for NewsDateForm"""
response = {}
if self.request.method == "POST":
response["data"] = self.request.POST
dates = list(self.object.dates.order_by("id"))
if len(dates) == 0:
return {}
response["instance"] = dates[0]
occurrences = NewsDateForm.get_occurrences(len(dates))
if occurrences is not None:
response["initial"] = {"is_weekly": True, "occurrences": occurrences}
return response
class NewsCreateView(CanCreateMixin, CreateView): def get_form_kwargs(self):
model = News return super().get_form_kwargs() | {
form_class = NewsForm "author": self.request.user,
template_name = "com/news_edit.jinja" "date_form": NewsDateForm(**self.get_date_form_kwargs()),
}
def get_initial(self):
init = {"author": self.request.user}
if "club" not in self.request.GET:
return init
init["club"] = Club.objects.filter(id=self.request.GET["club"]).first()
return init
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
self.object = form.instance
return self.form_invalid(form)
def form_valid(self, form):
self.object = form.save()
if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
self.object.moderator = self.request.user
self.object.is_moderated = True
self.object.save()
else:
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
Notification.objects.create(
user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
)
return super().form_valid(form)
class NewsDeleteView(CanEditMixin, DeleteView): class NewsDeleteView(PermissionRequiredMixin, DeleteView):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:news_admin_list") success_url = reverse_lazy("com:news_admin_list")
permission_required = "com.delete_news"
class NewsModerateView(CanEditMixin, SingleObjectMixin): class NewsModerateView(PermissionRequiredMixin, DetailView):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
@ -359,17 +235,23 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
return redirect("com:news_admin_list") return redirect("com:news_admin_list")
class NewsAdminListView(CanEditMixin, ListView): class NewsAdminListView(PermissionRequiredMixin, ListView):
model = News model = News
template_name = "com/news_admin_list.jinja" template_name = "com/news_admin_list.jinja"
queryset = News.objects.all() queryset = News.objects.select_related(
"club", "author", "moderator"
).prefetch_related("dates")
permission_required = ["com.moderate_news", "com.delete_news"]
class NewsListView(CanViewMixin, ListView): class NewsListView(ListView):
model = News model = News
template_name = "com/news_list.jinja" template_name = "com/news_list.jinja"
queryset = News.objects.filter(is_moderated=True) queryset = News.objects.filter(is_moderated=True)
def get_queryset(self):
return super().get_queryset().viewable_by(self.request.user)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["NewsDate"] = NewsDate kwargs["NewsDate"] = NewsDate
@ -390,6 +272,10 @@ class NewsDetailView(CanViewMixin, DetailView):
model = News model = News
template_name = "com/news_detail.jinja" template_name = "com/news_detail.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
# Weekmail # Weekmail

View File

@ -678,7 +678,6 @@ Welcome to the wiki page!
title="Apero barman", title="Apero barman",
summary="Viens boire un coup avec les barmans", summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -698,7 +697,6 @@ Welcome to the wiki page!
"Viens donc t'enjailler avec les autres barmans aux " "Viens donc t'enjailler avec les autres barmans aux "
"frais du BdF! \\o/" "frais du BdF! \\o/"
), ),
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -715,7 +713,6 @@ Welcome to the wiki page!
title="Repas fromager", title="Repas fromager",
summary="Wien manger du l'bon fromeug'", summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -732,7 +729,6 @@ Welcome to the wiki page!
title="SdF", title="SdF",
summary="Enjoy la fin des finaux!", summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -751,7 +747,6 @@ Welcome to the wiki page!
summary="Viens jouer!", summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens " content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
type="WEEKLY",
club=troll, club=troll,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -899,6 +894,7 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(*list(perms.filter(codename__in=["add_news"])))
old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(

View File

@ -665,7 +665,9 @@ form {
} }
&:checked { &:checked {
background: var(--nf-input-focus-border-color) none initial; background: none;
background-position: 0 0;
background-color: var(--nf-input-focus-border-color);
&::after { &::after {
transform: translateY(-50%) translateX( transform: translateY(-50%) translateX(

View File

@ -436,8 +436,8 @@ body {
$row-gap: 0.5rem; $row-gap: 0.5rem;
&.gap { &.gap {
column-gap: var($col-gap); column-gap: $col-gap;
row-gap: var($row-gap); row-gap: $row-gap;
} }
@for $i from 2 through 5 { @for $i from 2 through 5 {

View File

@ -26,6 +26,7 @@ import datetime
import phonenumbers import phonenumbers
from django import template from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ngettext from django.utils.translation import ngettext
@ -80,3 +81,43 @@ def format_timedelta(value: datetime.timedelta) -> str:
return ngettext( return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days "%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
) % {"nb_days": days, "remainder": str(remainder)} ) % {"nb_days": days, "remainder": str(remainder)}
@register.filter(name="add_attr")
def add_attr(field: BoundField, attr: str):
"""Add attributes to a form field directly in the template.
Attributes are `key=value` pairs, separated by commas.
Example:
```jinja
<form x-data="{alpineField: null}">
{{ form.field|add_attr("x-model=alpineField") }}
</form>
```
will render :
```html
<form x-data="{alpineField: null}">
<input type="..." x-model="alpineField">
</form>
```
Notes:
Doing this gives the same result as setting the attribute
directly in the python code.
However, sometimes there are attributes that are tightly
coupled to the frontend logic (like Alpine variables)
and that shouldn't be declared outside of it.
"""
attrs = {}
definition = attr.split(",")
for d in definition:
if "=" not in d:
attrs["class"] = d
else:
key, val = d.split("=")
attrs[key] = val
return field.as_widget(attrs=attrs)

View File

@ -9,6 +9,7 @@ from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe, foreign_key
from com.models import News
from core.baker_recipes import ( from core.baker_recipes import (
old_subscriber_user, old_subscriber_user,
subscriber_user, subscriber_user,
@ -22,6 +23,8 @@ from eboutic.models import Invoice, InvoiceItem
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,

View File

@ -14,7 +14,7 @@
# #
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
@ -77,6 +77,22 @@ def get_start_of_semester(today: date | None = None) -> date:
return autumn.replace(year=autumn.year - 1) return autumn.replace(year=autumn.year - 1)
def get_end_of_semester(today: date | None = None):
"""Return the date of the end of the semester of the given date.
If no date is given, return the end date of the current semester.
"""
# the algorithm is simple, albeit somewhat imprecise :
# 1. get the start of the next semester
# 2. Remove a month and a half for the autumn semester (summer holidays)
# and 28 days for spring semester (february holidays)
if today is None:
today = localdate()
semester_start = get_start_of_semester(today + timedelta(days=365 // 2))
if semester_start.month == settings.SITH_SEMESTER_START_AUTUMN[0]:
return semester_start - timedelta(days=45)
return semester_start - timedelta(days=28)
def get_semester_code(d: date | None = None) -> str: def get_semester_code(d: date | None = None) -> str:
"""Return the semester code of the given date. """Return the semester code of the given date.
If no date is given, return the semester code of the current semester. If no date is given, return the semester code of the current semester.

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-08 12:23+0100\n" "POT-Creation-Date: 2025-01-10 14:52+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -841,7 +841,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/views.py counter/forms.py election/views.py #: club/forms.py com/forms.py counter/forms.py election/views.py
#: subscription/forms.py #: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@ -1259,6 +1259,46 @@ msgstr "Liste d'affiches"
msgid "Props" msgid "Props"
msgstr "Propriétés" msgstr "Propriétés"
#: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
#: com/forms.py
msgid "Weekly event"
msgstr "Événement Hebdomadaire"
#: com/forms.py
msgid "Weekly events will occur each week for a specified timespan."
msgstr ""
"Les événements hebdomadaires se répéteront chaque semaine pendant une durée "
"déterminée"
#: com/forms.py
#, python-format
msgid "%d times"
msgstr "%d fois"
#: com/forms.py
msgid "Until the end of the semester"
msgstr "Jusqu'à la fin du semestre"
#: com/forms.py
msgid "Occurrences"
msgstr "Occurences"
#: com/forms.py
msgid "How much times should the event occur (including the first one)"
msgstr ""
"Combien de fois l'événement doit-il se répéter (en incluant la première fois)"
#: com/forms.py
msgid "Automoderation"
msgstr "Automodération"
#: com/models.py #: com/models.py
msgid "alert message" msgid "alert message"
msgstr "message d'alerte" msgstr "message d'alerte"
@ -1271,22 +1311,6 @@ msgstr "message d'info"
msgid "weekmail destinations" msgid "weekmail destinations"
msgstr "destinataires du weekmail" msgstr "destinataires du weekmail"
#: com/models.py
msgid "Notice"
msgstr "Information"
#: com/models.py
msgid "Event"
msgstr "Événement"
#: com/models.py
msgid "Weekly"
msgstr "Hebdomadaire"
#: com/models.py
msgid "Call"
msgstr "Appel"
#: com/models.py core/templates/core/macros.jinja election/models.py #: com/models.py core/templates/core/macros.jinja election/models.py
#: forum/models.py pedagogy/models.py #: forum/models.py pedagogy/models.py
msgid "title" msgid "title"
@ -1312,10 +1336,6 @@ msgstr "contenu"
msgid "A more detailed and exhaustive description of the event." msgid "A more detailed and exhaustive description of the event."
msgstr "Une description plus détaillée et exhaustive de l'évènement." msgstr "Une description plus détaillée et exhaustive de l'évènement."
#: com/models.py core/models.py launderette/models.py
msgid "type"
msgstr "type"
#: com/models.py #: com/models.py
msgid "The club which organizes the event." msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement." msgstr "Le club qui organise l'évènement."
@ -1324,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author" msgid "author"
msgstr "auteur" msgstr "auteur"
#: com/models.py
msgid "news"
msgstr "nouvelle"
#: com/models.py #: com/models.py
msgid "news_date" msgid "news_date"
msgstr "date de la nouvelle" msgstr "date de la nouvelle"
@ -1336,6 +1360,14 @@ msgstr "date de début"
msgid "end_date" msgid "end_date"
msgstr "date de fin" msgstr "date de fin"
#: com/models.py
msgid "news date"
msgstr "date de la nouvelle"
#: com/models.py
msgid "news dates"
msgstr "dates de la nouvelle"
#: com/models.py #: com/models.py
msgid "intro" msgid "intro"
msgstr "intro" msgstr "intro"
@ -1420,23 +1452,17 @@ msgid "News"
msgstr "Nouvelles" msgstr "Nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: core/templates/core/user_tools.jinja #: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news" msgid "Create news"
msgstr "Créer nouvelle" msgstr "Créer une nouvelle"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "Notices" msgid "Weeklies"
msgstr "Information" msgstr "Événements hebdomadaires"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "Displayed notices" msgid "Displayed weeklies"
msgstr "Informations affichées" msgstr "Événements hebdomadaires affichées"
#: com/templates/com/news_admin_list.jinja
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja forum/templates/forum/main.jinja #: forum/templates/forum/forum.jinja forum/templates/forum/main.jinja
@ -1457,18 +1483,6 @@ msgstr "Auteur"
msgid "Moderator" msgid "Moderator"
msgstr "Modérateur" msgstr "Modérateur"
#: com/templates/com/news_admin_list.jinja
msgid "Notices to moderate"
msgstr "Informations à modérer"
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies"
msgstr "Nouvelles hebdomadaires"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed weeklies"
msgstr "Nouvelles hebdomadaires affichées"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
msgid "Dates" msgid "Dates"
@ -1478,18 +1492,6 @@ msgstr "Dates"
msgid "Weeklies to moderate" msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer" msgstr "Nouvelles hebdomadaires à modérer"
#: com/templates/com/news_admin_list.jinja
msgid "Calls"
msgstr "Appels"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed calls"
msgstr "Appels affichés"
#: com/templates/com/news_admin_list.jinja
msgid "Calls to moderate"
msgstr "Appels à modérer"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja #: core/templates/core/base/navbar.jinja
msgid "Events" msgid "Events"
@ -1507,7 +1509,7 @@ msgstr "Événements à modérer"
msgid "Back to news" msgid "Back to news"
msgstr "Retour aux nouvelles" msgstr "Retour aux nouvelles"
#: com/templates/com/news_detail.jinja com/templates/com/news_edit.jinja #: com/templates/com/news_detail.jinja
msgid "Author: " msgid "Author: "
msgstr "Auteur : " msgstr "Auteur : "
@ -1523,41 +1525,14 @@ msgstr "Éditer (sera soumise de nouveau à la modération)"
msgid "Edit news" msgid "Edit news"
msgstr "Éditer la nouvelle" msgstr "Éditer la nouvelle"
#: com/templates/com/news_edit.jinja #: com/templates/com/news_list.jinja
msgid "Notice: Information, election result - no date" msgid "Events today and the next few days"
msgstr "Information, résultat d'élection - sans date" msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_edit.jinja
msgid "Event: punctual event, associated with one date"
msgstr "Événement : événement ponctuel associé à une date"
#: com/templates/com/news_edit.jinja
msgid ""
"Weekly: recurrent event, associated with many dates (specify the first one, "
"and a deadline)"
msgstr ""
"Hebdomadaire : événement récurrent, associé à plusieurs dates (spécifier la "
"première, ainsi que la date de fin)"
#: com/templates/com/news_edit.jinja
msgid ""
"Call: long time event, associated with a long date (like election appliance)"
msgstr ""
"Appel : événement de longue durée, associé à une longue date (comme des "
"candidatures à une élection)"
#: com/templates/com/news_edit.jinja com/templates/com/weekmail.jinja
msgid "Preview"
msgstr "Prévisualiser"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Administrate news" msgid "Administrate news"
msgstr "Administrer les news" msgstr "Administrer les news"
#: com/templates/com/news_list.jinja
msgid "Events today and the next few days"
msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Nothing to come..." msgid "Nothing to come..."
msgstr "Rien à venir..." msgstr "Rien à venir..."
@ -1679,6 +1654,10 @@ msgstr "Diaporama"
msgid "Weekmail" msgid "Weekmail"
msgstr "Weekmail" msgstr "Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Preview"
msgstr "Prévisualiser"
#: com/templates/com/weekmail.jinja com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail.jinja com/templates/com/weekmail_preview.jinja
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@ -1768,14 +1747,6 @@ msgstr "Astuce"
msgid "Final word" msgid "Final word"
msgstr "Le mot de la fin" msgstr "Le mot de la fin"
#: com/views.py
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/views.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
#: com/views.py #: com/views.py
msgid "Communication administration" msgid "Communication administration"
msgstr "Administration de la communication" msgstr "Administration de la communication"
@ -1796,22 +1767,6 @@ msgstr "Message d'alerte"
msgid "Screens list" msgid "Screens list"
msgstr "Liste d'écrans" msgstr "Liste d'écrans"
#: com/views.py rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
#: com/views.py
msgid "Automoderation"
msgstr "Automodération"
#: com/views.py
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: com/views.py
msgid "An event cannot end before its beginning."
msgstr "Un évènement ne peut pas se finir avant d'avoir commencé."
#: com/views.py #: com/views.py
msgid "Delete and save to regenerate" msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer" msgstr "Supprimer et sauver pour régénérer"
@ -2215,6 +2170,10 @@ msgstr "url"
msgid "param" msgid "param"
msgstr "param" msgstr "param"
#: core/models.py launderette/models.py
msgid "type"
msgstr "type"
#: core/models.py #: core/models.py
msgid "viewed" msgid "viewed"
msgstr "vue" msgstr "vue"
@ -4734,6 +4693,11 @@ msgstr "Machines"
msgid "New machine" msgid "New machine"
msgstr "Nouvelle machine" msgstr "Nouvelle machine"
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
#: launderette/templates/launderette/launderette_book.jinja #: launderette/templates/launderette/launderette_book.jinja
msgid "Choose" msgid "Choose"
msgstr "Choisir" msgstr "Choisir"
@ -5134,6 +5098,10 @@ msgstr "Fusion"
msgid "Ban a user" msgid "Ban a user"
msgstr "Bannir un utilisateur" msgstr "Bannir un utilisateur"
#: rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
#: rootplace/templates/rootplace/userban.jinja #: rootplace/templates/rootplace/userban.jinja
msgid "not specified" msgid "not specified"
msgstr "non spécifié" msgstr "non spécifié"

View File

@ -152,6 +152,7 @@ TEMPLATES = [
"phonenumber": "core.templatetags.renderer.phonenumber", "phonenumber": "core.templatetags.renderer.phonenumber",
"truncate_time": "core.templatetags.renderer.truncate_time", "truncate_time": "core.templatetags.renderer.truncate_time",
"format_timedelta": "core.templatetags.renderer.format_timedelta", "format_timedelta": "core.templatetags.renderer.format_timedelta",
"add_attr": "core.templatetags.renderer.add_attr",
}, },
"globals": { "globals": {
"can_edit_prop": "core.views.can_edit_prop", "can_edit_prop": "core.views.can_edit_prop",