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.admin import TabularInline
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)
class NewsAdmin(SearchModelAdmin):
list_display = ("title", "type", "club", "author")
list_display = ("title", "club", "author")
search_fields = ("title", "summary", "content")
autocomplete_fields = ("author", "moderator")
inlines = [NewsDateInline]
@admin.register(Poster)
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.
#
#
from typing import Self
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
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.templatetags.static import static
from django.urls import reverse
@ -54,12 +54,21 @@ class Sith(models.Model):
return user.is_com_admin
NEWS_TYPES = [
("NOTICE", _("Notice")),
("EVENT", _("Event")),
("WEEKLY", _("Weekly")),
("CALL", _("Call")),
]
class NewsQuerySet(models.QuerySet):
def moderated(self) -> Self:
return self.filter(is_moderated=True)
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):
@ -79,9 +88,6 @@ class News(models.Model):
default="",
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,
related_name="news",
@ -93,7 +99,7 @@ class News(models.Model):
User,
related_name="owned_news",
verbose_name=_("author"),
on_delete=models.CASCADE,
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
@ -104,19 +110,27 @@ class News(models.Model):
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):
return "%s: %s" % (self.type, self.title)
return self.title
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_moderated:
return
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification.objects.create(
user=user,
url=reverse("com:news_admin_list"),
type="NEWS_MODERATION",
param="1",
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
)
def get_absolute_url(self):
@ -130,35 +144,31 @@ class News(models.Model):
return False
return user.is_com_admin or user == self.author
def can_be_edited_by(self, user):
return user.is_com_admin
def can_be_edited_by(self, user: User):
return user.is_authenticated and (
self.author_id == user.id or user.has_perm("com.change_news")
)
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):
count = (
News.objects.filter(
Q(dates__start_date__gt=timezone.now(), is_moderated=False)
| Q(type="NOTICE", is_moderated=False)
)
.distinct()
.count()
)
count = News.objects.filter(
dates__start_date__gt=timezone.now(), is_moderated=False
).count()
if count:
notif.viewed = False
notif.param = "%s" % count
notif.param = str(count)
notif.date = timezone.now()
else:
notif.viewed = True
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
we don't have to make copies
A [News][] can have multiple dates, for example if it is a recurring event.
"""
news = models.ForeignKey(
@ -167,11 +177,21 @@ class NewsDate(models.Model):
verbose_name=_("news_date"),
on_delete=models.CASCADE,
)
start_date = models.DateTimeField(_("start_date"), null=True, blank=True)
end_date = models.DateTimeField(_("end_date"), null=True, blank=True)
start_date = models.DateTimeField(_("start_date"))
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):
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):
@ -330,6 +350,9 @@ class Poster(models.Model):
on_delete=models.CASCADE,
)
class Meta:
permissions = [("moderate_poster", "Can moderate poster")]
def __str__(self):
return self.name

View File

@ -10,78 +10,13 @@
<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 />
<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>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -94,7 +29,6 @@
<tbody>
{% for news in weeklies.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>
@ -124,7 +58,6 @@
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -136,7 +69,6 @@
<tbody>
{% for news in weeklies.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>
@ -161,91 +93,13 @@
{% endfor %}
</tbody>
</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 />
<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>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -259,16 +113,15 @@
<tbody>
{% for news in events.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>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ 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>
<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>
@ -282,7 +135,6 @@
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
@ -295,15 +147,14 @@
<tbody>
{% for news in events.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>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
{{ 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>
<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>

View File

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

View File

@ -10,21 +10,6 @@
{% endblock %}
{% 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 %}
<h2>{% trans %}Edit news{% endtrans %}</h2>
{% else %}
@ -33,103 +18,73 @@
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.author }}
<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>
<fieldset>
{{ form.title.errors }}
<label for="{{ form.title.name }}" class="required">{{ form.title.label }}</label>
{{ form.title.label_tag() }}
{{ form.title }}
</p>
<p>
</fieldset>
<fieldset>
{{ 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>
{{ form.club }}
</p>
<p>
</fieldset>
{{ 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 }}
<label for="{{ form.summary.name }}" class="required">{{ form.summary.label }}</label>
{{ form.summary.label_tag() }}
<span class="helptext">{{ form.summary.help_text }}</span>
{{ form.summary }}
</p>
<p>
</fieldset>
<fieldset>
{{ form.content.errors }}
<label for="{{ form.content.name }}">{{ form.content.label }}</label>
{{ form.content.label_tag() }}
<span class="helptext">{{ form.content.help_text }}</span>
{{ form.content }}
</p>
{% if user.is_com_admin %}
<p>
{{ form.automoderation.errors }}
<label for="{{ form.automoderation.name }}">{{ form.automoderation.label }}</label>
{{ form.automoderation }}
</p>
</fieldset>
{% if user.is_root or user.is_com_admin %}
<fieldset>
{{ form.auto_moderate.errors }}
{{ form.auto_moderate }}
{{ form.auto_moderate.label_tag() }}
</fieldset>
{% endif %}
<p><input type="submit" name="preview" value="{% trans %}Preview{% endtrans %}"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}"/></p>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" class="btn btn-blue"/></p>
</form>
{% 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 %}
{% 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="left_column" class="news_column">
{% for news in object_list.filter(type="NOTICE") %}
<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') %}
{% 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') %}
<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 %}
{% for d in events_dates %}
<div class="news_events_group">
@ -57,10 +41,7 @@
</div>
</div>
<div class="news_events_group_items">
{% for news in object_list.filter(dates__start_date__gte=d,
dates__start_date__lte=d+timedelta(days=1),
type="EVENT").exclude(dates__end_date__lt=timezone.now())
.order_by('dates__start_date') %}
{% 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') %}
<section class="news_event">
<div class="club_logo">
{% if news.club.logo %}
@ -92,11 +73,8 @@
</div>
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<div id="right_column">
@ -161,9 +139,6 @@
{%- endif -%}
</div>
</div>
</div>
</div>
{% 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"
#
#
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
@ -20,9 +23,12 @@ from django.urls import reverse
from django.utils import html
from django.utils.timezone import localtime, now
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 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
@ -137,15 +143,8 @@ class TestNews(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
new = News.objects.create(
title="dummy new",
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.new = baker.make(News)
cls.author = cls.new.author
cls.sli = User.objects.get(username="sli")
cls.anonymous = AnonymousUser()
@ -176,11 +175,11 @@ class TestNews(TestCase):
assert self.new.can_be_viewed_by(self.author)
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.author)
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.author)
class TestWeekmailArticle(TestCase):
@ -230,3 +229,93 @@ class TestPoster(TestCase):
assert not self.poster.is_owned_by(self.susbcriber)
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,
NewsDeleteView,
NewsDetailView,
NewsEditView,
NewsListView,
NewsModerateView,
NewsUpdateView,
PosterCreateView,
PosterDeleteView,
PosterEditView,
@ -75,11 +75,11 @@ urlpatterns = [
path("news/", NewsListView.as_view(), name="news_list"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
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>/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("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path(

View File

@ -24,11 +24,15 @@
import itertools
from datetime import timedelta
from smtplib import SMTPRecipientsRefused
from typing import Any
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import (
AccessMixin,
PermissionRequiredMixin,
)
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.http import HttpResponseRedirect
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.translation import gettext_lazy as _
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 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 core.models import Notification, User
from core.models import User
from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput
# Sith object
@ -59,92 +61,47 @@ from core.views.widgets.markdown import MarkdownInput
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):
def get_tabs_title(self):
return _("Communication administration")
def get_list_of_tabs(self):
tab_list = []
tab_list.append(
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
)
tab_list.append(
return [
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
{
"url": reverse("com:weekmail_destinations"),
"slug": "weekmail_destinations",
"name": _("Weekmail destinations"),
}
)
tab_list.append(
{"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
)
tab_list.append(
},
{
"url": reverse("com:info_edit"),
"slug": "info",
"name": _("Info message"),
},
{
"url": reverse("com:alert_edit"),
"slug": "alert",
"name": _("Alert message"),
}
)
tab_list.append(
},
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
}
)
tab_list.append(
},
{
"url": reverse("com:poster_list"),
"slug": "posters",
"name": _("Posters list"),
}
)
tab_list.append(
},
{
"url": reverse("com:screen_list"),
"slug": "screens",
"name": _("Screens list"),
}
)
return tab_list
},
]
class IsComAdminMixin(View):
class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
@ -184,167 +141,86 @@ class WeekmailDestinationEditView(ComEditView):
# News
class NewsForm(forms.ModelForm):
class Meta:
class NewsCreateView(PermissionRequiredMixin, CreateView):
"""View to either create or update News."""
model = News
fields = ["type", "title", "club", "summary", "content", "author"]
widgets = {
"author": forms.HiddenInput,
"type": forms.RadioSelect,
"summary": MarkdownInput,
"content": MarkdownInput,
form_class = NewsForm
template_name = "com/news_edit.jinja"
permission_required = "com.add_news"
def get_date_form_kwargs(self) -> dict[str, Any]:
"""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(
label=_("Start date"), widget=SelectDateTime, required=False
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
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
def get_initial(self):
init = super().get_initial()
# if the id of a club is provided, select it by default
if club_id := self.request.GET.get("club"):
init["club"] = Club.objects.filter(id=club_id).first()
return init
class NewsEditView(CanEditMixin, UpdateView):
class NewsUpdateView(UpdateView):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
def get_initial(self):
news_date: NewsDate = self.object.dates.order_by("id").first()
if news_date is None:
return {"start_date": None, "end_date": None}
return {"start_date": news_date.start_date, "end_date": news_date.end_date}
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:
return self.form_invalid(form)
def dispatch(self, request, *args, **kwargs):
if (
not request.user.has_perm("com.edit_news")
and self.get_object().author != request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
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:
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",
)
IcsCalendar.make_internal()
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):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
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)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"author": self.request.user,
"date_form": NewsDateForm(**self.get_date_form_kwargs()),
}
class NewsDeleteView(CanEditMixin, DeleteView):
class NewsDeleteView(PermissionRequiredMixin, DeleteView):
model = News
pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:news_admin_list")
permission_required = "com.delete_news"
class NewsModerateView(CanEditMixin, SingleObjectMixin):
class NewsModerateView(PermissionRequiredMixin, DetailView):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
@ -359,17 +235,23 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
return redirect("com:news_admin_list")
class NewsAdminListView(CanEditMixin, ListView):
class NewsAdminListView(PermissionRequiredMixin, ListView):
model = News
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
template_name = "com/news_list.jinja"
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):
kwargs = super().get_context_data(**kwargs)
kwargs["NewsDate"] = NewsDate
@ -390,6 +272,10 @@ class NewsDetailView(CanViewMixin, DetailView):
model = News
template_name = "com/news_detail.jinja"
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

View File

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

View File

@ -665,7 +665,9 @@ form {
}
&: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 {
transform: translateY(-50%) translateX(

View File

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

View File

@ -26,6 +26,7 @@ import datetime
import phonenumbers
from django import template
from django.forms import BoundField
from django.template.defaultfilters import stringfilter
from django.utils.safestring import mark_safe
from django.utils.translation import ngettext
@ -80,3 +81,43 @@ def format_timedelta(value: datetime.timedelta) -> str:
return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
) % {"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.recipe import Recipe, foreign_key
from com.models import News
from core.baker_recipes import (
old_subscriber_user,
subscriber_user,
@ -22,6 +23,8 @@ from eboutic.models import Invoice, InvoiceItem
class TestSearchUsers(TestCase):
@classmethod
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
User.objects.all().delete()
user_recipe = Recipe(
User,

View File

@ -14,7 +14,7 @@
#
from dataclasses import dataclass
from datetime import date
from datetime import date, timedelta
# Image utils
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)
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:
"""Return the semester code of the given date.
If no date is given, return the semester code of the current semester.

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Maréchal <thomas.girod@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"
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
msgid "End date"
msgstr "Date de fin"
@ -1259,6 +1259,46 @@ msgstr "Liste d'affiches"
msgid "Props"
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
msgid "alert message"
msgstr "message d'alerte"
@ -1271,22 +1311,6 @@ msgstr "message d'info"
msgid "weekmail destinations"
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
#: forum/models.py pedagogy/models.py
msgid "title"
@ -1312,10 +1336,6 @@ msgstr "contenu"
msgid "A more detailed and exhaustive description of the event."
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
msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement."
@ -1324,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author"
msgstr "auteur"
#: com/models.py
msgid "news"
msgstr "nouvelle"
#: com/models.py
msgid "news_date"
msgstr "date de la nouvelle"
@ -1336,6 +1360,14 @@ msgstr "date de début"
msgid "end_date"
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
msgid "intro"
msgstr "intro"
@ -1420,23 +1452,17 @@ msgid "News"
msgstr "Nouvelles"
#: 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"
msgstr "Créer nouvelle"
msgstr "Créer une nouvelle"
#: com/templates/com/news_admin_list.jinja
msgid "Notices"
msgstr "Information"
msgid "Weeklies"
msgstr "Événements hebdomadaires"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed notices"
msgstr "Informations affichées"
#: com/templates/com/news_admin_list.jinja
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
msgid "Displayed weeklies"
msgstr "Événements hebdomadaires affichées"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja forum/templates/forum/main.jinja
@ -1457,18 +1483,6 @@ msgstr "Auteur"
msgid "Moderator"
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
#: trombi/templates/trombi/edit_profile.jinja
msgid "Dates"
@ -1478,18 +1492,6 @@ msgstr "Dates"
msgid "Weeklies to moderate"
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
#: core/templates/core/base/navbar.jinja
msgid "Events"
@ -1507,7 +1509,7 @@ msgstr "Événements à modérer"
msgid "Back to news"
msgstr "Retour aux nouvelles"
#: com/templates/com/news_detail.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_detail.jinja
msgid "Author: "
msgstr "Auteur : "
@ -1523,41 +1525,14 @@ msgstr "Éditer (sera soumise de nouveau à la modération)"
msgid "Edit news"
msgstr "Éditer la nouvelle"
#: com/templates/com/news_edit.jinja
msgid "Notice: Information, election result - no date"
msgstr "Information, résultat d'élection - sans date"
#: 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
msgid "Events today and the next few days"
msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_list.jinja
msgid "Administrate 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
msgid "Nothing to come..."
msgstr "Rien à venir..."
@ -1679,6 +1654,10 @@ msgstr "Diaporama"
msgid "Weekmail"
msgstr "Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Preview"
msgstr "Prévisualiser"
#: com/templates/com/weekmail.jinja com/templates/com/weekmail_preview.jinja
msgid "Send"
msgstr "Envoyer"
@ -1768,14 +1747,6 @@ msgstr "Astuce"
msgid "Final word"
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
msgid "Communication administration"
msgstr "Administration de la communication"
@ -1796,22 +1767,6 @@ msgstr "Message d'alerte"
msgid "Screens list"
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
msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer"
@ -2215,6 +2170,10 @@ msgstr "url"
msgid "param"
msgstr "param"
#: core/models.py launderette/models.py
msgid "type"
msgstr "type"
#: core/models.py
msgid "viewed"
msgstr "vue"
@ -4734,6 +4693,11 @@ msgstr "Machines"
msgid "New machine"
msgstr "Nouvelle machine"
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
#: launderette/templates/launderette/launderette_book.jinja
msgid "Choose"
msgstr "Choisir"
@ -5134,6 +5098,10 @@ msgstr "Fusion"
msgid "Ban a user"
msgstr "Bannir un utilisateur"
#: rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
#: rootplace/templates/rootplace/userban.jinja
msgid "not specified"
msgstr "non spécifié"

View File

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