Merge pull request #1005 from ae-utbm/taiste

More group rework, ajax input style, news creation form rework and counter fixes
This commit is contained in:
thomas girod 2025-01-14 22:06:52 +01:00 committed by GitHub
commit 170f9dde61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 3914 additions and 2984 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@ -12,7 +12,7 @@ repos:
rev: "v0.1.0" # Use the sha / tag you want to point at rev: "v0.1.0" # Use the sha / tag you want to point at
hooks: hooks:
- id: biome-check - id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.3"] additional_dependencies: ["@biomejs/biome@1.9.4"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: 3.0.7 rev: 3.0.7
hooks: hooks:

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from accounting.models import ClubAccount, Company from accounting.models import ClubAccount, Company
from accounting.schemas import ClubAccountSchema, CompanySchema from accounting.schemas import ClubAccountSchema, CompanySchema
from core.api_permissions import CanAccessLookup from core.auth.api_permissions import CanAccessLookup
@api_controller("/lookup", permissions=[CanAccessLookup]) @api_controller("/lookup", permissions=[CanAccessLookup])

View File

@ -17,6 +17,7 @@ import collections
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
@ -44,15 +45,15 @@ from accounting.widgets.select import (
) )
from club.models import Club from club.models import Club
from club.widgets.select import AutoCompleteSelectClub from club.widgets.select import AutoCompleteSelectClub
from core.models import User from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
TabedViewMixin,
) )
from core.models import User
from core.views.forms import SelectDate, SelectFile from core.views.forms import SelectDate, SelectFile
from core.views.mixins import TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Counter, Product, Selling from counter.models import Counter, Product, Selling
@ -86,12 +87,13 @@ class SimplifiedAccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class SimplifiedAccountingTypeCreateView(CanCreateMixin, CreateView): class SimplifiedAccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins).""" """Create an accounting type (for the admins)."""
model = SimplifiedAccountingType model = SimplifiedAccountingType
fields = ["label", "accounting_type"] fields = ["label", "accounting_type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "accounting.add_simplifiedaccountingtype"
# Accounting types # Accounting types
@ -113,12 +115,13 @@ class AccountingTypeEditView(CanViewMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class AccountingTypeCreateView(CanCreateMixin, CreateView): class AccountingTypeCreateView(PermissionRequiredMixin, CreateView):
"""Create an accounting type (for the admins).""" """Create an accounting type (for the admins)."""
model = AccountingType model = AccountingType
fields = ["code", "label", "movement_type"] fields = ["code", "label", "movement_type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "accounting.add_accountingtype"
# BankAccount views # BankAccount views

View File

@ -7,7 +7,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Club from club.models import Club
from club.schemas import ClubSchema from club.schemas import ClubSchema
from core.api_permissions import CanAccessLookup from core.auth.api_permissions import CanAccessLookup
@api_controller("/club") @api_controller("/club")

View File

@ -213,9 +213,9 @@ class TestMembershipQuerySet(TestClub):
memberships[1].club.members_group, memberships[1].club.members_group,
memberships[1].club.board_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() user.memberships.all().delete()
assert user.groups.all().count() == 0 assert set(user.groups.all()).isdisjoint(club_groups)
class TestClubModel(TestClub): class TestClubModel(TestClub):

View File

@ -25,6 +25,7 @@
import csv import csv
from django.conf import settings 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.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Sum
@ -49,17 +50,15 @@ from com.views import (
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.models import PageRev from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, 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 from counter.models import Selling
@ -474,13 +473,14 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "props" current_tab = "props"
class ClubCreateView(CanCreateMixin, CreateView): class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin).""" """Create a club (for the Sith admin)."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"] fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView): 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).""" """Delete a membership (for admins only)."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "club.delete_membership"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})

View File

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

193
com/forms.py Normal file
View File

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

View File

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

View File

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

View File

@ -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 django.dispatch import receiver
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.models import News 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): def update_internal_ics(*args, **kwargs):
_ = IcsCalendar.make_internal() _ = IcsCalendar.make_internal()

View File

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

View File

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

View File

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

View File

@ -15,37 +15,21 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if user.is_com_admin %}
<div id="news_admin">
<a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
</div>
<br>
{% endif %}
<div id="news"> <div id="news">
<div id="left_column" class="news_column"> <div id="left_column" class="news_column">
{% for news in object_list.filter(type="NOTICE") %} {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}
<section class="news_notice">
<h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
<section class="news_call">
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}</div>
</section>
{% endfor %}
{% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
<h3>{% trans %}Events today and the next few days{% endtrans %}</h3> <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
{% endif %}
{% if user.is_com_admin %}
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
<br>
{% endif %}
{% if events_dates %} {% if events_dates %}
{% for d in events_dates %} {% for d in events_dates %}
<div class="news_events_group"> <div class="news_events_group">
@ -57,113 +41,104 @@
</div> </div>
</div> </div>
<div class="news_events_group_items"> <div class="news_events_group_items">
{% for news in object_list.filter(dates__start_date__gte=d, {% for news in object_list.filter(dates__start_date__gte=d,dates__start_date__lte=d+timedelta(days=1)).exclude(dates__end_date__lt=timezone.now()).order_by('dates__start_date') %}
dates__start_date__lte=d+timedelta(days=1), <section class="news_event">
type="EVENT").exclude(dates__end_date__lt=timezone.now()) <div class="club_logo">
.order_by('dates__start_date') %} {% if news.club.logo %}
<section class="news_event"> <img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
<div class="club_logo"> {% else %}
{% if news.club.logo %} <img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
<img src="{{ news.club.logo.url }}" alt="{{ news.club }}" /> {% endif %}
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
{% endif %}
</div>
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
</div> </div>
</div> <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
</section> <div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
</div>
</div>
</section>
{% endfor %}
</div>
</div>
{% endfor %} {% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div> </div>
</div> {% endif %}
{% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<h3>{% trans %}All coming events{% endtrans %}</h3> <ics-calendar locale="{{ get_language() }}"></ics-calendar>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<div id="right_column">
<div id="links">
<h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content">
<h4>{% trans %}Our services{% endtrans %}</h4>
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/XK9WfPsUFm">{% trans %}Dev Team{% endtrans %}</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
</li>
</ul>
</div> </div>
</div>
<div id="birthdays"> <div id="right_column">
<h3>{% trans %}Birthdays{% endtrans %}</h3> <div id="links">
<div id="birthdays_content"> <h3>{% trans %}Links{% endtrans %}</h3>
{%- if user.was_subscribed -%} <div id="links_content">
<ul class="birthdays_year"> <h4>{% trans %}Our services{% endtrans %}</h4>
{%- for year, users in birthdays -%} <ul>
<li> <li>
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %} <i class="fa-solid fa-graduation-cap fa-xl"></i>
<ul> <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{%- endfor -%}
</ul>
</li> </li>
{%- endfor -%} <li>
</ul> <i class="fa-solid fa-magnifying-glass fa-xl"></i>
{%- else -%} <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p> </li>
{%- endif -%} <li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
</li>
</ul>
</div>
</div>
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.was_subscribed -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
<ul>
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{%- endfor -%}
</ul>
</li>
{%- endfor -%}
</ul>
{%- else -%}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- endif -%}
</div>
</div>
</div> </div>
</div> </div>
</div>
</div>
{% endblock %} {% endblock %}

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

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

View File

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

View File

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

View File

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

View File

@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing from club.models import Mailing
from core.api_permissions import ( from core.auth.api_permissions import CanAccessLookup, CanView
CanAccessLookup,
CanView,
)
from core.models import Group, SithFile, User from core.models import Group, SithFile, User
from core.schemas import ( from core.schemas import (
FamilyGodfatherSchema, FamilyGodfatherSchema,

0
core/auth/__init__.py Normal file
View File

View File

@ -3,7 +3,8 @@
Some permissions are global (like `IsInGroup` or `IsRoot`), Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`). and some others are per-object (like `CanView` or `CanEdit`).
Examples: Example:
```python
# restrict all the routes of this controller # restrict all the routes of this controller
# to subscribed users # to subscribed users
@api_controller("/foo", permissions=[IsSubscriber]) @api_controller("/foo", permissions=[IsSubscriber])
@ -33,6 +34,7 @@ Examples:
] ]
def bar_delete(self, bar_id: int): def bar_delete(self, bar_id: int):
# ... # ...
```
""" """
from typing import Any from typing import Any

287
core/auth/mixins.py Normal file
View File

@ -0,0 +1,287 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr>
#
# 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

View File

@ -460,6 +460,7 @@ Welcome to the wiki page!
limit_age=18, limit_age=18,
) )
cons = Product.objects.create( cons = Product.objects.create(
id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup", name="Consigne Eco-cup",
code="CONS", code="CONS",
product_type=verre, product_type=verre,
@ -469,6 +470,7 @@ Welcome to the wiki page!
club=main_club, club=main_club,
) )
dcons = Product.objects.create( dcons = Product.objects.create(
id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
code="DECO", code="DECO",
product_type=verre, product_type=verre,
@ -676,7 +678,6 @@ Welcome to the wiki page!
title="Apero barman", title="Apero barman",
summary="Viens boire un coup avec les barmans", summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -696,7 +697,6 @@ Welcome to the wiki page!
"Viens donc t'enjailler avec les autres barmans aux " "Viens donc t'enjailler avec les autres barmans aux "
"frais du BdF! \\o/" "frais du BdF! \\o/"
), ),
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -713,7 +713,6 @@ Welcome to the wiki page!
title="Repas fromager", title="Repas fromager",
summary="Wien manger du l'bon fromeug'", summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -730,7 +729,6 @@ Welcome to the wiki page!
title="SdF", title="SdF",
summary="Enjoy la fin des finaux!", summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -749,7 +747,6 @@ Welcome to the wiki page!
summary="Viens jouer!", summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens " content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
type="WEEKLY",
club=troll, club=troll,
author=subscriber, author=subscriber,
is_moderated=True, is_moderated=True,
@ -897,6 +894,9 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
)
old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(

View File

@ -5,6 +5,7 @@ from typing import Iterator
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Count, Exists, Min, OuterRef, Subquery from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now 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.") raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = [ users = self.create_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)])
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
self.create_subscriptions(users) self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...") self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers]) users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list( subscribers_now = list(
@ -102,11 +87,34 @@ class Command(BaseCommand):
self.stdout.write("Done") 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 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] payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4) 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_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration) sub.subscription_end = sub.compute_end(duration)
return sub return sub
@ -130,6 +138,10 @@ class Command(BaseCommand):
user, self.faker.past_date(sub.subscription_end) user, self.faker.past_date(sub.subscription_end)
) )
subscriptions.append(sub) 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) Subscription.objects.bulk_create(subscriptions)
Customer.objects.bulk_create(customers, ignore_conflicts=True) Customer.objects.bulk_create(customers, ignore_conflicts=True)

View File

@ -29,6 +29,7 @@ import os
import string import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self 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.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image
if TYPE_CHECKING: if TYPE_CHECKING:
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
@ -320,12 +322,16 @@ class User(AbstractUser):
return self.get_display_name() return self.get_display_name()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
adding = self._state.adding
with transaction.atomic(): with transaction.atomic():
if self.id: if not adding:
old = User.objects.filter(id=self.id).first() old = User.objects.filter(id=self.id).first()
if old and old.username != self.username: if old and old.username != self.username:
self._change_username(self.username) self._change_username(self.username)
super().save(*args, **kwargs) 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: def get_absolute_url(self) -> str:
return reverse("core:user_profile", kwargs={"user_id": self.pk}) 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") raise ValueError("You must either provide the id or the name of the group")
if group is None: if group is None:
return False return False
if group.id == settings.SITH_GROUP_PUBLIC_ID:
return True
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID: if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed 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: if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root return self.is_root
return group in self.cached_groups return group in self.cached_groups
@ -988,17 +990,11 @@ class SithFile(models.Model):
if self.is_folder: if self.is_folder:
if self.file: if self.file:
try: try:
import imghdr Image.open(BytesIO(self.file.read()))
except Image.UnidentifiedImageError as e:
if imghdr.what(None, self.file.read()) not in [ raise ValidationError(
"gif", _("This is not a valid folder thumbnail")
"png", ) from e
"jpeg",
]:
self.file.delete()
self.file = None
except: # noqa E722 I don't know the exception that can be raised
self.file = None
self.mime_type = "inode/directory" self.mime_type = "inode/directory"
if self.is_file and (self.file is None or self.file == ""): if self.is_file and (self.file is None or self.file == ""):
raise ValidationError(_("You must provide a file")) raise ValidationError(_("You must provide a file"))

View File

@ -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:
<p show-more="20">
My very long text will be cut by this script
</p>
*/
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);
}
});

View File

@ -1,11 +1,27 @@
.ts-wrapper.multi .ts-control {
min-width: calc(100% - 0.2rem);
}
/* This also requires ajax-select-index.css */ /* This also requires ajax-select-index.css */
.ts-dropdown { .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 { .select-item {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
overflow: hidden;
img { img {
height: 40px; height: 40px;
@ -16,19 +32,44 @@
} }
} }
.ts-wrapper { .ts-wrapper.single {
margin: 5px; > .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 { .ts-wrapper input[type="text"] {
width: 263px; // same length as regular text inputs 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 { .ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa; 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],
[data-value].active { [data-value].active {
background-image: none; background-image: none;
@ -37,19 +78,17 @@
border: 1px solid #aaa; border: 1px solid #aaa;
border-radius: 4px; border-radius: 4px;
display: inline-block; display: inline-block;
margin-left: 5px;
margin-top: 5px;
margin-bottom: 5px;
padding-right: 10px; padding-right: 10px;
padding-left: 10px; padding-left: 10px;
text-shadow: none; text-shadow: none;
box-shadow: none; box-shadow: none;
.remove {
vertical-align: baseline;
}
} }
} }
.ts-dropdown { .ts-wrapper.focus .ts-control {
.option.active { box-shadow: none;
background-color: #e5eafa;
color: inherit;
}
} }

View File

@ -48,7 +48,8 @@
input, input,
textarea[type="text"], textarea[type="text"],
[type="number"] { [type="number"],
.ts-control {
border: none; border: none;
text-decoration: none; text-decoration: none;
background-color: $background-button-color; background-color: $background-button-color;
@ -69,7 +70,7 @@
font-family: sans-serif; font-family: sans-serif;
} }
select { select, .ts-control {
border: none; border: none;
text-decoration: none; text-decoration: none;
font-size: 1.2em; font-size: 1.2em;
@ -177,7 +178,7 @@ form {
} }
// wrap texts // wrap texts
label, legend, ul.errorlist>li, .helptext { label, legend, ul.errorlist > li, .helptext {
text-wrap: wrap; text-wrap: wrap;
} }
@ -218,23 +219,25 @@ form {
} }
} }
input[type="text"], :not(.ts-control) > {
input[type="email"], input[type="text"],
input[type="tel"], input[type="email"],
input[type="url"], input[type="tel"],
input[type="password"], input[type="url"],
input[type="number"], input[type="password"],
input[type="date"], input[type="number"],
input[type="week"], input[type="date"],
input[type="time"], input[type="week"],
input[type="month"], input[type="time"],
input[type="search"], input[type="search"],
textarea, textarea,
select { input[type="month"],
min-width: 300px; select {
min-width: 300px;
&.grow { &.grow {
width: 95%; width: 95%;
}
} }
} }
@ -253,7 +256,8 @@ form {
input[type="month"], input[type="month"],
input[type="search"], input[type="search"],
textarea, textarea,
select { select,
.ts-control {
background: var(--nf-input-background-color); background: var(--nf-input-background-color);
font-size: var(--nf-input-font-size); font-size: var(--nf-input-font-size);
border-color: var(--nf-input-border-color); border-color: var(--nf-input-border-color);
@ -661,7 +665,9 @@ form {
} }
&:checked { &:checked {
background: var(--nf-input-focus-border-color) none initial; background: none;
background-position: 0 0;
background-color: var(--nf-input-focus-border-color);
&::after { &::after {
transform: translateY(-50%) translateX( transform: translateY(-50%) translateX(
@ -713,7 +719,11 @@ form {
// ---------------- SELECT // ---------------- 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-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-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
background-repeat: no-repeat; background-repeat: no-repeat;

View File

@ -131,6 +131,10 @@ body {
display: none !important; display: none !important;
} }
[show-more]:not([show-more-loaded]) {
display: none !important;
}
/*--------------------------------HEADER-------------------------------*/ /*--------------------------------HEADER-------------------------------*/
#popupheader { #popupheader {
@ -432,8 +436,8 @@ body {
$row-gap: 0.5rem; $row-gap: 0.5rem;
&.gap { &.gap {
column-gap: var($col-gap); column-gap: $col-gap;
row-gap: var($row-gap); row-gap: $row-gap;
} }
@for $i from 2 through 5 { @for $i from 2 through 5 {

View File

@ -125,15 +125,14 @@
navbar.style.setProperty("display", current === "none" ? "block" : "none"); navbar.style.setProperty("display", current === "none" ? "block" : "none");
} }
$(document).keydown(function (e) { document.addEventListener("keydown", (e) => {
if ($(e.target).is('input')) { return } // Looking at the `s` key when not typing in a form
if ($(e.target).is('textarea')) { return } if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
if ($(e.target).is('select')) { return } return;
if (e.keyCode === 83) {
$("#search").focus();
return false;
} }
}); document.getElementById("search").focus();
e.preventDefault(); // Don't type the character in the focused search input
})
</script> </script>
{% endblock %} {% endblock %}
</body> </body>

View File

@ -57,13 +57,4 @@
{% endblock %} {% endblock %}
{% endif %} {% endif %}
{% block script %}
{{ super() }}
{% if popup %}
<script>
parent.$(".choose_file_widget").css("height", "75%");
</script>
{% endif %}
{% endblock %}
{% endblock %} {% endblock %}

View File

@ -30,7 +30,7 @@
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td> <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
{% endif %} {% endif %}
{% if user.is_root %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %} {% endif %}
</tr> </tr>
@ -59,7 +59,7 @@
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ m.end_date }}</td>
{% if user.is_root %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
{% endif %} {% endif %}
</tr> </tr>

View File

@ -244,27 +244,30 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
$(function () { // Image selection
var keys = []; for (const img of document.querySelectorAll("#small_pictures img")){
var pattern = "71,85,89,71,85,89"; img.addEventListener("click", (e) => {
$(document).keydown(function (e) { const displayed = document.querySelector("#big_picture img");
keys.push(e.keyCode); displayed.src = e.target.src;
if (keys.toString() == pattern) { displayed.alt = e.target.alt;
keys = []; displayed.title = e.target.title;
$("#big_picture img").attr("src", "{{ static('core/img/yug.jpg') }}");
}
if (keys.length == 6) {
keys.shift();
}
});
});
$(function () {
$("#small_pictures img").click(function () {
$("#big_picture img").attr("src", $(this)[0].src);
$("#big_picture img").attr("alt", $(this)[0].alt);
$("#big_picture img").attr("title", $(this)[0].title);
}) })
}
let keys = [];
const pattern = "71,85,89,71,85,89";
document.addEventListener("keydown", (e) => {
keys.push(e.keyCode);
if (keys.toString() === pattern) {
keys = [];
document.querySelector("#big_picture img").src = "{{ static('core/img/yug.jpg') }}";
}
if (keys.length === 6) {
keys.shift();
}
}); });
$(function () { $(function () {
$("#drop_gifts").accordion({ $("#drop_gifts").accordion({
heightStyle: "content", heightStyle: "content",

View File

@ -23,7 +23,7 @@
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li> <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li> <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
{% endif %} {% endif %}
{% if user.has_perm("core:view_userban") %} {% if user.has_perm("core.view_userban") %}
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li> <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
{% endif %} {% endif %}
{% if user.can_create_subscription or user.is_root %} {% if user.can_create_subscription or user.is_root %}

View File

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

View File

@ -327,12 +327,9 @@ http://git.an
class TestUserTools: class TestUserTools:
def test_anonymous_user_unauthorized(self, client): def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to the tools page.""" """An anonymous user shouldn't have access to the tools page."""
response = client.get(reverse("core:user_tools")) url = reverse("core:user_tools")
assertRedirects( response = client.get(url)
response, assertRedirects(response, expected_url=reverse("core:login") + f"?next={url}")
expected_url="/login?next=%2Fuser%2Ftools%2F",
target_status_code=301,
)
@pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"]) @pytest.mark.parametrize("username", ["guy", "root", "skia", "comunity"])
def test_page_is_working(self, client, username): def test_page_is_working(self, client, username):

View File

@ -9,6 +9,7 @@ from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe, foreign_key
from com.models import News
from core.baker_recipes import ( from core.baker_recipes import (
old_subscriber_user, old_subscriber_user,
subscriber_user, subscriber_user,
@ -22,6 +23,8 @@ from eboutic.models import Invoice, InvoiceItem
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
@ -187,3 +190,11 @@ def test_generate_username(first_name: str, last_name: str, expected: str):
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com") new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
new_user.generate_username() new_user.generate_username()
assert new_user.username == expected assert new_user.username == expected
@pytest.mark.django_db
def test_user_added_to_public_group():
"""Test that newly created users are added to the public group"""
user = baker.make(User)
assert user.groups.filter(pk=settings.SITH_GROUP_PUBLIC_ID).exists()
assert user.is_in_group(pk=settings.SITH_GROUP_PUBLIC_ID)

View File

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

View File

@ -22,15 +22,6 @@
# #
# #
import types
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
)
from django.http import ( from django.http import (
HttpResponseForbidden, HttpResponseForbidden,
HttpResponseNotFound, HttpResponseNotFound,
@ -38,12 +29,10 @@ from django.http import (
) )
from django.shortcuts import render from django.shortcuts import render
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from core.models import User
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -65,254 +54,6 @@ def internal_servor_error(request):
return HttpResponseServerError(render(request, "core/500.jinja")) return HttpResponseServerError(render(request, "core/500.jinja"))
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
Examples:
```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
Examples:
```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
Examples:
```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 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 UserIsRootMixin(GenericContentPermissionMixinBuilder):
"""Allow only root admins.
Raises:
PermissionDenied: if the user isn't root
"""
@staticmethod
def permission_function(obj: Any, user: User):
return user.is_root
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 SubscriberMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class DetailFormView(SingleObjectMixin, FormView): class DetailFormView(SingleObjectMixin, FormView):
"""Class that allow both a detail view and a form view.""" """Class that allow both a detail view and a form view."""
@ -326,14 +67,6 @@ class DetailFormView(SingleObjectMixin, FormView):
return super().get_object() return super().get_object()
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)
# F403: those star-imports would be hellish to refactor # F403: those star-imports would be hellish to refactor
# E402: putting those import at the top of the file would also be difficult # E402: putting those import at the top of the file would also be difficult
from .files import * # noqa: F403 E402 from .files import * # noqa: F403 E402

View File

@ -33,14 +33,14 @@ from django.views.generic import DetailView, ListView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import DeleteView, FormMixin, UpdateView from django.views.generic.edit import DeleteView, FormMixin, UpdateView
from core.models import Notification, SithFile, User from core.auth.mixins import (
from core.views import (
AllowFragment,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
can_view, can_view,
) )
from core.models import Notification, SithFile, User
from core.views.mixins import AllowFragment
from core.views.widgets.select import ( from core.views.widgets.select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectSithFile, AutoCompleteSelectSithFile,

View File

@ -16,13 +16,15 @@
"""Views to manage Groups.""" """Views to manage Groups."""
from django import forms from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin
from core.models import Group, User from core.models import Group, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import DetailFormView
from core.views.widgets.select import AutoCompleteSelectMultipleUser from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms # Forms
@ -73,13 +75,14 @@ class GroupEditView(CanEditMixin, UpdateView):
fields = ["name", "description"] fields = ["name", "description"]
class GroupCreateView(CanCreateMixin, CreateView): class GroupCreateView(PermissionRequiredMixin, CreateView):
"""Add a new Group.""" """Add a new Group."""
model = Group model = Group
queryset = Group.objects.filter(is_manually_manageable=True) queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja" template_name = "core/create.jinja"
fields = ["name", "description"] fields = ["name", "description"]
permission_required = "core.add_group"
class GroupTemplateView(CanEditMixin, DetailFormView): class GroupTemplateView(CanEditMixin, DetailFormView):

67
core/views/mixins.py Normal file
View File

@ -0,0 +1,67 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.views import View
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)

View File

@ -21,8 +21,13 @@ from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev from core.models import LockError, Page, PageRev
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from core.views.forms import PageForm, PagePropForm from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput

View File

@ -54,14 +54,8 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User from core.models import Gift, Preferences, User
from core.views import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.views.forms import ( from core.views.forms import (
GiftForm, GiftForm,
LoginForm, LoginForm,
@ -70,6 +64,7 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from counter.views.student_card import StudentCardFormView from counter.views.student_card import StudentCardFormView
from eboutic.models import Invoice from eboutic.models import Invoice

View File

@ -20,7 +20,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from counter.models import Counter, Product, ProductType from counter.models import Counter, Product, ProductType
from counter.schemas import ( from counter.schemas import (
CounterFilterSchema, CounterFilterSchema,

View File

@ -76,7 +76,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return { return {
...super.tomSelectSettings(), ...super.tomSelectSettings(),
openOnFocus: false, openOnFocus: false,
searchField: ["code", "text"], // We make searching on exact code matching a higher priority
// We need to manually set weights or it results on an inconsistent
// behavior between production and development environment
searchField: [
// @ts-ignore documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 },
// @ts-ignore documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 },
],
}; };
} }
} }

View File

@ -236,6 +236,10 @@ class TestCounterClick(TestFullClickBase):
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID) BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
) )
cls.gift = product_recipe.make(
selling_price="-1.5",
special_selling_price="-1.5",
)
cls.beer = product_recipe.make( cls.beer = product_recipe.make(
limit_age=18, selling_price="1.5", special_selling_price="1" limit_age=18, selling_price="1.5", special_selling_price="1"
) )
@ -253,7 +257,12 @@ class TestCounterClick(TestFullClickBase):
limit_age=0, selling_price="1.5", special_selling_price="1" limit_age=0, selling_price="1.5", special_selling_price="1"
) )
cls.counter.products.add(cls.beer, cls.beer_tap, cls.snack) cls.cons = Product.objects.get(id=settings.SITH_ECOCUP_CONS)
cls.dcons = Product.objects.get(id=settings.SITH_ECOCUP_DECO)
cls.counter.products.add(
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
)
cls.other_counter.products.add(cls.snack) cls.other_counter.products.add(cls.snack)
@ -594,6 +603,84 @@ class TestCounterClick(TestFullClickBase):
else: else:
assert not counter.has_annotated_barman assert not counter.has_annotated_barman
def test_selling_ordering(self):
# Cheaper items should be processed with a higher priority
self.login_in_bar(self.barmen)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.beer.id, 1),
BasketItem(self.gift.id, 1),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == 0
def test_recordings(self):
self.refill_user(self.customer, self.cons.selling_price * 3)
self.login_in_bar(self.barmen)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.cons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == 0
assert (
self.submit_basket(
self.customer,
[BasketItem(self.dcons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
assert (
self.submit_basket(
self.customer,
[BasketItem(self.dcons.id, settings.SITH_ECOCUP_LIMIT)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-3 - settings.SITH_ECOCUP_LIMIT
)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.dcons.id, 1)],
).status_code
== 200
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-3 - settings.SITH_ECOCUP_LIMIT
)
assert (
self.submit_basket(
self.customer,
[
BasketItem(self.cons.id, 1),
BasketItem(self.dcons.id, 1),
],
).status_code
== 302
)
assert self.updated_amount(self.customer) == self.dcons.selling_price * (
-3 - settings.SITH_ECOCUP_LIMIT
)
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod

View File

@ -24,8 +24,8 @@ from django.utils import timezone
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin
from counter.forms import CounterEditForm, ProductEditForm from counter.forms import CounterEditForm, ProductEditForm
from counter.models import Counter, Product, ProductType, Refilling, Selling from counter.models import Counter, Product, ProductType, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter

View File

@ -23,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView from django.views.generic.edit import UpdateView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import CashSummaryFormBase from counter.forms import CashSummaryFormBase
from counter.models import ( from counter.models import (
CashRegisterSummary, CashRegisterSummary,

View File

@ -31,9 +31,9 @@ from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin
from counter.forms import RefillForm from counter.forms import RefillForm
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@ -194,7 +194,11 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
with transaction.atomic(): with transaction.atomic():
self.request.session["last_basket"] = [] self.request.session["last_basket"] = []
for form in formset: # We sort items from cheap to expensive
# This is important because some items have a negative price
# Negative priced items gives money to the customer and should
# be processed first so that we don't throw a not enough money error
for form in sorted(formset, key=lambda form: form.product.price):
self.request.session["last_basket"].append( self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.product.name}" f"{form.cleaned_data['quantity']} x {form.product.name}"
) )

View File

@ -18,7 +18,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm from counter.forms import EticketForm
from counter.models import Eticket, Selling from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin

View File

@ -22,7 +22,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.edit import FormMixin, ProcessFormView from django.views.generic.edit import FormMixin, ProcessFormView
from core.views import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.forms import LoginForm from core.views.forms import LoginForm
from counter.forms import GetUserForm from counter.forms import GetUserForm
from counter.models import Counter from counter.models import Counter

View File

@ -19,7 +19,7 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View from django.views.generic.base import View
from core.views import TabedViewMixin from core.views.mixins import TabedViewMixin
class CounterAdminMixin(View): class CounterAdminMixin(View):

View File

@ -21,8 +21,8 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from core.auth.mixins import can_edit
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import can_edit
from counter.forms import StudentCardForm from counter.forms import StudentCardForm
from counter.models import Customer, StudentCard from counter.models import Customer, StudentCard
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter

View File

@ -1 +0,0 @@
::: core.api_permissions

View File

@ -0,0 +1,32 @@
## Backend
::: core.auth.backends
handler: python
options:
heading_level: 3
members:
- SithModelBackend
## Mixins
::: core.auth.mixins
handler: python
options:
heading_level: 3
members:
- can_edit_prop
- can_edit
- can_view
- CanCreateMixin
- CanEditMixin
- CanViewMixin
- FormerSubscriberMixin
- PermissionOrAuthorRequiredMixin
## API Permissions
::: core.auth.api_permissions
handler: python
options:
heading_level: 3

View File

@ -157,7 +157,9 @@ il est automatiquement ajouté au groupe des membres
du club. du club.
Lorsqu'il quitte le club, il est retiré du groupe. Lorsqu'il quitte le club, il est retiré du groupe.
## Les principaux groupes utilisés ## Les groupes utilisés
### Groupes principaux
Les groupes les plus notables gérables par les administrateurs du site sont : Les groupes les plus notables gérables par les administrateurs du site sont :
@ -168,15 +170,61 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
- `SAS admin` : les administrateurs du SAS - `SAS admin` : les administrateurs du SAS
- `Forum admin` : les administrateurs du forum - `Forum admin` : les administrateurs du forum
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs) - `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation
En plus de ces groupes, on peut noter : En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site - `Public` : tous les utilisateurs du site.
- `Subscribers` : tous les cotisants du site Un utilisateur est automatiquement ajouté à ce group
- `Old subscribers` : tous les anciens cotisants lors de la création de son compte.
- `Subscribers` : tous les cotisants du site.
Les utilisateurs ne sont pas réellement ajoutés ce groupe ;
cependant, les utilisateurs cotisants sont implicitement
considérés comme membres du groupe lors de l'appel
à la méthode `User.has_perm`.
- `Old subscribers` : tous les anciens cotisants.
Un utilisateur est automatiquement ajouté à ce groupe
lors de sa première cotisation
!!!note "Utilisation du groupe Public"
Le groupe Public est un groupe particulier.
Tout le monde faisant partie de ce groupe
(même les utilisateurs non-connectés en sont implicitement
considérés comme membres),
il ne doit pas être utilisé pour résoudre les
permissions d'une vue.
En revanche, il est utile pour attribuer une ressource
à tout le monde.
Par exemple, un produit avec le groupe de vente Public
est considéré comme achetable par tous utilisateurs.
S'il n'avait eu aucun group de vente, il n'aurait
été accessible à personne.
### Groupes de club
Chaque club est associé à deux groupes :
le groupe des membres et le groupe du bureau.
Lorsqu'un utilisateur rejoint un club, il est automatiquement
ajouté au groupe des membres.
S'il rejoint le club en tant que membre du bureau,
il est également ajouté au groupe du bureau.
Lorsqu'un utilisateur quitte le club, il est automatiquement
retiré des groupes liés au club.
S'il quitte le bureau, mais reste dans le club,
il est retiré du groupe du bureau, mais reste dans le groupe des membres.
### Groupes de ban
Les groupes de ban sont une catégorie de groupes à part,
qui ne sont pas stockés dans la même table
et qui ne sont pas gérés sur la même interface
que les autres groupes.
Les groupes de ban existants sont les suivants :
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation

View File

@ -1,15 +1,292 @@
## Les permissions ## Objectifs du système de permissions
Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions Les permissions attendues sur le site sont relativement spécifiques.
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le L'accès à une ressource peut se faire selon un certain nombre
plus simple à l'époque était de concevoir un système maison afin de se calquer de paramètres différents :
sur ce que faisait l'ancien site.
### Protéger un modèle `L'état de la ressource`
: Certaines ressources
sont visibles par tous les cotisants (voire tous les utilisateurs),
à condition qu'elles aient passé une étape de modération.
La visibilité des ressources non-modérées nécessite des permissions
supplémentaires.
La gestion des permissions se fait directement par modèle. `L'appartenance à un groupe`
Il existe trois niveaux de permission : : Les groupes Root, Admin Com, Admin SAS, etc.
sont associés à des jeux de permissions.
Par exemple, les membres du groupe Admin SAS ont tous les droits sur
les ressources liées au SAS : ils peuvent voir,
créer, éditer, supprimer et éventuellement modérer
des images, des albums, des identifications de personnes...
Il en va de même avec les admins Com pour la communication,
les admins pédagogie pour le guide des UEs et ainsi de suite.
Quant aux membres du groupe Root, ils ont tous les droits
sur toutes les ressources du site.
`Le statut de la cotisation`
: Les non-cotisants n'ont presque aucun
droit sur les ressources du site (ils peuvent seulement en voir une poignée),
les anciens cotisants peuvent voir un grand nombre de ressources
et les cotisants actuels ont la plupart des droits qui ne sont
pas liés à un club ou à l'administration du site.
`L'appartenance à un club`
: Être dans un club donne le droit
de voir la plupart des ressources liées au club dans lequel ils
sont ; être dans le bureau du club donne en outre des droits
d'édition et de création sur ces ressources.
`Être l'auteur ou le possesseur d'une ressource`
: Certaines ressources, comme les nouvelles,
enregistrent l'utilisateur qui les a créées ;
ce dernier a les droits de voir, de modifier et éventuellement
de supprimer ses ressources, quand bien même
elles ne seraient pas visibles pour les utilisateurs normaux
(par exemple, parce qu'elles ne sont pas encore modérées.)
Le système de permissions inclus par défaut dans django
permet de modéliser aisément l'accès à des ressources au niveau
de la table.
Ainsi, il n'est pas compliqué de gérer les permissions liées
aux groupes d'administration.
Cependant, une surcouche est nécessaire dès lors que l'on veut
gérer les droits liés à une ligne en particulier
d'une table de la base de données.
Nous essayons le plus possible de nous tenir aux fonctionnalités
de django, sans pour autant hésiter à nous rabattre sur notre
propre surcouche dès lors que les permissions attendues
deviennent trop spécifiques pour être gérées avec juste django.
!!!info "Un peu d'histoire"
Les permissions du site n'ont pas toujours été gérées
avec un mélange de fonctionnalités de django et de notre
propre code.
Pendant très longtemps, seule la surcouche était utilisée,
ce qui menait souvent à des vérifications de droits
inefficaces et à une gestion complexe de certaines
parties qui auraient pu être manipulées beaucoup plus simplement.
En plus de ça, les permissions liées à la plupart
des groupes se faisait de manière hardcodée :
plutôt que d'associer un groupe à un jeu de permission
et de faire une jointure en db sur les groupes de l'utilisateur
ayant cette permissions,
on conservait la clef primaire du groupe dans la config
et on vérifiait en dur dans le code que l'utilisateur
était un des groupes voulus.
Ce système possédait le triple désavantage de prendre énormément
de temps, d'être extrêmement limité (de fait, si tout est hardcodé,
on est obligé d'avoir le moins de groupes possibles pour que ça reste
gérable) et d'être désespérément dangereux (par exemple : fin novembre 2024,
une erreur dans le code a donné les accès à la création des cotisations
à tout le monde ; mi-octobre 2019, le calcul des permissions des etickets
pouvait faire tomber le site, cf.
[ce topic du forum](https://ae.utbm.fr/forum/topic/17943/?page=1msg2277272))
## Accès à toutes les ressources d'une table
Gérer ce genre d'accès (par exemple : voir toutes les nouvelles
ou pouvoir supprimer n'importe quelle photo)
est exactement le problème que le système de permissions de django résout.
Nous utilisons donc ce système dans ce genre de situations.
!!!note
Nous décrivons ci-dessous l'usage que nous faisons du système
de permissions de django,
mais la seule source d'information complète et pleinement fiable
sur le fonctionnement réel de ce système est
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/auth/default/).
### Permissions d'un modèle
Par défaut, django crée quatre permissions pour chaque table de la base de données :
- `add_<nom de la table>` : créer un objet dans cette table
- `view_<nom de la table>` : voir le contenu de la table
- `change_<nom de la table>` : éditer des objets de la table
- `delete_<nom de la table>` : supprimer des objets de la table
Ces permissions sont créées au même moment que le modèle.
Si la table existe en base de données, ces permissions existent aussi.
Il est également possible de rajouter nos propres permissions,
directement dans les options Meta du modèle.
Par exemple, prenons le modèle suivant :
```python
from django.db import models
class News(models.Model):
# ...
class Meta:
permissions = [
("moderate_news", "Can moderate news"),
("view_unmoderated_news", "Can view non-moderated news"),
]
```
Ce dernier aura les permissions : `view_news`, `add_news`, `change_news`,
`delete_news`, `moderate_news` et `view_unmoderated_news`.
### Utilisation des permissions d'un modèle
Pour vérifier qu'un utilisateur a une permission,
on utilise les fonctions suivantes :
- `User.has_perm(perm)` : retourne `True` si l'utilisateur
a la permission voulue, sinon `False`
- `User.has_perms([perm_a, perm_b, perm_c])` : retourne `True` si l'utilisateur
a toutes les permissions voulues, sinon `False`.
Ces fonctions attendent un string suivant le format :
`<nom de l'application>.<nom de la permission>`.
Par exemple, la permission pour vérifier qu'un utilisateur
peut modérer une nouvelle sera : `com.moderate_news`.
Ces fonctions sont utilisables aussi bien dans les templates Jinja
que dans le code Python :
=== "Jinja"
```jinja
{% if user.has_perm("com.moderate_news") %}
<form method="post" action="{{ url("com:news_moderate", news_id=387) }}">
<input type="submit" value="Modérer" />
</form>
{% endif %}
```
=== "Python"
```python
from com.models import News
from core.models import User
user = User.objects.get(username="bibou")
news = News.objects.get(id=387)
if user.has_perm("com.moderate_news"):
news.is_moderated = True
news.save()
else:
raise PermissionDenied
```
Pour utiliser ce système de permissions dans une class-based view
(c'est-à-dire la plus grande partie de nos vues),
Django met à disposition `PermissionRequiredMixin`,
qui restreint l'accès à la vue aux utilisateurs ayant
la ou les permissions requises.
Pour les vues sous forme de fonction, il y a le décorateur
`permission_required`.
=== "Class-Based View"
```python
from com.models import News
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import redirect
from django.urls import reverse
from django.views import View
from django.views.generic.detail import SingleObjectMixin
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
# On peut aussi fournir plusieurs permissions, par exemple :
# permission_required = ["com.moderate_news", "com.delete_news"]
def post(self, request, *args, **kwargs):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
obj = self.get_object()
obj.is_moderated = True
obj.save()
return redirect(reverse("com:news_list"))
```
=== "Function-based view"
```python
from com.models import News
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.decorators.http import require_POST
@permission_required("com.moderate_news")
@require_POST
def moderate_news(request, news_id: int):
# Si nous sommes ici, nous pouvons être certains que l'utilisateur
# a la permission requise
news = get_object_or_404(News, id=news_id)
news.is_moderated = True
news.save()
return redirect(reverse("com:news_list"))
```
## Accès à des éléments en particulier
### Accès à l'auteur de la ressource
Dans ce genre de cas, on peut identifier trois acteurs possibles :
- les administrateurs peuvent accéder à toutes les ressources,
y compris non-modérées
- l'auteur d'une ressource non-modérée peut y accéder
- Les autres utilisateurs ne peuvent pas voir les ressources
non-modérées dont ils ne sont pas l'auteur
Dans ce genre de cas, on souhaite donc accorder l'accès aux
utilisateurs qui ont la permission globale, selon le système
décrit plus haut, ou bien à l'auteur de la ressource.
Pour cela, nous avons le mixin `PermissionOrAuthorRequired`.
Ce dernier va effectuer les mêmes vérifications que `PermissionRequiredMixin`
puis, si l'utilisateur n'a pas la permission requise, vérifier
s'il est l'auteur de la ressource.
```python
from com.models import News
from core.auth.mixins import PermissionOrAuthorRequiredMixin
from django.views.generic import UpdateView
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
pk_url_kwarg = "news_id"
permission_required = "com.change_news"
author_field = "author" # (1)!
```
1. Nom du champ du modèle utilisé comme clef étrangère vers l'auteur.
Par exemple, ici, la permission sera accordée si
l'utilisateur connecté correspond à l'utilisateur
désigné par `News.author`.
### Accès en fonction de règles plus complexes
Tout ce que nous avons décrit précédemment permet de couvrir
la plupart des cas simples.
Cependant, il arrivera souvent que les permissions attendues soient
plus complexes.
Dans ce genre de cas, on rentre entièrement dans notre surcouche.
#### Implémentation dans les modèles
La gestion de ce type de permissions se fait directement par modèle.
Il en existe trois niveaux :
- Éditer des propriétés de l'objet - Éditer des propriétés de l'objet
- Éditer certaines valeurs l'objet - Éditer certaines valeurs l'objet
@ -47,28 +324,43 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group from core.models import User, Group
# Utilisation de la protection par fonctions
class Article(models.Model): class Article(models.Model):
title = models.CharField(_("title"), max_length=100) title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content")) content = models.TextField(_("content"))
# Donne ou non les droits d'édition des propriétés de l'objet def is_owned_by(self, user): # (1)!
# Un utilisateur dans le bureau AE aura tous les droits sur cet objet
def is_owned_by(self, user):
return user.is_board_member return user.is_board_member
# Donne ou non les droits d'édition de l'objet def can_be_edited_by(self, user): # (2)!
# L'objet ne sera modifiable que par un utilisateur cotisant
def can_be_edited_by(self, user):
return user.is_subscribed return user.is_subscribed
# Donne ou non les droits de vue de l'objet def can_be_viewed_by(self, user): # (3)!
# Ici, l'objet n'est visible que par un utilisateur connecté
def can_be_viewed_by(self, user):
return not user.is_anonymous return not user.is_anonymous
``` ```
1. Donne ou non les droits d'édition des propriétés de l'objet.
Ici, un utilisateur dans le bureau AE aura tous les droits sur cet objet
2. Donne ou non les droits d'édition de l'objet
Ici, l'objet ne sera modifiable que par un utilisateur cotisant
3. Donne ou non les droits de vue de l'objet
Ici, l'objet n'est visible que par un utilisateur connecté
!!!note
Dans cet exemple, nous utilisons des permissions très simples
pour que vous puissiez constater le squelette de ce système,
plutôt que la logique de validation dans ce cas particulier.
En réalité, il serait ici beaucoup plus approprié de
donner les permissions `com.delete_article` et
`com.change_article_properties` (en créant ce dernier
s'il n'existe pas encore) au groupe du bureau AE,
de donner également la permission `com.change_article`
au groupe `Cotisants` et enfin de restreindre l'accès
aux vues d'accès aux articles avec `LoginRequiredMixin`.
=== "Avec les groupes de permission" === "Avec les groupes de permission"
```python ```python
@ -83,15 +375,12 @@ Voici un exemple d'implémentation de ce système :
content = models.TextField(_("content")) content = models.TextField(_("content"))
# relation one-to-many # relation one-to-many
# Groupe possédant l'objet owner_group = models.ForeignKey( # (1)!
# Donne les droits d'édition des propriétés de l'objet
owner_group = models.ForeignKey(
Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID Group, related_name="owned_articles", default=settings.SITH_GROUP_ROOT_ID
) )
# relation many-to-many # relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits d'édition de l'objet edit_groups = models.ManyToManyField( # (2)!
edit_groups = models.ManyToManyField(
Group, Group,
related_name="editable_articles", related_name="editable_articles",
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
@ -99,8 +388,7 @@ Voici un exemple d'implémentation de ce système :
) )
# relation many-to-many # relation many-to-many
# Tous les groupes qui seront ajouté dans ce champ auront les droits de vue de l'objet view_groups = models.ManyToManyField( # (3)!
view_groups = models.ManyToManyField(
Group, Group,
related_name="viewable_articles", related_name="viewable_articles",
verbose_name=_("view groups"), verbose_name=_("view groups"),
@ -108,18 +396,25 @@ Voici un exemple d'implémentation de ce système :
) )
``` ```
### Appliquer les permissions 1. Groupe possédant l'objet
Donne les droits d'édition des propriétés de l'objet.
Il ne peut y avoir qu'un seul groupe `owner` par objet.
2. Tous les groupes ayant droit d'édition sur l'objet.
Il peut y avoir autant de groupes d'édition que l'on veut par objet.
3. Tous les groupes ayant droit de voir l'objet.
Il peut y avoir autant de groupes de vue que l'on veut par objet.
#### Dans un template
#### Application dans les templates
Il existe trois fonctions de base sur lesquelles Il existe trois fonctions de base sur lesquelles
reposent les vérifications de permission. reposent les vérifications de permission.
Elles sont disponibles dans le contexte par défaut du Elles sont disponibles dans le contexte par défaut du
moteur de template et peuvent être utilisées à tout moment. moteur de template et peuvent être utilisées à tout moment.
- [can_edit_prop(obj, user)][core.views.can_edit_prop] : équivalent de `obj.is_owned_by(user)` - [can_edit_prop(obj, user)][core.auth.mixins.can_edit_prop] : équivalent de `obj.is_owned_by(user)`
- [can_edit(obj, user)][core.views.can_edit] : équivalent de `obj.can_be_edited_by(user)` - [can_edit(obj, user)][core.auth.mixins.can_edit] : équivalent de `obj.can_be_edited_by(user)`
- [can_view(obj, user)][core.views.can_view] : équivalent de `obj.can_be_viewed_by(user)` - [can_view(obj, user)][core.auth.mixins.can_view] : équivalent de `obj.can_be_viewed_by(user)`
Voici un exemple d'utilisation dans un template : Voici un exemple d'utilisation dans un template :
@ -130,7 +425,7 @@ Voici un exemple d'utilisation dans un template :
{% endif %} {% endif %}
``` ```
#### Dans une vue #### Application dans les vues
Généralement, les vérifications de droits dans les templates Généralement, les vérifications de droits dans les templates
se limitent aux urls à afficher puisqu'il se limitent aux urls à afficher puisqu'il
@ -138,7 +433,7 @@ ne faut normalement pas mettre de logique autre que d'affichage à l'intérieur
(en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus). (en réalité, c'est un principe qu'on a beaucoup violé, mais promis on le fera plus).
C'est donc habituellement au niveau des vues que cela a lieu. C'est donc habituellement au niveau des vues que cela a lieu.
Notre système s'appuie sur un système de mixin Pour cela, nous avons rajouté des mixins
à hériter lors de la création d'une vue basée sur une classe. à hériter lors de la création d'une vue basée sur une classe.
Ces mixins ne sont compatibles qu'avec les classes récupérant Ces mixins ne sont compatibles qu'avec les classes récupérant
un objet ou une liste d'objet. un objet ou une liste d'objet.
@ -152,34 +447,60 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment : Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python ```python
from django.views.generic import CreateView, ListView from django.views.generic import CreateView, DetailView
from core.views import CanViewMixin, CanCreateMixin from core.auth.mixins import CanViewMixin, CanCreateMixin
from com.models import WeekmailArticle from com.models import WeekmailArticle
# Il est important de mettre le mixin avant la classe héritée de Django # Il est important de mettre le mixin avant la classe héritée de Django
# L'héritage multiple se fait de droite à gauche et les mixins ont besoin # L'héritage multiple se fait de droite à gauche et les mixins ont besoin
# d'une classe de base pour fonctionner correctement. # d'une classe de base pour fonctionner correctement.
class ArticlesListView(CanViewMixin, ListView): class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article # Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView): class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle model = WeekmailArticle
``` ```
Les mixins suivants sont implémentés : Les mixins suivants sont implémentés :
- [CanCreateMixin][core.views.CanCreateMixin] : l'utilisateur peut-il créer l'objet ? - [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
- [CanEditPropMixin][core.views.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ? Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
- [CanEditMixin][core.views.CanEditMixin] : L'utilisateur peut-il éditer l'objet ? - [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanViewMixin][core.views.CanViewMixin] : L'utilisateur peut-il voir l'objet ? - [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [UserIsRootMixin][core.views.UserIsRootMixin] : L'utilisateur a-t-il les droit root ? - [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.views.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ? - [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
- [UserIsLoggedMixin][core.views.UserIsLoggedMixin] : L'utilisateur est-il connecté ?
(à éviter ; préférez `LoginRequiredMixin`, fourni par Django) !!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance" !!!danger "Performance"
@ -197,6 +518,76 @@ Les mixins suivants sont implémentés :
Mais sur les `ListView`, on peut arriver à des temps Mais sur les `ListView`, on peut arriver à des temps
de réponse extrêmement élevés. de réponse extrêmement élevés.
### Filtrage des querysets
Récupérer tous les objets d'un queryset et vérifier pour chacun que
l'utilisateur a le droit de les voir peut-être excessivement
coûteux en ressources
(cf. l'encart ci-dessus).
Lorsqu'il est nécessaire de récupérer un certain nombre
d'objets depuis la base de données, il est donc préférable
de filtrer directement depuis le queryset.
Pour cela, certains modèles, tels que [Picture][sas.models.Picture]
peuvent être filtrés avec la méthode de queryset `viewable_by`.
Cette dernière s'utilise comme n'importe quelle autre méthode
de queryset :
```python
from sas.models import Picture
from core.models import User
user = User.objects.get(username="bibou")
pictures = Picture.objects.viewable_by(user)
```
Le résultat de la requête contiendra uniquement des éléments
que l'utilisateur sélectionné a effectivement le droit de voir.
Si vous désirez utiliser cette méthode sur un modèle
qui ne la possède pas, il est relativement facile de l'écrire :
```python
from typing import Self
from django.db import models
from core.models import User
class NewsQuerySet(models.QuerySet): # (1)!
def viewable_by(self, user: User) -> Self:
if user.has_perm("com.view_unmoderated_news"):
# si l'utilisateur peut tout voir, on retourne tout
return self
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
# est l'auteur
return self.filter(
models.Q(is_moderated=True)
| models.Q(author=user)
)
class News(models.Model):
is_moderated = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.PROTECT)
# ...
objects = NewsQuerySet.as_manager() # (2)!
class Meta:
permissions = [("view_unmoderated_news", "Can view non moderated news")]
```
1. On crée un `QuerySet` maison, dans lequel on définit la méthode `viewable_by`
2. Puis, on attache ce `QuerySet` à notre modèle
!!!note
Pour plus d'informations sur la création de `QuerySet` personnalisés, voir
[la documentation de django](https://docs.djangoproject.com/fr/stable/topics/db/managers/)
## API ## API
L'API utilise son propre système de permissions. L'API utilise son propre système de permissions.

View File

@ -26,7 +26,7 @@ $min_col_width: 100px;
} }
#page #content { #page #content {
overflow-x: clip; overflow-x: scroll;
} }
.election_table { .election_table {
@ -106,11 +106,17 @@ $min_col_width: 100px;
margin: 0; margin: 0;
} }
>p { .role_description {
flex-grow: 1; flex-grow: 1;
margin-top: .5em; margin-top: .5em;
text-wrap: auto; text-wrap: auto;
text-align: left; text-align: left;
// Show more/less element
a {
text-align: center;
display: block;
}
} }
} }

View File

@ -4,12 +4,12 @@
{{ object.title }} {{ object.title }}
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_js %}
<link rel="stylesheet" href="{{ static('election/css/election.scss') }}"> <script type="module" src="{{ static('bundled/core/read-more-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_css %}
<script src="{{ static('bundled/vendored/jquery.shorten.min.js') }}"></script> <link rel="stylesheet" href="{{ static('election/css/election.scss') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -68,7 +68,7 @@
<td class="role_title"> <td class="role_title">
<div class="role_text"> <div class="role_text">
<h4>{{ role.title }}</h4> <h4>{{ role.title }}</h4>
<p class="role_description">{{ role.description }}</p> <p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %} {%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %}
<strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong> <strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong>
{%- endif %} {%- endif %}
@ -139,7 +139,9 @@
<figcaption class="candidate__details"> <figcaption class="candidate__details">
<h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5> <h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5>
{%- if not election.is_vote_finished %} {%- if not election.is_vote_finished %}
<q class="candidate_program">{{ candidature.program | markdown or '' }}</q> <q class="candidate_program" show-more="200">
{{ candidature.program|markdown or '' }}
</q>
{%- endif %} {%- endif %}
</figcaption> </figcaption>
{%- if user.can_edit(candidature) -%} {%- if user.can_edit(candidature) -%}
@ -198,18 +200,6 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script type="text/javascript">
$('.role_description').shorten({
moreText: "{% trans %}Show more{% endtrans %}",
lessText: "{% trans %}Show less{% endtrans %}",
showChars: 300
});
$('.candidate_program').shorten({
moreText: "{% trans %}Show more{% endtrans %}",
lessText: "{% trans %}Show less{% endtrans %}",
showChars: 200
});
</script>
<script type="text/javascript"> <script type="text/javascript">
document.querySelectorAll('.role__multiple-choices').forEach(setupRestrictions); document.querySelectorAll('.role__multiple-choices').forEach(setupRestrictions);

View File

@ -1,6 +1,7 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django import forms from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -10,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.views import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
from core.views.widgets.select import ( from core.views.widgets.select import (
@ -300,7 +301,7 @@ class VoteFormView(CanCreateMixin, FormView):
# Create views # Create views
class CandidatureCreateView(CanCreateMixin, CreateView): class CandidatureCreateView(LoginRequiredMixin, CreateView):
"""View dedicated to a cundidature creation.""" """View dedicated to a cundidature creation."""
form_class = CandidateForm form_class = CandidateForm
@ -326,12 +327,13 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = Election.objects.get(id=self.election.id) obj.election = self.election
obj.user = obj.user if hasattr(obj, "user") else self.request.user if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):
return super(CreateView, self).form_valid(form) return super().form_valid(form)
raise PermissionDenied raise PermissionDenied
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -343,22 +345,14 @@ class CandidatureCreateView(CanCreateMixin, CreateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionCreateView(CanCreateMixin, CreateView): class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election model = Election
form_class = ElectionForm form_class = ElectionForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "election.add_election"
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
"""Allow every user that had passed the dispatch to create an election."""
return super(CreateView, self).form_valid(form)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(CanCreateMixin, CreateView): class RoleCreateView(CanCreateMixin, CreateView):

View File

@ -43,7 +43,7 @@ from haystack.query import RelatedSearchQuerySet
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from club.widgets.select import AutoCompleteSelectClub from club.widgets.select import AutoCompleteSelectClub
from core.views import ( from core.auth.mixins import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,

View File

@ -27,12 +27,9 @@ from django.http import Http404, JsonResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, View from django.views.generic import DetailView, View
from core.auth.mixins import CanViewMixin, FormerSubscriberMixin
from core.models import User from core.models import User
from core.views import ( from core.views import UserTabsMixin
CanViewMixin,
FormerSubscriberMixin,
UserTabsMixin,
)
from galaxy.models import Galaxy, GalaxyLane from galaxy.models import Galaxy, GalaxyLane

View File

@ -19,6 +19,7 @@ from datetime import timezone as tz
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction from django.db import transaction
from django.template import defaultfilters from django.template import defaultfilters
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -28,8 +29,8 @@ from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView
from club.models import Club from club.models import Club
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Page, User from core.models import Page, User
from core.views import CanCreateMixin, CanEditMixin, CanEditPropMixin, CanViewMixin
from counter.forms import GetUserForm from counter.forms import GetUserForm
from counter.models import Counter, Customer, Selling from counter.models import Counter, Customer, Selling
from launderette.models import Launderette, Machine, Slot, Token from launderette.models import Launderette, Machine, Slot, Token
@ -186,12 +187,13 @@ class LaunderetteEditView(CanEditPropMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
class LaunderetteCreateView(CanCreateMixin, CreateView): class LaunderetteCreateView(PermissionRequiredMixin, CreateView):
"""Create a new launderette.""" """Create a new launderette."""
model = Launderette model = Launderette
fields = ["name"] fields = ["name"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "launderette.add_launderette"
def form_valid(self, form): def form_valid(self, form):
club = Club.objects.filter( club = Club.objects.filter(
@ -492,12 +494,13 @@ class MachineDeleteView(CanEditPropMixin, DeleteView):
success_url = reverse_lazy("launderette:launderette_list") success_url = reverse_lazy("launderette:launderette_list")
class MachineCreateView(CanCreateMixin, CreateView): class MachineCreateView(PermissionRequiredMixin, CreateView):
"""Create a new machine.""" """Create a new machine."""
model = Machine model = Machine
fields = ["name", "launderette", "type"] fields = ["name", "launderette", "type"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "launderette.add_machine"
def get_initial(self): def get_initial(self):
ret = super().get_initial() ret = super().get_initial()

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-05 16:39+0100\n" "POT-Creation-Date: 2025-01-10 14:52+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -841,7 +841,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/views.py counter/forms.py election/views.py #: club/forms.py com/forms.py counter/forms.py election/views.py
#: subscription/forms.py #: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@ -935,6 +935,10 @@ msgstr "rôle"
msgid "description" msgid "description"
msgstr "description" msgstr "description"
#: club/models.py
msgid "past member"
msgstr "ancien membre"
#: club/models.py #: club/models.py
msgid "Email address" msgid "Email address"
msgstr "Adresse email" msgstr "Adresse email"
@ -1255,6 +1259,46 @@ msgstr "Liste d'affiches"
msgid "Props" msgid "Props"
msgstr "Propriétés" msgstr "Propriétés"
#: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
#: com/forms.py
msgid "Weekly event"
msgstr "Événement Hebdomadaire"
#: com/forms.py
msgid "Weekly events will occur each week for a specified timespan."
msgstr ""
"Les événements hebdomadaires se répéteront chaque semaine pendant une durée "
"déterminée"
#: com/forms.py
#, python-format
msgid "%d times"
msgstr "%d fois"
#: com/forms.py
msgid "Until the end of the semester"
msgstr "Jusqu'à la fin du semestre"
#: com/forms.py
msgid "Occurrences"
msgstr "Occurences"
#: com/forms.py
msgid "How much times should the event occur (including the first one)"
msgstr ""
"Combien de fois l'événement doit-il se répéter (en incluant la première fois)"
#: com/forms.py
msgid "Automoderation"
msgstr "Automodération"
#: com/models.py #: com/models.py
msgid "alert message" msgid "alert message"
msgstr "message d'alerte" msgstr "message d'alerte"
@ -1267,22 +1311,6 @@ msgstr "message d'info"
msgid "weekmail destinations" msgid "weekmail destinations"
msgstr "destinataires du weekmail" msgstr "destinataires du weekmail"
#: com/models.py
msgid "Notice"
msgstr "Information"
#: com/models.py
msgid "Event"
msgstr "Événement"
#: com/models.py
msgid "Weekly"
msgstr "Hebdomadaire"
#: com/models.py
msgid "Call"
msgstr "Appel"
#: com/models.py core/templates/core/macros.jinja election/models.py #: com/models.py core/templates/core/macros.jinja election/models.py
#: forum/models.py pedagogy/models.py #: forum/models.py pedagogy/models.py
msgid "title" msgid "title"
@ -1308,10 +1336,6 @@ msgstr "contenu"
msgid "A more detailed and exhaustive description of the event." msgid "A more detailed and exhaustive description of the event."
msgstr "Une description plus détaillée et exhaustive de l'évènement." msgstr "Une description plus détaillée et exhaustive de l'évènement."
#: com/models.py core/models.py launderette/models.py
msgid "type"
msgstr "type"
#: com/models.py #: com/models.py
msgid "The club which organizes the event." msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement." msgstr "Le club qui organise l'évènement."
@ -1320,6 +1344,10 @@ msgstr "Le club qui organise l'évènement."
msgid "author" msgid "author"
msgstr "auteur" msgstr "auteur"
#: com/models.py
msgid "news"
msgstr "nouvelle"
#: com/models.py #: com/models.py
msgid "news_date" msgid "news_date"
msgstr "date de la nouvelle" msgstr "date de la nouvelle"
@ -1332,6 +1360,14 @@ msgstr "date de début"
msgid "end_date" msgid "end_date"
msgstr "date de fin" msgstr "date de fin"
#: com/models.py
msgid "news date"
msgstr "date de la nouvelle"
#: com/models.py
msgid "news dates"
msgstr "dates de la nouvelle"
#: com/models.py #: com/models.py
msgid "intro" msgid "intro"
msgstr "intro" msgstr "intro"
@ -1416,23 +1452,17 @@ msgid "News"
msgstr "Nouvelles" msgstr "Nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: core/templates/core/user_tools.jinja #: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news" msgid "Create news"
msgstr "Créer nouvelle" msgstr "Créer une nouvelle"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "Notices" msgid "Weeklies"
msgstr "Information" msgstr "Événements hebdomadaires"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
msgid "Displayed notices" msgid "Displayed weeklies"
msgstr "Informations affichées" msgstr "Événements hebdomadaires affichées"
#: com/templates/com/news_admin_list.jinja
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja forum/templates/forum/main.jinja #: forum/templates/forum/forum.jinja forum/templates/forum/main.jinja
@ -1453,18 +1483,6 @@ msgstr "Auteur"
msgid "Moderator" msgid "Moderator"
msgstr "Modérateur" msgstr "Modérateur"
#: com/templates/com/news_admin_list.jinja
msgid "Notices to moderate"
msgstr "Informations à modérer"
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies"
msgstr "Nouvelles hebdomadaires"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed weeklies"
msgstr "Nouvelles hebdomadaires affichées"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
msgid "Dates" msgid "Dates"
@ -1474,18 +1492,6 @@ msgstr "Dates"
msgid "Weeklies to moderate" msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer" msgstr "Nouvelles hebdomadaires à modérer"
#: com/templates/com/news_admin_list.jinja
msgid "Calls"
msgstr "Appels"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed calls"
msgstr "Appels affichés"
#: com/templates/com/news_admin_list.jinja
msgid "Calls to moderate"
msgstr "Appels à modérer"
#: com/templates/com/news_admin_list.jinja #: com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja #: core/templates/core/base/navbar.jinja
msgid "Events" msgid "Events"
@ -1503,7 +1509,7 @@ msgstr "Événements à modérer"
msgid "Back to news" msgid "Back to news"
msgstr "Retour aux nouvelles" msgstr "Retour aux nouvelles"
#: com/templates/com/news_detail.jinja com/templates/com/news_edit.jinja #: com/templates/com/news_detail.jinja
msgid "Author: " msgid "Author: "
msgstr "Auteur : " msgstr "Auteur : "
@ -1519,41 +1525,14 @@ msgstr "Éditer (sera soumise de nouveau à la modération)"
msgid "Edit news" msgid "Edit news"
msgstr "Éditer la nouvelle" msgstr "Éditer la nouvelle"
#: com/templates/com/news_edit.jinja #: com/templates/com/news_list.jinja
msgid "Notice: Information, election result - no date" msgid "Events today and the next few days"
msgstr "Information, résultat d'élection - sans date" msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_edit.jinja
msgid "Event: punctual event, associated with one date"
msgstr "Événement : événement ponctuel associé à une date"
#: com/templates/com/news_edit.jinja
msgid ""
"Weekly: recurrent event, associated with many dates (specify the first one, "
"and a deadline)"
msgstr ""
"Hebdomadaire : événement récurrent, associé à plusieurs dates (spécifier la "
"première, ainsi que la date de fin)"
#: com/templates/com/news_edit.jinja
msgid ""
"Call: long time event, associated with a long date (like election appliance)"
msgstr ""
"Appel : événement de longue durée, associé à une longue date (comme des "
"candidatures à une élection)"
#: com/templates/com/news_edit.jinja com/templates/com/weekmail.jinja
msgid "Preview"
msgstr "Prévisualiser"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Administrate news" msgid "Administrate news"
msgstr "Administrer les news" msgstr "Administrer les news"
#: com/templates/com/news_list.jinja
msgid "Events today and the next few days"
msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Nothing to come..." msgid "Nothing to come..."
msgstr "Rien à venir..." msgstr "Rien à venir..."
@ -1675,6 +1654,10 @@ msgstr "Diaporama"
msgid "Weekmail" msgid "Weekmail"
msgstr "Weekmail" msgstr "Weekmail"
#: com/templates/com/weekmail.jinja
msgid "Preview"
msgstr "Prévisualiser"
#: com/templates/com/weekmail.jinja com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail.jinja com/templates/com/weekmail_preview.jinja
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@ -1764,14 +1747,6 @@ msgstr "Astuce"
msgid "Final word" msgid "Final word"
msgstr "Le mot de la fin" msgstr "Le mot de la fin"
#: com/views.py
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/views.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
#: com/views.py #: com/views.py
msgid "Communication administration" msgid "Communication administration"
msgstr "Administration de la communication" msgstr "Administration de la communication"
@ -1792,22 +1767,6 @@ msgstr "Message d'alerte"
msgid "Screens list" msgid "Screens list"
msgstr "Liste d'écrans" msgstr "Liste d'écrans"
#: com/views.py rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
#: com/views.py
msgid "Automoderation"
msgstr "Automodération"
#: com/views.py
msgid "This field is required."
msgstr "Ce champ est obligatoire."
#: com/views.py
msgid "An event cannot end before its beginning."
msgstr "Un évènement ne peut pas se finir avant d'avoir commencé."
#: com/views.py #: com/views.py
msgid "Delete and save to regenerate" msgid "Delete and save to regenerate"
msgstr "Supprimer et sauver pour régénérer" msgstr "Supprimer et sauver pour régénérer"
@ -2062,16 +2021,12 @@ msgid "reason"
msgstr "raison" msgstr "raison"
#: core/models.py #: core/models.py
#, fuzzy
#| msgid "user"
msgid "user ban" msgid "user ban"
msgstr "utilisateur" msgstr "utilisateur banni"
#: core/models.py #: core/models.py
#, fuzzy
#| msgid "user"
msgid "user bans" msgid "user bans"
msgstr "utilisateur" msgstr "utilisateurs bannis"
#: core/models.py #: core/models.py
msgid "receive the Weekmail" msgid "receive the Weekmail"
@ -2155,6 +2110,10 @@ msgstr ""
msgid "Duplicate file" msgid "Duplicate file"
msgstr "Un fichier de ce nom existe déjà" msgstr "Un fichier de ce nom existe déjà"
#: core/models.py
msgid "This is not a valid folder thumbnail"
msgstr "Ceci n'est pas une miniature de dossier valide"
#: core/models.py #: core/models.py
msgid "You must provide a file" msgid "You must provide a file"
msgstr "Vous devez fournir un fichier" msgstr "Vous devez fournir un fichier"
@ -2215,6 +2174,10 @@ msgstr "url"
msgid "param" msgid "param"
msgstr "param" msgstr "param"
#: core/models.py launderette/models.py
msgid "type"
msgstr "type"
#: core/models.py #: core/models.py
msgid "viewed" msgid "viewed"
msgstr "vue" msgstr "vue"
@ -3328,8 +3291,8 @@ msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
#: core/views/forms.py #: core/views/forms.py
msgid "" msgid ""
"Profile: you need to be visible on the picture, in order to be recognized (e." "Profile: you need to be visible on the picture, in order to be recognized "
"g. by the barmen)" "(e.g. by the barmen)"
msgstr "" msgstr ""
"Photo de profil: vous devez être visible sur la photo afin d'être reconnu " "Photo de profil: vous devez être visible sur la photo afin d'être reconnu "
"(par exemple par les barmen)" "(par exemple par les barmen)"
@ -3935,8 +3898,8 @@ msgstr ""
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "If you think this was a mistake, please mail us at ae@utbm.fr." msgid "If you think this was a mistake, please mail us at ae@utbm.fr."
msgstr "" msgstr ""
"Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à ae@utbm." "Si vous pensez qu'il s'agit d'une erreur, veuillez envoyer un mail à "
"fr." "ae@utbm.fr."
#: counter/templates/counter/mails/account_dump.jinja #: counter/templates/counter/mails/account_dump.jinja
msgid "" msgid ""
@ -4456,14 +4419,6 @@ msgstr "Ajouter un nouveau rôle"
msgid "Submit the vote !" msgid "Submit the vote !"
msgstr "Envoyer le vote !" msgstr "Envoyer le vote !"
#: election/templates/election/election_detail.jinja
msgid "Show more"
msgstr "Montrer plus"
#: election/templates/election/election_detail.jinja
msgid "Show less"
msgstr "Montrer moins"
#: election/templates/election/election_list.jinja #: election/templates/election/election_list.jinja
msgid "Election list" msgid "Election list"
msgstr "Liste des élections" msgstr "Liste des élections"
@ -4742,6 +4697,11 @@ msgstr "Machines"
msgid "New machine" msgid "New machine"
msgstr "Nouvelle machine" msgstr "Nouvelle machine"
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py
msgid "Type"
msgstr "Type"
#: launderette/templates/launderette/launderette_book.jinja #: launderette/templates/launderette/launderette_book.jinja
msgid "Choose" msgid "Choose"
msgstr "Choisir" msgstr "Choisir"
@ -5142,6 +5102,10 @@ msgstr "Fusion"
msgid "Ban a user" msgid "Ban a user"
msgstr "Bannir un utilisateur" msgstr "Bannir un utilisateur"
#: rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
#: rootplace/templates/rootplace/userban.jinja #: rootplace/templates/rootplace/userban.jinja
msgid "not specified" msgid "not specified"
msgstr "non spécifié" msgstr "non spécifié"

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-04 23:07+0100\n" "POT-Creation-Date: 2025-01-08 12:23+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -113,6 +113,14 @@ msgstr "Guide markdown"
msgid "Unsupported NFC card" msgid "Unsupported NFC card"
msgstr "Carte NFC non supportée" msgstr "Carte NFC non supportée"
#: core/static/bundled/core/read-more-index.ts
msgid "Show less"
msgstr "Montrer moins"
#: core/static/bundled/core/read-more-index.ts
msgid "Show more"
msgstr "Montrer plus"
#: core/static/bundled/user/family-graph-index.js #: core/static/bundled/user/family-graph-index.js
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"

View File

@ -32,8 +32,9 @@ from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from core.auth.mixins import FormerSubscriberMixin
from core.models import User from core.models import User
from core.views import FormerSubscriberMixin, search_user from core.views import search_user
from core.views.forms import SelectDate from core.views.forms import SelectDate
# Enum to select search type # Enum to select search type

View File

@ -98,7 +98,7 @@ nav:
- Champs de modèle: reference/core/model_fields.md - Champs de modèle: reference/core/model_fields.md
- reference/core/views.md - reference/core/views.md
- reference/core/schemas.md - reference/core/schemas.md
- reference/core/api_permissions.md - reference/core/auth.md
- counter: - counter:
- reference/counter/models.md - reference/counter/models.md
- reference/counter/views.md - reference/counter/views.md

3241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,23 +25,24 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4", "@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.3", "@biomejs/biome": "1.9.4",
"@hey-api/openapi-ts": "^0.53.8", "@hey-api/openapi-ts": "^0.61.3",
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"vite": "^5.4.11", "vite": "^6.0.7",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^2.1.0" "vite-plugin-static-copy": "^2.1.0"
}, },
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15", "@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15", "@fullcalendar/list": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.6.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
@ -56,9 +57,8 @@
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1", "native-file-system-adapter": "^3.0.1",
"three": "^0.169.0", "three": "^0.172.0",
"three-spritetext": "^1.9.0", "three-spritetext": "^1.9.0",
"tom-select": "^2.3.1" "tom-select": "^2.3.1"
} }

View File

@ -7,7 +7,7 @@ from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
from core.api_permissions import IsInGroup, IsRoot, IsSubscriber from core.auth.api_permissions import IsInGroup, IsRoot, IsSubscriber
from pedagogy.models import UV from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.utbm_api import find_uv from pedagogy.utbm_api import find_uv

View File

@ -26,6 +26,7 @@ from django.conf import settings
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pytest_django.asserts import assertRedirects
from core.models import Notification, User from core.models import Notification, User
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport
@ -106,7 +107,7 @@ class TestUVCreation(TestCase):
def test_create_uv_unauthorized_fail(self): def test_create_uv_unauthorized_fail(self):
# Test with anonymous user # Test with anonymous user
response = self.client.post(self.create_uv_url, create_uv_template(0)) response = self.client.post(self.create_uv_url, create_uv_template(0))
assert response.status_code == 403 assertRedirects(response, reverse("core:login") + f"?next={self.create_uv_url}")
# Test with subscribed user # Test with subscribed user
self.client.force_login(self.sli) self.client.force_login(self.sli)
@ -815,11 +816,11 @@ class TestUVCommentReportCreate(TestCase):
self.create_report_test("guy", success=False) self.create_report_test("guy", success=False)
def test_create_report_anonymous_fail(self): def test_create_report_anonymous_fail(self):
url = reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id})
response = self.client.post( response = self.client.post(
reverse("pedagogy:comment_report", kwargs={"comment_id": self.comment.id}), url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"}
{"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"},
) )
assert response.status_code == 403 assertRedirects(response, reverse("core:login") + f"?next={url}")
assert not UVCommentReport.objects.all().exists() assert not UVCommentReport.objects.all().exists()
def test_notifications(self): def test_notifications(self):

View File

@ -22,7 +22,7 @@
# #
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -35,14 +35,9 @@ from django.views.generic import (
UpdateView, UpdateView,
) )
from core.auth.mixins import CanEditPropMixin, CanViewMixin, FormerSubscriberMixin
from core.models import Notification, User from core.models import Notification, User
from core.views import ( from core.views import DetailFormView
CanCreateMixin,
CanEditPropMixin,
CanViewMixin,
DetailFormView,
FormerSubscriberMixin,
)
from pedagogy.forms import ( from pedagogy.forms import (
UVCommentForm, UVCommentForm,
UVCommentModerationForm, UVCommentModerationForm,
@ -51,8 +46,6 @@ from pedagogy.forms import (
) )
from pedagogy.models import UV, UVComment, UVCommentReport from pedagogy.models import UV, UVComment, UVCommentReport
# Acutal views
class UVDetailFormView(CanViewMixin, DetailFormView): class UVDetailFormView(CanViewMixin, DetailFormView):
"""Display every comment of an UV and detailed infos about it. """Display every comment of an UV and detailed infos about it.
@ -138,12 +131,13 @@ class UVGuideView(LoginRequiredMixin, FormerSubscriberMixin, TemplateView):
} }
class UVCommentReportCreateView(CanCreateMixin, CreateView): class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inapropriate comment.""" """Create a new report for an inapropriate comment."""
model = UVCommentReport model = UVCommentReport
form_class = UVCommentReportForm form_class = UVCommentReportForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uvcommentreport"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"]) self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"])
@ -204,12 +198,13 @@ class UVModerationFormView(FormView):
return reverse_lazy("pedagogy:moderation") return reverse_lazy("pedagogy:moderation")
class UVCreateView(CanCreateMixin, CreateView): class UVCreateView(PermissionRequiredMixin, CreateView):
"""Add a new UV (Privileged).""" """Add a new UV (Privileged)."""
model = UV model = UV
form_class = UVForm form_class = UVForm
template_name = "pedagogy/uv_edit.jinja" template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.add_uv"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()

View File

@ -7,7 +7,7 @@
{% block content %} {% block content %}
{% if user.has_perm("core:add_userban") %} {% if user.has_perm("core.add_userban") %}
<a href="{{ url("rootplace:ban_create") }}" class="btn btn-red margin-bottom"> <a href="{{ url("rootplace:ban_create") }}" class="btn btn-red margin-bottom">
<i class="fa fa-person-circle-xmark"></i> <i class="fa fa-person-circle-xmark"></i>
{% trans %}Ban a user{% endtrans %} {% trans %}Ban a user{% endtrans %}
@ -44,7 +44,7 @@
<summary class="clickable">{% trans %}Reason{% endtrans %}</summary> <summary class="clickable">{% trans %}Reason{% endtrans %}</summary>
<p>{{ user_ban.reason }}</p> <p>{{ user_ban.reason }}</p>
</details> </details>
{% if user.has_perm("core:delete_userban") %} {% if user.has_perm("core.delete_userban") %}
<span> <span>
<a <a
href="{{ url("rootplace:ban_remove", ban_id=user_ban.id) }}" href="{{ url("rootplace:ban_remove", ban_id=user_ban.id) }}"

View File

@ -14,6 +14,7 @@
# #
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localtime, now from django.utils.timezone import localtime, now
@ -71,10 +72,12 @@ class TestMergeUser(TestCase):
assert self.to_keep.nick_name == "B'ian" assert self.to_keep.nick_name == "B'ian"
assert self.to_keep.address == "Jerusalem" assert self.to_keep.address == "Jerusalem"
assert self.to_keep.parent_address == "Rome" assert self.to_keep.parent_address == "Rome"
assert self.to_keep.groups.count() == 3 assert set(self.to_keep.groups.values_list("id", flat=True)) == {
groups = sorted(self.to_keep.groups.all(), key=lambda i: i.id) settings.SITH_GROUP_PUBLIC_ID,
expected = sorted([subscribers, mde_admin, sas_admin], key=lambda i: i.id) subscribers.id,
assert groups == expected mde_admin.id,
sas_admin.id,
}
def test_both_subscribers_and_with_account(self): def test_both_subscribers_and_with_account(self):
Customer(user=self.to_keep, account_id="11000l", amount=0).save() Customer(user=self.to_keep, account_id="11000l", amount=0).save()

View File

@ -12,7 +12,7 @@ from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
from core.models import Notification, User from core.models import Notification, User
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (

View File

@ -23,8 +23,8 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, TemplateView from django.views.generic import DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import CanEditMixin, CanViewMixin
from core.views.files import FileView, send_file from core.views.files import FileView, send_file
from sas.forms import ( from sas.forms import (
AlbumEditForm, AlbumEditForm,

View File

@ -152,15 +152,15 @@ TEMPLATES = [
"phonenumber": "core.templatetags.renderer.phonenumber", "phonenumber": "core.templatetags.renderer.phonenumber",
"truncate_time": "core.templatetags.renderer.truncate_time", "truncate_time": "core.templatetags.renderer.truncate_time",
"format_timedelta": "core.templatetags.renderer.format_timedelta", "format_timedelta": "core.templatetags.renderer.format_timedelta",
"add_attr": "core.templatetags.renderer.add_attr",
}, },
"globals": { "globals": {
"can_edit_prop": "core.views.can_edit_prop", "can_edit_prop": "core.auth.mixins.can_edit_prop",
"can_edit": "core.views.can_edit", "can_edit": "core.auth.mixins.can_edit",
"can_view": "core.views.can_view", "can_view": "core.auth.mixins.can_view",
"settings": "sith.settings", "settings": "sith.settings",
"Launderette": "launderette.models.Launderette", "Launderette": "launderette.models.Launderette",
"Counter": "counter.models.Counter", "Counter": "counter.models.Counter",
"ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language", "get_language": "django.utils.translation.get_language",
@ -291,9 +291,9 @@ STORAGES = {
# Auth configuration # Auth configuration
AUTH_USER_MODEL = "core.User" AUTH_USER_MODEL = "core.User"
AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser" AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser"
AUTHENTICATION_BACKENDS = ["core.auth_backends.SithModelBackend"] AUTHENTICATION_BACKENDS = ["core.auth.backends.SithModelBackend"]
LOGIN_URL = "/login" LOGIN_URL = "/login/"
LOGOUT_URL = "/logout" LOGOUT_URL = "/logout/"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
DEFAULT_FROM_EMAIL = "bibou@git.an" DEFAULT_FROM_EMAIL = "bibou@git.an"
SITH_COM_EMAIL = "bibou_com@git.an" SITH_COM_EMAIL = "bibou_com@git.an"

View File

@ -76,8 +76,11 @@ class Subscription(models.Model):
super().save() super().save()
from counter.models import Customer from counter.models import Customer
_, created = Customer.get_or_create(self.member) _, account_created = Customer.get_or_create(self.member)
if created: if account_created:
# Someone who subscribed once will be considered forever
# as an old subscriber.
self.member.groups.add(settings.SITH_GROUP_OLD_SUBSCRIBERS_ID)
form = PasswordResetForm({"email": self.member.email}) form = PasswordResetForm({"email": self.member.email})
if form.is_valid(): if form.is_valid():
form.save( form.save(

View File

@ -38,16 +38,15 @@ from django.views.generic import DetailView, RedirectView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club from club.models import Club
from core.models import User from core.auth.mixins import (
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
) )
from core.models import User
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.select import AutoCompleteSelectUser from core.views.widgets.select import AutoCompleteSelectUser
from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser from trombi.models import Trombi, TrombiClubMembership, TrombiComment, TrombiUser

View File

@ -97,10 +97,6 @@ export default defineConfig((config: UserConfig) => {
src: resolve(nodeModules, "jquery-ui/dist/jquery-ui.min.js"), src: resolve(nodeModules, "jquery-ui/dist/jquery-ui.min.js"),
dest: vendored, dest: vendored,
}, },
{
src: resolve(nodeModules, "jquery.shorten/src/jquery.shorten.min.js"),
dest: vendored,
},
], ],
}), }),
], ],