diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..41583e36 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@jsr:registry=https://npm.jsr.io diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e480eda0..506d9ebe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: rev: "v0.1.0" # Use the sha / tag you want to point at hooks: - id: biome-check - additional_dependencies: ["@biomejs/biome@1.9.3"] + additional_dependencies: ["@biomejs/biome@1.9.4"] - repo: https://github.com/rtts/djhtml rev: 3.0.7 hooks: diff --git a/accounting/api.py b/accounting/api.py index a16fb7ab..5ba6c12d 100644 --- a/accounting/api.py +++ b/accounting/api.py @@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema from accounting.models import ClubAccount, Company from accounting.schemas import ClubAccountSchema, CompanySchema -from core.api_permissions import CanAccessLookup +from core.auth.api_permissions import CanAccessLookup @api_controller("/lookup", permissions=[CanAccessLookup]) diff --git a/accounting/views.py b/accounting/views.py index ce0ae45b..f9fd8412 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -17,6 +17,7 @@ import collections from django import forms from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.db.models import Sum @@ -44,15 +45,15 @@ from accounting.widgets.select import ( ) from club.models import Club from club.widgets.select import AutoCompleteSelectClub -from core.models import User -from core.views import ( +from core.auth.mixins import ( CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin, - TabedViewMixin, ) +from core.models import User from core.views.forms import SelectDate, SelectFile +from core.views.mixins import TabedViewMixin from core.views.widgets.select import AutoCompleteSelectUser from counter.models import Counter, Product, Selling @@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView): template_name = "core/edit.jinja" -class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView): +class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView): """Create an accounting type (for the admins).""" model = SimplifiedAccountingType fields = ["label", "accounting_type"] template_name = "core/create.jinja" + permission_required = "accounting.add_simplifiedaccountingtype" # Accounting types @@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView): template_name = "core/edit.jinja" -class AccountingTypeCreateView(CanCreateMixin, CreateView): +class AccountingTypeCreateView(PermissionRequiredMixin, CreateView): """Create an accounting type (for the admins).""" model = AccountingType fields = ["code", "label", "movement_type"] template_name = "core/create.jinja" + permission_required = "accounting.add_accountingtype" # BankAccount views diff --git a/club/api.py b/club/api.py index 9a680154..2ad0f5c8 100644 --- a/club/api.py +++ b/club/api.py @@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema from club.models import Club from club.schemas import ClubSchema -from core.api_permissions import CanAccessLookup +from core.auth.api_permissions import CanAccessLookup @api_controller("/club") diff --git a/club/tests.py b/club/tests.py index b81aa38d..a9b7e2e6 100644 --- a/club/tests.py +++ b/club/tests.py @@ -213,9 +213,9 @@ class TestMembershipQuerySet(TestClub): memberships[1].club.members_group, memberships[1].club.board_group, } - assert set(user.groups.all()) == club_groups + assert set(user.groups.all()).issuperset(club_groups) user.memberships.all().delete() - assert user.groups.all().count() == 0 + assert set(user.groups.all()).isdisjoint(club_groups) class TestClubModel(TestClub): diff --git a/club/views.py b/club/views.py index d713a1cc..de5ccaee 100644 --- a/club/views.py +++ b/club/views.py @@ -25,6 +25,7 @@ import csv from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.paginator import InvalidPage, Paginator from django.db.models import Sum @@ -49,17 +50,15 @@ from com.views import ( PosterEditBaseView, PosterListBaseView, ) -from core.models import PageRev -from core.views import ( +from core.auth.mixins import ( CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin, - DetailFormView, - PageEditViewBase, - TabedViewMixin, - UserIsRootMixin, ) +from core.models import PageRev +from core.views import DetailFormView, PageEditViewBase +from core.views.mixins import TabedViewMixin from counter.models import Selling @@ -474,13 +473,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView): current_tab = "props" -class ClubCreateView(CanCreateMixin, CreateView): +class ClubCreateView(PermissionRequiredMixin, CreateView): """Create a club (for the Sith admin).""" model = Club pk_url_kwarg = "club_id" fields = ["name", "unix_name", "parent"] template_name = "core/edit.jinja" + permission_required = "club.add_club" class MembershipSetOldView(CanEditMixin, DetailView): @@ -512,12 +512,13 @@ class MembershipSetOldView(CanEditMixin, DetailView): ) -class MembershipDeleteView(UserIsRootMixin, DeleteView): +class MembershipDeleteView(PermissionRequiredMixin, DeleteView): """Delete a membership (for admins only).""" model = Membership pk_url_kwarg = "membership_id" template_name = "core/delete_confirm.jinja" + permission_required = "club.delete_membership" def get_success_url(self): return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) 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..1219410a 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,24 @@ 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 + q_filter = Q(is_moderated=True) + if user.is_authenticated: + q_filter |= Q(author_id=user.id) + return self.filter(q_filter) class News(models.Model): @@ -79,9 +91,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 +102,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 +113,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 +147,35 @@ 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 + def can_be_viewed_by(self, user: User): + return ( + self.is_moderated + or user.has_perm("com.view_unmoderated_news") + or (user.is_authenticated and self.author_id == user.id) + ) 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 +184,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 +357,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/signals.py b/com/signals.py index ea004ad8..1c42c6e9 100644 --- a/com/signals.py +++ b/com/signals.py @@ -1,10 +1,10 @@ -from django.db.models.base import post_save +from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from com.calendar import IcsCalendar from com.models import News -@receiver(post_save, sender=News, dispatch_uid="update_internal_ics") +@receiver([post_save, post_delete], sender=News, dispatch_uid="update_internal_ics") def update_internal_ics(*args, **kwargs): _ = IcsCalendar.make_internal() 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) %} - - - + + {% endif %} - {% if user.is_root %} + {% if user.has_perm("club.delete_membership") %} {% endif %} @@ -59,7 +59,7 @@ - {% if user.is_root %} + {% if user.has_perm("club.delete_membership") %} {% endif %} diff --git a/core/templates/core/user_detail.jinja b/core/templates/core/user_detail.jinja index 58836b6f..cb93b1cd 100644 --- a/core/templates/core/user_detail.jinja +++ b/core/templates/core/user_detail.jinja @@ -244,27 +244,30 @@ {% block script %} {{ super() }} {% endblock %} -{% block additional_js %} - +{% block additional_css %} + {% endblock %} {% block content %} @@ -68,7 +68,7 @@
{% 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 e0f5c4e5..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..100a83ef 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() @@ -160,13 +159,13 @@ class TestNews(TestCase): def test_news_viewer(self): """Test that moderated news can be viewed by anyone - and not moderated news only by com admins. + and not moderated news only by com admins and by their author. """ - # by default a news isn't moderated + # by default news aren't moderated assert self.new.can_be_viewed_by(self.com_admin) + assert self.new.can_be_viewed_by(self.author) assert not self.new.can_be_viewed_by(self.sli) assert not self.new.can_be_viewed_by(self.anonymous) - assert not self.new.can_be_viewed_by(self.author) self.new.is_moderated = True self.new.save() @@ -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..a6faf214 100644 --- a/com/views.py +++ b/com/views.py @@ -24,11 +24,12 @@ 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 +38,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.views import ( - CanCreateMixin, - CanEditMixin, +from core.auth.mixins import ( CanEditPropMixin, CanViewMixin, - QuickNotifMixin, - TabedViewMixin, + PermissionOrAuthorRequiredMixin, ) -from core.views.forms import SelectDateTime +from core.models import User +from core.views.mixins import QuickNotifMixin, TabedViewMixin from core.views.widgets.markdown import MarkdownInput # Sith object @@ -59,92 +58,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 +138,79 @@ 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(PermissionOrAuthorRequiredMixin, 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) + permission_required = "com.edit_news" 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", - ) - return super().form_valid(form) + response = super().form_valid(form) # Does the saving part + IcsCalendar.make_internal() + return response + + 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 + + def get_form_kwargs(self): + return super().get_form_kwargs() | { + "author": self.request.user, + "date_form": NewsDateForm(**self.get_date_form_kwargs()), + } -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) - - -class NewsDeleteView(CanEditMixin, DeleteView): +class NewsDeleteView(PermissionOrAuthorRequiredMixin, 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 +225,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 +262,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/api.py b/core/api.py index 1662cb84..e1b3bbbd 100644 --- a/core/api.py +++ b/core/api.py @@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema from club.models import Mailing -from core.api_permissions import ( - CanAccessLookup, - CanView, -) +from core.auth.api_permissions import CanAccessLookup, CanView from core.models import Group, SithFile, User from core.schemas import ( FamilyGodfatherSchema, diff --git a/core/auth/__init__.py b/core/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/core/api_permissions.py b/core/auth/api_permissions.py similarity index 99% rename from core/api_permissions.py rename to core/auth/api_permissions.py index f4da67af..4d83143e 100644 --- a/core/api_permissions.py +++ b/core/auth/api_permissions.py @@ -3,7 +3,8 @@ Some permissions are global (like `IsInGroup` or `IsRoot`), and some others are per-object (like `CanView` or `CanEdit`). -Examples: +Example: + ```python # restrict all the routes of this controller # to subscribed users @api_controller("/foo", permissions=[IsSubscriber]) @@ -33,6 +34,7 @@ Examples: ] def bar_delete(self, bar_id: int): # ... + ``` """ from typing import Any diff --git a/core/auth_backends.py b/core/auth/backends.py similarity index 100% rename from core/auth_backends.py rename to core/auth/backends.py diff --git a/core/auth/mixins.py b/core/auth/mixins.py new file mode 100644 index 00000000..974e9bd1 --- /dev/null +++ b/core/auth/mixins.py @@ -0,0 +1,287 @@ +# +# Copyright 2016,2017 +# - Skia +# - Sli +# +# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM, +# http://ae.utbm.fr. +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License a published by the Free Software +# Foundation; either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple +# Place - Suite 330, Boston, MA 02111-1307, USA. +# +# +from __future__ import annotations + +import types +import warnings +from typing import TYPE_CHECKING, Any, LiteralString + +from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin +from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.views.generic.base import View + +if TYPE_CHECKING: + from django.db.models import Model + + from core.models import User + + +def can_edit_prop(obj: Any, user: User) -> bool: + """Can the user edit the properties of the object. + + Args: + obj: Object to test for permission + user: core.models.User to test permissions against + + Returns: + True if user is authorized to edit object properties else False + + Example: + ```python + if not can_edit_prop(self.object ,request.user): + raise PermissionDenied + ``` + """ + return obj is None or user.is_owner(obj) + + +def can_edit(obj: Any, user: User) -> bool: + """Can the user edit the object. + + Args: + obj: Object to test for permission + user: core.models.User to test permissions against + + Returns: + True if user is authorized to edit object else False + + Example: + ```python + if not can_edit(self.object, request.user): + raise PermissionDenied + ``` + """ + if obj is None or user.can_edit(obj): + return True + return can_edit_prop(obj, user) + + +def can_view(obj: Any, user: User) -> bool: + """Can the user see the object. + + Args: + obj: Object to test for permission + user: core.models.User to test permissions against + + Returns: + True if user is authorized to see object else False + + Example: + ```python + if not can_view(self.object ,request.user): + raise PermissionDenied + ``` + """ + if obj is None or user.can_view(obj): + return True + return can_edit(obj, user) + + +class GenericContentPermissionMixinBuilder(View): + """Used to build permission mixins. + + This view protect any child view that would be showing an object that is restricted based + on two properties. + + Attributes: + raised_error: permission to be raised + """ + + raised_error = PermissionDenied + + @staticmethod + def permission_function(obj: Any, user: User) -> bool: + """Function to test permission with.""" + return False + + @classmethod + def get_permission_function(cls, obj, user): + return cls.permission_function(obj, user) + + def dispatch(self, request, *arg, **kwargs): + if hasattr(self, "get_object") and callable(self.get_object): + self.object = self.get_object() + if not self.get_permission_function(self.object, request.user): + raise self.raised_error + return super().dispatch(request, *arg, **kwargs) + + # If we get here, it's a ListView + + queryset = self.get_queryset() + l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)] + if not l_id and queryset.count() != 0: + raise self.raised_error + self._get_queryset = self.get_queryset + + def get_qs(self2): + return self2._get_queryset().filter(id__in=l_id) + + self.get_queryset = types.MethodType(get_qs, self) + return super().dispatch(request, *arg, **kwargs) + + +class CanCreateMixin(View): + """Protect any child view that would create an object. + + Raises: + PermissionDenied: + If the user has not the necessary permission + to create the object of the view. + """ + + def __init_subclass__(cls, **kwargs): + warnings.warn( + f"{cls.__name__} is deprecated and should be replaced " + "by other permission verification mecanism.", + DeprecationWarning, + stacklevel=2, + ) + super().__init_subclass__(**kwargs) + + def __init__(self, *args, **kwargs): + warnings.warn( + f"{self.__class__.__name__} is deprecated and should be replaced " + "by other permission verification mecanism.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + + def dispatch(self, request, *arg, **kwargs): + res = super().dispatch(request, *arg, **kwargs) + if not request.user.is_authenticated: + raise PermissionDenied + return res + + def form_valid(self, form): + obj = form.instance + if can_edit_prop(obj, self.request.user): + return super().form_valid(form) + raise PermissionDenied + + +class CanEditPropMixin(GenericContentPermissionMixinBuilder): + """Ensure the user has owner permissions on the child view object. + + In other word, you can make a view with this view as parent, + and it will be retricted to the users that are in the + object's owner_group or that pass the `obj.can_be_viewed_by` test. + + Raises: + PermissionDenied: If the user cannot see the object + """ + + permission_function = can_edit_prop + + +class CanEditMixin(GenericContentPermissionMixinBuilder): + """Ensure the user has permission to edit this view's object. + + Raises: + PermissionDenied: if the user cannot edit this view's object. + """ + + permission_function = can_edit + + +class CanViewMixin(GenericContentPermissionMixinBuilder): + """Ensure the user has permission to view this view's object. + + Raises: + PermissionDenied: if the user cannot edit this view's object. + """ + + permission_function = can_view + + +class FormerSubscriberMixin(AccessMixin): + """Check if the user was at least an old subscriber. + + Raises: + PermissionDenied: if the user never subscribed. + """ + + def dispatch(self, request, *args, **kwargs): + if not request.user.was_subscribed: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + +class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin): + """Require that the user has the required perm or is the object author. + + This mixin can be used in combination with `DetailView`, + or another base class that implements the `get_object` method. + + Example: + In the following code, a user will be able + to edit news if he has the `com.change_news` permission + or if he tries to edit his own news : + + ```python + class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView): + model = News + author_field = "author" + permission_required = "com.change_news" + ``` + + This is more or less equivalent to : + + ```python + class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView): + model = News + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + if not ( + user.has_perm("com.change_news") + or self.object.author == request.user + ): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + ``` + """ + + author_field: LiteralString = "author" + + def has_permission(self): + if not hasattr(self, "get_object"): + raise ImproperlyConfigured( + f"{self.__class__.__name__} is missing the " + "get_object attribute. " + f"Define {self.__class__.__name__}.get_object, " + "or inherit from a class that implement it (like DetailView)" + ) + if super().has_permission(): + return True + if self.request.user.is_anonymous: + return False + obj: Model = self.get_object() + if not self.author_field.endswith("_id"): + # getting the related model could trigger a db query + # so we will rather get the foreign value than + # the object itself. + self.author_field += "_id" + author_id = getattr(obj, self.author_field, None) + return author_id == self.request.user.id diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index e3d6d8e4..5e0f099d 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -460,6 +460,7 @@ Welcome to the wiki page! limit_age=18, ) cons = Product.objects.create( + id=settings.SITH_ECOCUP_CONS, name="Consigne Eco-cup", code="CONS", product_type=verre, @@ -469,6 +470,7 @@ Welcome to the wiki page! club=main_club, ) dcons = Product.objects.create( + id=settings.SITH_ECOCUP_DECO, name="Déconsigne Eco-cup", code="DECO", product_type=verre, @@ -676,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, @@ -696,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, @@ -713,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, @@ -730,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, @@ -749,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, @@ -897,6 +894,9 @@ 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", "add_uvcommentreport"])) + ) old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers.permissions.add( *list( diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index fbef3ec2..7acec959 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -5,6 +5,7 @@ from typing import Iterator from dateutil.relativedelta import relativedelta from django.conf import settings +from django.contrib.auth.hashers import make_password from django.core.management.base import BaseCommand from django.db.models import Count, Exists, Min, OuterRef, Subquery from django.utils.timezone import localdate, make_aware, now @@ -38,26 +39,10 @@ class Command(BaseCommand): raise Exception("Never call this command in prod. Never.") self.stdout.write("Creating users...") - users = [ - User( - username=self.faker.user_name(), - first_name=self.faker.first_name(), - last_name=self.faker.last_name(), - date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25), - email=self.faker.email(), - phone=self.faker.phone_number(), - address=self.faker.address(), - ) - for _ in range(600) - ] - # there may a duplicate or two - # Not a problem, we will just have 599 users instead of 600 - User.objects.bulk_create(users, ignore_conflicts=True) - users = list(User.objects.order_by("-id")[: len(users)]) - + users = self.create_users() subscribers = random.sample(users, k=int(0.8 * len(users))) self.stdout.write("Creating subscriptions...") - self.create_subscriptions(users) + self.create_subscriptions(subscribers) self.stdout.write("Creating club memberships...") users_qs = User.objects.filter(id__in=[s.id for s in subscribers]) subscribers_now = list( @@ -102,11 +87,34 @@ class Command(BaseCommand): self.stdout.write("Done") + def create_users(self) -> list[User]: + password = make_password("plop") + users = [ + User( + username=self.faker.user_name(), + first_name=self.faker.first_name(), + last_name=self.faker.last_name(), + date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25), + email=self.faker.email(), + phone=self.faker.phone_number(), + address=self.faker.address(), + password=password, + ) + for _ in range(600) + ] + # there may a duplicate or two + # Not a problem, we will just have 599 users instead of 600 + users = User.objects.bulk_create(users, ignore_conflicts=True) + users = list(User.objects.order_by("-id")[: len(users)]) + public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID) + public_group.users.add(*users) + return users + def create_subscriptions(self, users: list[User]): - def prepare_subscription(user: User, start_date: date) -> Subscription: + def prepare_subscription(_user: User, start_date: date) -> Subscription: payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0] duration = random.randint(1, 4) - sub = Subscription(member=user, payment_method=payment_method) + sub = Subscription(member=_user, payment_method=payment_method) sub.subscription_start = sub.compute_start(d=start_date, duration=duration) sub.subscription_end = sub.compute_end(duration) return sub @@ -130,6 +138,10 @@ class Command(BaseCommand): user, self.faker.past_date(sub.subscription_end) ) subscriptions.append(sub) + old_subscriber_group = Group.objects.get( + pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID + ) + old_subscriber_group.users.add(*users) Subscription.objects.bulk_create(subscriptions) Customer.objects.bulk_create(customers, ignore_conflicts=True) diff --git a/core/models.py b/core/models.py index 278182ac..b1caa912 100644 --- a/core/models.py +++ b/core/models.py @@ -29,6 +29,7 @@ import os import string import unicodedata from datetime import timedelta +from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Optional, Self @@ -50,6 +51,7 @@ from django.utils.html import escape from django.utils.timezone import localdate, now from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField +from PIL import Image if TYPE_CHECKING: from pydantic import NonNegativeInt @@ -320,12 +322,16 @@ class User(AbstractUser): return self.get_display_name() def save(self, *args, **kwargs): + adding = self._state.adding with transaction.atomic(): - if self.id: + if not adding: old = User.objects.filter(id=self.id).first() if old and old.username != self.username: self._change_username(self.username) super().save(*args, **kwargs) + if adding: + # All users are in the public group. + self.groups.add(settings.SITH_GROUP_PUBLIC_ID) def get_absolute_url(self) -> str: return reverse("core:user_profile", kwargs={"user_id": self.pk}) @@ -380,12 +386,8 @@ class User(AbstractUser): raise ValueError("You must either provide the id or the name of the group") if group is None: return False - if group.id == settings.SITH_GROUP_PUBLIC_ID: - return True if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID: return self.is_subscribed - if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID: - return self.was_subscribed if group.id == settings.SITH_GROUP_ROOT_ID: return self.is_root return group in self.cached_groups @@ -988,17 +990,11 @@ class SithFile(models.Model): if self.is_folder: if self.file: try: - import imghdr - - if imghdr.what(None, self.file.read()) not in [ - "gif", - "png", - "jpeg", - ]: - self.file.delete() - self.file = None - except: # noqa E722 I don't know the exception that can be raised - self.file = None + Image.open(BytesIO(self.file.read())) + except Image.UnidentifiedImageError as e: + raise ValidationError( + _("This is not a valid folder thumbnail") + ) from e self.mime_type = "inode/directory" if self.is_file and (self.file is None or self.file == ""): raise ValidationError(_("You must provide a file")) diff --git a/core/static/bundled/core/read-more-index.ts b/core/static/bundled/core/read-more-index.ts new file mode 100644 index 00000000..52e095a3 --- /dev/null +++ b/core/static/bundled/core/read-more-index.ts @@ -0,0 +1,73 @@ +import clip from "@arendjr/text-clipper"; + +/* + This script adds a way to have a 'show more / show less' button + on some text content. + + The usage is very simple, you just have to add the attribute `show-more` + with the desired max size to the element you want to add the button to. + This script does html matching and is able to properly cut rendered markdown. + + Example usage: +

+ My very long text will be cut by this script +

+*/ + +function showMore(element: HTMLElement) { + if (!element.hasAttribute("show-more")) { + return; + } + + // Mark element as loaded so we can hide unloaded + // tags with css and avoid blinking text + element.setAttribute("show-more-loaded", ""); + + const fullContent = element.innerHTML; + const clippedContent = clip( + element.innerHTML, + Number.parseInt(element.getAttribute("show-more") as string), + { + html: true, + }, + ); + + // If already at the desired size, we don't do anything + if (clippedContent === fullContent) { + return; + } + + const actionLink = document.createElement("a"); + actionLink.setAttribute("class", "show-more-link"); + + let opened = false; + + const setText = () => { + if (opened) { + element.innerHTML = fullContent; + actionLink.innerText = gettext("Show less"); + } else { + element.innerHTML = clippedContent; + actionLink.innerText = gettext("Show more"); + } + element.appendChild(document.createElement("br")); + element.appendChild(actionLink); + }; + + const toggle = () => { + opened = !opened; + setText(); + }; + + setText(); + actionLink.addEventListener("click", (event) => { + event.preventDefault(); + toggle(); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + for (const elem of document.querySelectorAll("[show-more]")) { + showMore(elem as HTMLElement); + } +}); diff --git a/core/static/core/components/ajax-select.scss b/core/static/core/components/ajax-select.scss index cb9d466c..59d146fc 100644 --- a/core/static/core/components/ajax-select.scss +++ b/core/static/core/components/ajax-select.scss @@ -1,11 +1,27 @@ +.ts-wrapper.multi .ts-control { + min-width: calc(100% - 0.2rem); +} + /* This also requires ajax-select-index.css */ .ts-dropdown { + width: calc(100% - 0.2rem); + left: 0.1rem; + top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width)); + border: var(--nf-input-border-color) var(--nf-input-border-width) solid; + border-top: none; + border-bottom-width: var(--nf-input-border-bottom-width); + + .option.active { + background-color: #e5eafa; + color: inherit; + } .select-item { display: flex; flex-direction: row; gap: 10px; align-items: center; + overflow: hidden; img { height: 40px; @@ -16,19 +32,44 @@ } } -.ts-wrapper { - margin: 5px; +.ts-wrapper.single { + > .ts-control { + box-shadow: none; + max-width: 300px; + background-color: var(--nf-input-background-color); + + &::after { + content: none; + } + } + + > .ts-dropdown { + max-width: 300px; + } } -.ts-wrapper.single { - width: 263px; // same length as regular text inputs +.ts-wrapper input[type="text"] { + border: none; + border-radius: 0; +} + +.ts-wrapper.multi, .ts-wrapper.single { + .ts-control:has(input:focus) { + outline: none; + border-color: var(--nf-input-focus-border-color); + box-shadow: none; + } } .ts-wrapper.plugin-remove_button:not(.rtl) .item .remove { border-left: 1px solid #aaa; } -.ts-wrapper.multi .ts-control { +.ts-wrapper.multi.has-items .ts-control { + padding: calc(var(--nf-input-size) * 0.65); + display: flex; + gap: calc(var(--nf-input-size) / 3); + [data-value], [data-value].active { background-image: none; @@ -37,19 +78,17 @@ border: 1px solid #aaa; border-radius: 4px; display: inline-block; - margin-left: 5px; - margin-top: 5px; - margin-bottom: 5px; padding-right: 10px; padding-left: 10px; text-shadow: none; box-shadow: none; + + .remove { + vertical-align: baseline; + } } } -.ts-dropdown { - .option.active { - background-color: #e5eafa; - color: inherit; - } -} \ No newline at end of file +.ts-wrapper.focus .ts-control { + box-shadow: none; +} diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 1d0fa1bc..9982e77f 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -48,7 +48,8 @@ input, textarea[type="text"], - [type="number"] { + [type="number"], + .ts-control { border: none; text-decoration: none; background-color: $background-button-color; @@ -69,7 +70,7 @@ font-family: sans-serif; } - select { + select, .ts-control { border: none; text-decoration: none; font-size: 1.2em; @@ -177,7 +178,7 @@ form { } // wrap texts - label, legend, ul.errorlist>li, .helptext { + label, legend, ul.errorlist > li, .helptext { text-wrap: wrap; } @@ -218,23 +219,25 @@ form { } } - input[type="text"], - input[type="email"], - input[type="tel"], - input[type="url"], - input[type="password"], - input[type="number"], - input[type="date"], - input[type="week"], - input[type="time"], - input[type="month"], - input[type="search"], - textarea, - select { - min-width: 300px; + :not(.ts-control) > { + input[type="text"], + input[type="email"], + input[type="tel"], + input[type="url"], + input[type="password"], + input[type="number"], + input[type="date"], + input[type="week"], + input[type="time"], + input[type="search"], + textarea, + input[type="month"], + select { + min-width: 300px; - &.grow { - width: 95%; + &.grow { + width: 95%; + } } } @@ -253,7 +256,8 @@ form { input[type="month"], input[type="search"], textarea, - select { + select, + .ts-control { background: var(--nf-input-background-color); font-size: var(--nf-input-font-size); border-color: var(--nf-input-border-color); @@ -661,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( @@ -713,7 +719,11 @@ form { // ---------------- SELECT - select { + select, + .ts-wrapper.multi .ts-control, + .ts-wrapper.single .ts-control, + .ts-wrapper.single.input-active .ts-control { + background-color: var(--nf-input-background-color); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%; background-repeat: no-repeat; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 913733d6..a89d33cf 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -131,6 +131,10 @@ body { display: none !important; } +[show-more]:not([show-more-loaded]) { + display: none !important; +} + /*--------------------------------HEADER-------------------------------*/ #popupheader { @@ -432,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/templates/core/base.jinja b/core/templates/core/base.jinja index 17b9befa..ef184fbf 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -125,15 +125,14 @@ navbar.style.setProperty("display", current === "none" ? "block" : "none"); } - $(document).keydown(function (e) { - if ($(e.target).is('input')) { return } - if ($(e.target).is('textarea')) { return } - if ($(e.target).is('select')) { return } - if (e.keyCode === 83) { - $("#search").focus(); - return false; + document.addEventListener("keydown", (e) => { + // Looking at the `s` key when not typing in a form + if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) { + return; } - }); + document.getElementById("search").focus(); + e.preventDefault(); // Don't type the character in the focused search input + }) {% endblock %} diff --git a/core/templates/core/file.jinja b/core/templates/core/file.jinja index 95c29bdc..09b86936 100644 --- a/core/templates/core/file.jinja +++ b/core/templates/core/file.jinja @@ -57,13 +57,4 @@ {% endblock %} {% endif %} - {% block script %} - {{ super() }} - {% if popup %} - - {% endif %} - {% endblock %} - {% endblock %} diff --git a/core/templates/core/user_clubs.jinja b/core/templates/core/user_clubs.jinja index 324f8389..51b6827f 100644 --- a/core/templates/core/user_clubs.jinja +++ b/core/templates/core/user_clubs.jinja @@ -30,7 +30,7 @@ {% if m.can_be_edited_by(user) %}
{% trans %}Mark as old{% endtrans %}{% trans %}Delete{% endtrans %}
{{ m.description }} {{ m.start_date }} {{ m.end_date }}{% trans %}Delete{% endtrans %}

{{ role.title }}

-

{{ role.description }}

+

{{ role.description }}

{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %} {% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %} {%- endif %} @@ -139,7 +139,9 @@
{{ candidature.user.first_name }} {{candidature.user.nick_name or ''}} {{ candidature.user.last_name }}
{%- if not election.is_vote_finished %} - {{ candidature.program | markdown or '' }} + + {{ candidature.program|markdown or '' }} + {%- endif %}
{%- if user.can_edit(candidature) -%} @@ -198,18 +200,6 @@ {% block script %} {{ super() }} -