diff --git a/com/admin.py b/com/admin.py index 21e14e4f..f9b4b081 100644 --- a/com/admin.py +++ b/com/admin.py @@ -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): diff --git a/com/forms.py b/com/forms.py new file mode 100644 index 00000000..471e6632 --- /dev/null +++ b/com/forms.py @@ -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 diff --git a/com/migrations/0008_alter_news_options_alter_newsdate_options_and_more.py b/com/migrations/0008_alter_news_options_alter_newsdate_options_and_more.py new file mode 100644 index 00000000..98b09b65 --- /dev/null +++ b/com/migrations/0008_alter_news_options_alter_newsdate_options_and_more.py @@ -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", + ), + ), + ] diff --git a/com/models.py b/com/models.py index 633c7671..85c2b63d 100644 --- a/com/models.py +++ b/com/models.py @@ -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 diff --git a/com/templates/com/news_admin_list.jinja b/com/templates/com/news_admin_list.jinja index 3214ffd5..3884cfc7 100644 --- a/com/templates/com/news_admin_list.jinja +++ b/com/templates/com/news_admin_list.jinja @@ -10,78 +10,13 @@

{% trans %}Create news{% endtrans %}

-
-

{% trans %}Notices{% endtrans %}

- {% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %} -
{% trans %}Displayed notices{% endtrans %}
- - - - - - - - - - - - - - {% for news in notices.filter(is_moderated=True) %} - - - - - - - - - - {% endfor %} - -
{% trans %}Type{% endtrans %}{% trans %}Title{% endtrans %}{% trans %}Summary{% endtrans %}{% trans %}Club{% endtrans %}{% trans %}Author{% endtrans %}{% trans %}Moderator{% endtrans %}{% trans %}Actions{% endtrans %}
{{ news.get_type_display() }}{{ news.title }}{{ news.summary|markdown }}{{ news.club }}{{ user_profile_link(news.author) }}{{ user_profile_link(news.moderator) }}{% trans %}View{% endtrans %} - {% trans %}Edit{% endtrans %} - {% trans %}Remove{% endtrans %} - {% trans %}Delete{% endtrans %} -
-
{% trans %}Notices to moderate{% endtrans %}
- - - - - - - - - - - - - {% for news in notices.filter(is_moderated=False) %} - - - - - - - - - {% endfor %} - -
{% trans %}Type{% endtrans %}{% trans %}Title{% endtrans %}{% trans %}Summary{% endtrans %}{% trans %}Club{% endtrans %}{% trans %}Author{% endtrans %}{% trans %}Actions{% endtrans %}
{{ news.get_type_display() }}{{ news.title }}{{ news.summary|markdown }}{{ news.club }}{{ user_profile_link(news.author) }}{% trans %}View{% endtrans %} - {% trans %}Edit{% endtrans %} - {% trans %}Moderate{% endtrans %} - {% trans %}Delete{% endtrans %} -
-

{% trans %}Weeklies{% endtrans %}

- {% 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') %}
{% trans %}Displayed weeklies{% endtrans %}
- @@ -94,7 +29,6 @@ {% for news in weeklies.filter(is_moderated=True) %} - @@ -124,7 +58,6 @@
{% trans %}Type{% endtrans %} {% trans %}Title{% endtrans %} {% trans %}Summary{% endtrans %} {% trans %}Club{% endtrans %}
{{ news.get_type_display() }} {{ news.title }} {{ news.summary|markdown }} {{ news.club }}
- @@ -136,7 +69,6 @@ {% for news in weeklies.filter(is_moderated=False) %} - @@ -161,91 +93,13 @@ {% endfor %}
{% trans %}Type{% endtrans %} {% trans %}Title{% endtrans %} {% trans %}Summary{% endtrans %} {% trans %}Club{% endtrans %}
{{ news.get_type_display() }} {{ news.title }} {{ news.summary|markdown }} {{ news.club }}
- -
-

{% trans %}Calls{% endtrans %}

- {% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %} -
{% trans %}Displayed calls{% endtrans %}
- - - - - - - - - - - - - - - - {% for news in calls.filter(is_moderated=True) %} - - - - - - - - - - - - {% endfor %} - -
{% trans %}Type{% endtrans %}{% trans %}Title{% endtrans %}{% trans %}Summary{% endtrans %}{% trans %}Club{% endtrans %}{% trans %}Author{% endtrans %}{% trans %}Moderator{% endtrans %}{% trans %}Start{% endtrans %}{% trans %}End{% endtrans %}{% trans %}Actions{% endtrans %}
{{ news.get_type_display() }}{{ news.title }}{{ news.summary|markdown }}{{ news.club }}{{ user_profile_link(news.author) }}{{ user_profile_link(news.moderator) }}{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}{% trans %}View{% endtrans %} - {% trans %}Edit{% endtrans %} - {% trans %}Remove{% endtrans %} - {% trans %}Delete{% endtrans %} -
-
{% trans %}Calls to moderate{% endtrans %}
- - - - - - - - - - - - - - - {% for news in calls.filter(is_moderated=False) %} - - - - - - - - - - - {% endfor %} - -
{% trans %}Type{% endtrans %}{% trans %}Title{% endtrans %}{% trans %}Summary{% endtrans %}{% trans %}Club{% endtrans %}{% trans %}Author{% endtrans %}{% trans %}Start{% endtrans %}{% trans %}End{% endtrans %}{% trans %}Actions{% endtrans %}
{{ news.get_type_display() }}{{ news.title }}{{ news.summary|markdown }}{{ news.club }}{{ user_profile_link(news.author) }}{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}{% trans %}View{% endtrans %} - {% trans %}Edit{% endtrans %} - {% trans %}Moderate{% endtrans %} - {% trans %}Delete{% endtrans %} -
-

{% trans %}Events{% endtrans %}

- {% 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') %}
{% trans %}Displayed events{% endtrans %}
- @@ -259,16 +113,15 @@ {% for news in events.filter(is_moderated=True) %} - - - + +
{% trans %}Type{% endtrans %} {% trans %}Title{% endtrans %} {% trans %}Summary{% endtrans %} {% trans %}Club{% endtrans %}
{{ news.get_type_display() }} {{ news.title }} {{ news.summary|markdown }} {{ news.club }} {{ user_profile_link(news.author) }} {{ user_profile_link(news.moderator) }}{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }} + {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }} + {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} {% trans %}Remove{% endtrans %} @@ -282,7 +135,6 @@ - @@ -295,15 +147,14 @@ {% for news in events.filter(is_moderated=False) %} - - - + +
{% trans %}Type{% endtrans %} {% trans %}Title{% endtrans %} {% trans %}Summary{% endtrans %} {% trans %}Club{% endtrans %}
{{ news.get_type_display() }} {{ news.title }} {{ news.summary|markdown }} {{ news.club }} {{ user_profile_link(news.author) }}{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }} + {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }} + {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} {% trans %}View{% endtrans %} {% trans %}Edit{% endtrans %} {% trans %}Moderate{% endtrans %} diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja index 238515ed..454eb2ef 100644 --- a/com/templates/com/news_detail.jinja +++ b/com/templates/com/news_detail.jinja @@ -25,10 +25,10 @@

{{ news.title }}

- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} + {{ date.start_date|localtime|date(DATETIME_FORMAT) }} + {{ date.start_date|localtime|time(DATETIME_FORMAT) }} - + {{ date.end_date|localtime|date(DATETIME_FORMAT) }} + {{ date.end_date|localtime|time(DATETIME_FORMAT) }}

{{ news.summary|markdown }}
diff --git a/com/templates/com/news_edit.jinja b/com/templates/com/news_edit.jinja index 74040dc8..e3302fb5 100644 --- a/com/templates/com/news_edit.jinja +++ b/com/templates/com/news_edit.jinja @@ -10,21 +10,6 @@ {% endblock %} {% block content %} - {% if 'preview' in request.POST.keys() %} -
-

{{ form.instance.title }}

-

- {{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} -

-

{{ form.instance.club or "Club" }}

-
{{ form.instance.summary|markdown }}
-
{{ form.instance.content|markdown }}
-

{% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}

-
- {% endif %} {% if object %}

{% trans %}Edit news{% endtrans %}

{% else %} @@ -33,103 +18,73 @@
{% csrf_token %} {{ form.non_field_errors() }} - {{ form.author }} -

- {{ form.type.errors }} - -

    -
  • {% trans %}Notice: Information, election result - no date{% endtrans %}
  • -
  • {% trans %}Event: punctual event, associated with one date{% endtrans %}
  • -
  • - {% trans trimmed%} - Weekly: recurrent event, associated with many dates - (specify the first one, and a deadline) - {% endtrans %} -
  • -
  • - {% trans trimmed %} - Call: long time event, associated with a long date (like election appliance) - {% endtrans %} -
  • -
- {{ form.type }} -

-

- {{ form.start_date.errors }} - - {{ form.start_date }} -

-

- {{ form.end_date.errors }} - - {{ form.end_date }} -

-

- {{ form.until.errors }} - - {{ form.until }} -

-

+

{{ form.title.errors }} - + {{ form.title.label_tag() }} {{ form.title }} -

-

+

+
{{ form.club.errors }} - + {{ form.club.label_tag() }} {{ form.club.help_text }} {{ form.club }} -

-

+

+ {{ form.date_form.non_field_errors() }} +
+ {# startDate is used to dynamically ensure end_date >= start_date, + whatever the value of start_date #} +
+ {{ form.date_form.start_date.errors }} + {{ form.date_form.start_date.label_tag() }} + {{ form.date_form.start_date.help_text }} + {{ form.date_form.start_date|add_attr("x-model=startDate") }} +
+
+ {{ form.date_form.end_date.errors }} + {{ form.date_form.end_date.label_tag() }} + {{ form.date_form.end_date.help_text }} + {{ form.date_form.end_date|add_attr(":min=startDate") }} +
+
+ {# lower to convert True and False to true and false #} +
+
+
+ {{ form.date_form.is_weekly|add_attr("x-model=isWeekly") }} +
+ {{ form.date_form.is_weekly.label_tag() }} + {{ form.date_form.is_weekly.help_text }} +
+
+
+
+ {{ form.date_form.occurrences.label_tag() }} + {{ form.date_form.occurrences.help_text }} + {{ form.date_form.occurrences }} +
+
+
{{ form.summary.errors }} - + {{ form.summary.label_tag() }} {{ form.summary.help_text }} {{ form.summary }} -

-

+

+
{{ form.content.errors }} - + {{ form.content.label_tag() }} {{ form.content.help_text }} {{ form.content }} -

- {% if user.is_com_admin %} -

- {{ form.automoderation.errors }} - - {{ form.automoderation }} -

+
+ {% if user.is_root or user.is_com_admin %} +
+ {{ form.auto_moderate.errors }} + {{ form.auto_moderate }} + {{ form.auto_moderate.label_tag() }} +
{% endif %} -

-

+

{% endblock %} - -{% block script %} - {{ super() }} - -{% endblock %} - - diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index 0cd04749..168a95b4 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -15,37 +15,21 @@ {% endblock %} {% block content %} - {% if user.is_com_admin %} - -
- {% endif %}
- {% for news in object_list.filter(type="NOTICE") %} -
-

{{ news.title }}

-
{{ news.summary|markdown }}
-
- {% endfor %} - - {% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %} -
-

{{ news.title }}

-
- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }} - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} -
-
{{ news.summary|markdown }}
-
- {% 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') %}

{% trans %}Events today and the next few days{% endtrans %}

+ {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %} + + + {% trans %}Create news{% endtrans %} + + {% endif %} + {% if user.is_com_admin %} + {% trans %}Administrate news{% endtrans %} +
+ {% endif %} {% if events_dates %} {% for d in events_dates %}
@@ -57,113 +41,104 @@
- {% 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') %} -
- -

{{ news.title }}

- -
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - - {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} -
-
{{ news.summary|markdown }} -
- {{ fb_quick(news) }} - {{ tweet_quick(news) }} + {% 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') %} +
+ -
-
+

{{ news.title }}

+ +
+ {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} - + {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} +
+
{{ news.summary|markdown }} +
+ {{ fb_quick(news) }} + {{ tweet_quick(news) }} +
+
+ + {% endfor %} +
+
{% endfor %} + {% else %} +
+ {% trans %}Nothing to come...{% endtrans %}
-
- {% endfor %} -{% else %} -
- {% trans %}Nothing to come...{% endtrans %} -
-{% endif %} + {% endif %} - -

{% trans %}All coming events{% endtrans %}

- - - - - -
- -
-

{% trans %}Birthdays{% endtrans %}

-
- {%- if user.was_subscribed -%} - +
+

{% trans %}Social media{% endtrans %}

+ +
+
+ +
+

{% trans %}Birthdays{% endtrans %}

+
+ {%- if user.was_subscribed -%} +
    + {%- for year, users in birthdays -%} +
  • + {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %} + +
  • + {%- endfor -%} +
+ {%- else -%} +

{% trans %}You need to subscribe to access this content{% endtrans %}

+ {%- endif -%} +
+
- - - - - {% endblock %} diff --git a/com/tests/test_models.py b/com/tests/test_models.py new file mode 100644 index 00000000..41be34ee --- /dev/null +++ b/com/tests/test_models.py @@ -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], + } diff --git a/com/tests/test_views.py b/com/tests/test_views.py index 3f98bfdc..c526dee5 100644 --- a/com/tests/test_views.py +++ b/com/tests/test_views.py @@ -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() diff --git a/com/urls.py b/com/urls.py index 304e5312..592e653b 100644 --- a/com/urls.py +++ b/com/urls.py @@ -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//edit/", NewsUpdateView.as_view(), name="news_edit"), path("news//delete/", NewsDeleteView.as_view(), name="news_delete"), path( "news//moderate/", NewsModerateView.as_view(), name="news_moderate" ), - path("news//edit/", NewsEditView.as_view(), name="news_edit"), path("news//", NewsDetailView.as_view(), name="news_detail"), path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"), path( diff --git a/com/views.py b/com/views.py index f9993b3c..54e37578 100644 --- a/com/views.py +++ b/com/views.py @@ -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: - model = News - fields = ["type", "title", "club", "summary", "content", "author"] - widgets = { - "author": forms.HiddenInput, - "type": forms.RadioSelect, - "summary": MarkdownInput, - "content": MarkdownInput, +class NewsCreateView(PermissionRequiredMixin, CreateView): + """View to either create or update News.""" + + model = News + 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 diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 918c7dd7..a5131d64 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -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( diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 326be76c..9982e77f 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -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( diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 6a4f0235..a89d33cf 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -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 { diff --git a/core/templatetags/renderer.py b/core/templatetags/renderer.py index 383f0383..62f9f730 100644 --- a/core/templatetags/renderer.py +++ b/core/templatetags/renderer.py @@ -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.field|add_attr("x-model=alpineField") }} +
+ ``` + + will render : + ```html +
+ +
+ ``` + + 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) diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 16bef4e1..0e14bad8 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -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, diff --git a/core/utils.py b/core/utils.py index cdd72fa6..6b72bde8 100644 --- a/core/utils.py +++ b/core/utils.py @@ -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. diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c2ca80d8..56b03b07 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \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é" diff --git a/sith/settings.py b/sith/settings.py index 42e46603..61079d64 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -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",