diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml
index d577edab..0ea9c853 100644
--- a/.github/auto_assign.yml
+++ b/.github/auto_assign.yml
@@ -6,7 +6,7 @@ addAssignees: author
# A list of team reviewers to be added to pull requests (GitHub team slug)
reviewers:
- - ae-utbm/sith-3-developers
+ - ae-utbm/developpeurs
# Number of reviewers has no impact on GitHub teams
# Set 0 to add all the reviewers (default: 0)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d9af29db..8fd9c718 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -16,7 +16,16 @@ multi-ecosystem-groups:
updates:
- package-ecosystem: "uv"
+ patterns: ["*"]
multi-ecosystem-group: "common"
- package-ecosystem: "npm"
+ patterns: ["*"]
multi-ecosystem-group: "common"
+ groups:
+ # npm supports production and development groups, but not uv
+ # cf. https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#dependency-type-groups
+ main-deps:
+ dependency-type: "production"
+ dev-deps:
+ dependency-type: "development"
diff --git a/club/migrations/0012_club_board_group_club_members_group.py b/club/migrations/0012_club_board_group_club_members_group.py
index f3d3a1e9..e436bcd4 100644
--- a/club/migrations/0012_club_board_group_club_members_group.py
+++ b/club/migrations/0012_club_board_group_club_members_group.py
@@ -34,12 +34,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
clubs = list(Club.objects.all())
for club in clubs:
club.board_group = meta_groups.get_or_create(
- name=club.unix_name + settings.SITH_BOARD_SUFFIX,
- defaults={"is_meta": True},
+ name=f"{club.unix_name}-bureau", defaults={"is_meta": True}
)[0]
club.members_group = meta_groups.get_or_create(
- name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
- defaults={"is_meta": True},
+ name=f"{club.unix_name}-membres", defaults={"is_meta": True}
)[0]
club.save()
club.refresh_from_db()
diff --git a/club/models.py b/club/models.py
index fc5aa64b..800f67c2 100644
--- a/club/models.py
+++ b/club/models.py
@@ -42,6 +42,13 @@ from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
+class ClubQuerySet(models.QuerySet):
+ def having_board_member(self, user: User) -> Self:
+ """Filter all club in which the given user is a board member."""
+ active_memberships = user.memberships.board().ongoing()
+ return self.filter(Exists(active_memberships.filter(club=OuterRef("pk"))))
+
+
class Club(models.Model):
"""The Club class, made as a tree to allow nice tidy organization."""
@@ -91,6 +98,8 @@ class Club(models.Model):
Group, related_name="club_board", on_delete=models.PROTECT
)
+ objects = ClubQuerySet.as_manager()
+
class Meta:
ordering = ["name"]
diff --git a/club/templates/club/club_detail.jinja b/club/templates/club/club_detail.jinja
index 2d2c5719..33760695 100644
--- a/club/templates/club/club_detail.jinja
+++ b/club/templates/club/club_detail.jinja
@@ -1,6 +1,14 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link %}
+{% block title -%}
+ {{ club.name }}
+{%- endblock %}
+
+{% block description -%}
+ {{ club.short_description }}
+{%- endblock %}
+
{% block content %}
{% if club.logo %}
diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja
index f914803e..da0a54be 100644
--- a/club/templates/club/club_list.jinja
+++ b/club/templates/club/club_list.jinja
@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %}
-{% block title %}
+{% block title -%}
{% trans %}Club list{% endtrans %}
-{% endblock %}
+{%- endblock %}
+
+{% block description -%}
+ {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
+{%- endblock %}
{% macro display_club(club) -%}
@@ -21,7 +25,7 @@
{%- if club.children.all()|length != 0 %}
- {%- for c in club.children.order_by('name') %}
+ {%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
@@ -36,8 +40,8 @@
{% if club_list %}
{% trans %}Club list{% endtrans %}
- {%- for c in club_list.all().order_by('name') if c.parent is none %}
- {{ display_club(c) }}
+ {%- for club in club_list %}
+ {{ display_club(club) }}
{%- endfor %}
{% else %}
diff --git a/club/tests/test_club.py b/club/tests/test_club.py
new file mode 100644
index 00000000..2a232b19
--- /dev/null
+++ b/club/tests/test_club.py
@@ -0,0 +1,27 @@
+from datetime import timedelta
+
+import pytest
+from django.utils.timezone import localdate
+from model_bakery import baker
+from model_bakery.recipe import Recipe
+
+from club.models import Club, Membership
+from core.baker_recipes import subscriber_user
+
+
+@pytest.mark.django_db
+def test_club_queryset_having_board_member():
+ clubs = baker.make(Club, _quantity=5)
+ user = subscriber_user.make()
+ membership_recipe = Recipe(
+ Membership, user=user, start_date=localdate() - timedelta(days=3)
+ )
+ membership_recipe.make(club=clubs[0], role=1)
+ membership_recipe.make(club=clubs[1], role=3)
+ membership_recipe.make(club=clubs[2], role=7)
+ membership_recipe.make(
+ club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
+ )
+
+ club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
+ assert set(club_ids) == {clubs[1].id, clubs[2].id}
diff --git a/club/tests/test_posters.py b/club/tests/test_posters.py
new file mode 100644
index 00000000..8f9941e5
--- /dev/null
+++ b/club/tests/test_posters.py
@@ -0,0 +1,35 @@
+import pytest
+from django.test import Client
+from django.urls import reverse
+from model_bakery import baker
+
+from club.models import Club
+from com.models import Poster
+from core.baker_recipes import subscriber_user
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("route_url", ["club:poster_list", "club:poster_create"])
+def test_access(client: Client, route_url):
+ club = baker.make(Club)
+ user = subscriber_user.make()
+ url = reverse(route_url, kwargs={"club_id": club.id})
+
+ client.force_login(user)
+ assert client.get(url).status_code == 403
+ club.board_group.users.add(user)
+ assert client.get(url).status_code == 200
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("route_url", ["club:poster_edit", "club:poster_delete"])
+def test_access_specific_poster(client: Client, route_url):
+ club = baker.make(Club)
+ user = subscriber_user.make()
+ poster = baker.make(Poster)
+ url = reverse(route_url, kwargs={"club_id": club.id, "poster_id": poster.id})
+
+ client.force_login(user)
+ assert client.get(url).status_code == 403
+ club.board_group.users.add(user)
+ assert client.get(url).status_code == 200
diff --git a/club/views.py b/club/views.py
index 184e64fd..4ed40a9c 100644
--- a/club/views.py
+++ b/club/views.py
@@ -51,13 +51,17 @@ from club.forms import (
SellingsForm,
)
from club.models import Club, Mailing, MailingSubscription, Membership
+from com.models import Poster
from com.views import (
PosterCreateBaseView,
PosterDeleteBaseView,
PosterEditBaseView,
PosterListBaseView,
)
-from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
+from core.auth.mixins import (
+ CanEditMixin,
+ CanViewMixin,
+)
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
@@ -66,9 +70,12 @@ from counter.models import Selling
class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self):
- obj = self.get_object()
- if isinstance(obj, PageRev):
- self.object = obj.page.club
+ if not hasattr(self, "object") or not self.object:
+ self.object = self.get_object()
+ if isinstance(self.object, PageRev):
+ self.object = self.object.page.club
+ elif isinstance(self.object, Poster):
+ self.object = self.object.club
return self.object.get_display_name()
def get_list_of_tabs(self):
@@ -159,7 +166,7 @@ class ClubTabsMixin(TabedViewMixin):
"club:poster_list", kwargs={"club_id": self.object.id}
),
"slug": "posters",
- "name": _("Posters list"),
+ "name": _("Posters"),
},
]
)
@@ -171,6 +178,10 @@ class ClubListView(ListView):
model = Club
template_name = "club/club_list.jinja"
+ queryset = (
+ Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
+ )
+ context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView):
@@ -682,48 +693,45 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id)
-class PosterListView(ClubTabsMixin, PosterListBaseView, CanViewMixin):
+class PosterListView(ClubTabsMixin, PosterListBaseView):
"""List communication posters."""
+ current_tab = "posters"
+ extra_context = {"app": "club"}
+
+ def get_queryset(self):
+ return super().get_queryset().filter(club=self.club.id)
+
def get_object(self):
return self.club
- def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["app"] = "club"
- kwargs["club"] = self.club
- return kwargs
-
-class PosterCreateView(PosterCreateBaseView, CanCreateMixin):
+class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
"""Create communication poster."""
- pk_url_kwarg = "club_id"
-
- def get_object(self):
- obj = super().get_object()
- if not obj:
- return self.club
- return obj
+ current_tab = "posters"
def get_success_url(self, **kwargs):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
+ def get_object(self, *args, **kwargs):
+ return self.club
-class PosterEditView(ClubTabsMixin, PosterEditBaseView, CanEditMixin):
+
+class PosterEditView(ClubTabsMixin, PosterEditBaseView):
"""Edit communication poster."""
+ current_tab = "posters"
+ extra_context = {"app": "club"}
+
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
- def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["app"] = "club"
- return kwargs
-
-class PosterDeleteView(PosterDeleteBaseView, ClubTabsMixin, CanEditMixin):
+class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
"""Delete communication poster."""
+ current_tab = "posters"
+
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
diff --git a/com/forms.py b/com/forms.py
index e94d697e..4d5d0e09 100644
--- a/com/forms.py
+++ b/com/forms.py
@@ -2,7 +2,6 @@ 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 _
@@ -35,20 +34,18 @@ class PosterForm(forms.ModelForm):
label=_("Start date"),
widget=SelectDateTime,
required=True,
- initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
+ initial=timezone.now(),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
- def __init__(self, *args, **kwargs):
- self.user = kwargs.pop("user", None)
+ def __init__(self, *args, user: User, **kwargs):
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")
+ if user.is_root or user.is_com_admin:
+ self.fields["club"].widget = AutoCompleteSelectClub()
+ else:
+ self.fields["club"].queryset = Club.objects.having_board_member(user)
class NewsDateForm(forms.ModelForm):
@@ -161,16 +158,9 @@ class NewsForm(forms.ModelForm):
# 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
- )
+ self.fields["club"].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")))
- )
- )
+ self.fields["club"].queryset = Club.objects.having_board_member(author)
def is_valid(self):
return super().is_valid() and self.date_form.is_valid()
diff --git a/com/ics_calendar.py b/com/ics_calendar.py
index 6cb10d2d..b0a2da5b 100644
--- a/com/ics_calendar.py
+++ b/com/ics_calendar.py
@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date,
end=news_date.end_date,
url=as_absolute_url(
- reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
+ reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
),
)
calendar.events.append(event)
diff --git a/com/models.py b/com/models.py
index ebc458e7..6485d73b 100644
--- a/com/models.py
+++ b/com/models.py
@@ -27,7 +27,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction
-from django.db.models import F, Q
+from django.db.models import Exists, F, OuterRef, Q
from django.shortcuts import render
from django.templatetags.static import static
from django.urls import reverse
@@ -55,9 +55,17 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet):
- def moderated(self) -> Self:
+ def published(self) -> Self:
return self.filter(is_published=True)
+ def waiting_moderation(self) -> Self:
+ """Filter all non-finished non-published news"""
+ # Because of the way News and NewsDates are created,
+ # there may be some cases where this method is called before
+ # the NewsDates linked to a Date are actually persisted in db.
+ # Thus, it's important to filter by "not past date" rather than by "future date"
+ return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False)
+
def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view.
@@ -127,20 +135,28 @@ class News(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
- if self.is_published:
- return
- for user in User.objects.filter(
- groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
- ):
- Notification.objects.create(
- user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
+ if not self.is_published:
+ admins_without_notif = User.objects.filter(
+ ~Exists(
+ Notification.objects.filter(
+ user=OuterRef("pk"), type="NEWS_MODERATION"
+ )
+ ),
+ groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
+ notif_url = reverse("com:news_admin_list")
+ new_notifs = [
+ Notification(user=user, url=notif_url, type="NEWS_MODERATION")
+ for user in admins_without_notif
+ ]
+ Notification.objects.bulk_create(new_notifs)
+ self.update_moderation_notifs()
def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self):
- return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url())
+ return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
def is_owned_by(self, user):
if user.is_anonymous:
@@ -159,19 +175,16 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id)
)
-
-def news_notification_callback(notif: Notification):
- # the NewsDate linked to the News
- # which creation triggered this callback may not exist yet,
- # so it's important to filter by "not past date" rather than by "future date"
- count = News.objects.filter(
- ~Q(dates__start_date__gt=timezone.now()), is_published=False
- ).count()
- if count:
- notif.viewed = False
- notif.param = str(count)
- else:
- notif.viewed = True
+ @staticmethod
+ def update_moderation_notifs():
+ count = News.objects.waiting_moderation().count()
+ notifs_qs = Notification.objects.filter(
+ type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID
+ )
+ if count:
+ notifs_qs.update(viewed=False, param=str(count))
+ else:
+ notifs_qs.update(viewed=True)
class NewsDateQuerySet(models.QuerySet):
@@ -399,17 +412,5 @@ class Poster(models.Model):
if self.date_end and self.date_begin > self.date_end:
raise ValidationError(_("Begin date should be before end date"))
- def is_owned_by(self, user):
- if user.is_anonymous:
- return False
- return user.is_com_admin or len(user.clubs_with_rights) > 0
-
- def can_be_moderated_by(self, user):
- return user.is_com_admin
-
def get_display_name(self):
return self.club.get_display_name()
-
- @property
- def page(self):
- return self.club.page
diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja
index 92e4dd71..1becd1ba 100644
--- a/com/templates/com/news_list.jinja
+++ b/com/templates/com/news_list.jinja
@@ -1,10 +1,6 @@
{% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %}
-{% block title %}
- {% trans %}News{% endtrans %}
-{% endblock %}
-
{% block additional_css %}
diff --git a/com/tests/test_notifications.py b/com/tests/test_notifications.py
index fa541efb..8ddbfcb3 100644
--- a/com/tests/test_notifications.py
+++ b/com/tests/test_notifications.py
@@ -1,13 +1,22 @@
+from datetime import timedelta
+
import pytest
from django.conf import settings
+from django.utils.timezone import now
from model_bakery import baker
-from com.models import News
+from com.models import News, NewsDate
+from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User
@pytest.mark.django_db
def test_notification_created():
+ # this news is unpublished, but is set in the past
+ # it shouldn't be taken into account when counting the number
+ # of news that are to be moderated
+ past_news = baker.make(News, is_published=False)
+ baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete()
Notification.objects.all().delete()
@@ -15,9 +24,28 @@ def test_notification_created():
for i in range(2):
# news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one.
- baker.make(News)
+ baker.make(News, is_published=False)
notifications = list(Notification.objects.all())
assert len(notifications) == 1
assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1)
+
+
+@pytest.mark.django_db
+def test_notification_edited_when_moderating_news():
+ com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
+ com_admins = subscriber_user.make(_quantity=3)
+ com_admin_group.users.set(com_admins)
+ Notification.objects.all().delete()
+ news = baker.make(News, is_published=False)
+ assert Notification.objects.count() == 3
+ assert Notification.objects.filter(viewed=False).count() == 3
+
+ news.is_published = True
+ news.moderator = com_admins[0]
+ news.save()
+ # when the news is moderated, the notification should be marked as read
+ # for all admins
+ assert Notification.objects.count() == 3
+ assert Notification.objects.filter(viewed=False).count() == 0
diff --git a/com/tests/test_views.py b/com/tests/test_views.py
index 607d4b3f..02081597 100644
--- a/com/tests/test_views.py
+++ b/com/tests/test_views.py
@@ -18,17 +18,16 @@ from unittest.mock import patch
import pytest
from django.conf import settings
from django.contrib.sites.models import Site
-from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import html
-from django.utils.timezone import localtime, now
+from django.utils.timezone import now
from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
-from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
+from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -207,31 +206,6 @@ class TestWeekmailArticle(TestCase):
assert not self.article.is_owned_by(self.sli)
-class TestPoster(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.com_admin = User.objects.get(username="comunity")
- cls.poster = Poster.objects.create(
- name="dummy",
- file=SimpleUploadedFile("dummy.jpg", b"azertyuiop"),
- club=Club.objects.first(),
- date_begin=localtime(now()),
- )
- cls.sli = User.objects.get(username="sli")
- cls.sli.memberships.all().delete()
- Membership(user=cls.sli, club=Club.objects.first(), role=5).save()
- cls.susbcriber = User.objects.get(username="subscriber")
- cls.anonymous = AnonymousUser()
-
- def test_poster_owner(self):
- """Test that poster are owned by com admins and board members in clubs."""
- assert self.poster.is_owned_by(self.com_admin)
- assert not self.poster.is_owned_by(self.anonymous)
-
- assert not self.poster.is_owned_by(self.susbcriber)
- assert self.poster.is_owned_by(self.sli)
-
-
class TestNewsCreation(TestCase):
@classmethod
def setUpTestData(cls):
diff --git a/com/views.py b/com/views.py
index 024cb781..1b58feac 100644
--- a/com/views.py
+++ b/com/views.py
@@ -28,7 +28,9 @@ from typing import Any
from dateutil.relativedelta import relativedelta
from django.conf import settings
-from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
+from django.contrib.auth.mixins import (
+ PermissionRequiredMixin,
+)
from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max
@@ -50,6 +52,7 @@ from core.auth.mixins import (
CanEditPropMixin,
CanViewMixin,
PermissionOrAuthorRequiredMixin,
+ PermissionOrClubBoardRequiredMixin,
)
from core.models import User
from core.views.mixins import QuickNotifMixin, TabedViewMixin
@@ -99,13 +102,6 @@ class ComTabsMixin(TabedViewMixin):
]
-class IsComAdminMixin(AccessMixin):
- def dispatch(self, request, *args, **kwargs):
- if not request.user.is_com_admin:
- raise PermissionDenied
- return super().dispatch(request, *args, **kwargs)
-
-
class ComEditView(ComTabsMixin, CanEditPropMixin, UpdateView):
model = Sith
template_name = "core/edit.jinja"
@@ -558,161 +554,109 @@ class MailingModerateView(View):
raise PermissionDenied
-class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin):
- current_tab = "posters"
-
-
-class PosterListBaseView(PosterAdminViewMixin, ListView):
+class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView):
"""List communication posters."""
- current_tab = "posters"
model = Poster
template_name = "com/poster_list.jinja"
-
- def dispatch(self, request, *args, **kwargs):
- club_id = kwargs.pop("club_id", None)
- self.club = None
- if club_id:
- self.club = get_object_or_404(Club, pk=club_id)
- return super().dispatch(request, *args, **kwargs)
-
- def get_queryset(self):
- if self.request.user.is_com_admin:
- return Poster.objects.all().order_by("-date_begin")
- else:
- return Poster.objects.filter(club=self.club.id)
+ permission_required = "com.view_poster"
+ ordering = ["-date_begin"]
def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- if not self.request.user.is_com_admin:
- kwargs["club"] = self.club
- return kwargs
+ return super().get_context_data(**kwargs) | {"club": self.club}
-class PosterCreateBaseView(PosterAdminViewMixin, CreateView):
+class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
"""Create communication poster."""
- current_tab = "posters"
form_class = PosterForm
template_name = "core/create.jinja"
+ permission_required = "com.add_poster"
def get_queryset(self):
return Poster.objects.all()
- def dispatch(self, request, *args, **kwargs):
- if "club_id" in kwargs:
- self.club = get_object_or_404(Club, pk=kwargs["club_id"])
- return super().dispatch(request, *args, **kwargs)
-
def get_form_kwargs(self):
- kwargs = super().get_form_kwargs()
- kwargs.update({"user": self.request.user})
- return kwargs
+ return super().get_form_kwargs() | {"user": self.request.user}
+
+ def get_initial(self):
+ return {"club": self.club}
def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- if not self.request.user.is_com_admin:
- kwargs["club"] = self.club
- return kwargs
+ return super().get_context_data(**kwargs) | {"club": self.club}
def form_valid(self, form):
- if self.request.user.is_com_admin:
+ if self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = True
return super().form_valid(form)
-class PosterEditBaseView(PosterAdminViewMixin, UpdateView):
+class PosterEditBaseView(PermissionOrClubBoardRequiredMixin, UpdateView):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
- current_tab = "posters"
form_class = PosterForm
template_name = "com/poster_edit.jinja"
-
- def get_initial(self):
- return {
- "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
- if self.object.date_begin
- else None,
- "date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
- if self.object.date_end
- else None,
- }
-
- def dispatch(self, request, *args, **kwargs):
- if kwargs.get("club_id"):
- try:
- self.club = Club.objects.get(pk=kwargs["club_id"])
- except Club.DoesNotExist as e:
- raise PermissionDenied from e
- return super().dispatch(request, *args, **kwargs)
+ permission_required = "com.change_poster"
def get_queryset(self):
return Poster.objects.all()
def get_form_kwargs(self):
- kwargs = super().get_form_kwargs()
- kwargs.update({"user": self.request.user})
- return kwargs
+ return super().get_form_kwargs() | {"user": self.request.user}
def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- if hasattr(self, "club"):
- kwargs["club"] = self.club
- return kwargs
+ return super().get_context_data(**kwargs) | {"club": self.club}
def form_valid(self, form):
- if self.request.user.is_com_admin:
+ if not self.request.user.has_perm("com.moderate_poster"):
form.instance.is_moderated = False
return super().form_valid(form)
-class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView):
+class PosterDeleteBaseView(
+ PermissionOrClubBoardRequiredMixin, ComTabsMixin, DeleteView
+):
"""Edit communication poster."""
pk_url_kwarg = "poster_id"
current_tab = "posters"
model = Poster
template_name = "core/delete_confirm.jinja"
-
- def dispatch(self, request, *args, **kwargs):
- if kwargs.get("club_id"):
- try:
- self.club = Club.objects.get(pk=kwargs["club_id"])
- except Club.DoesNotExist as e:
- raise PermissionDenied from e
- return super().dispatch(request, *args, **kwargs)
+ permission_required = "com.delete_poster"
-class PosterListView(PosterListBaseView):
+class PosterListView(ComTabsMixin, PosterListBaseView):
"""List communication posters."""
+ current_tab = "posters"
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ if self.request.user.has_perm("com.view_poster"):
+ return qs
+ return qs.filter(club=self.club.id)
+
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
-class PosterCreateView(PosterCreateBaseView):
+class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
"""Create communication poster."""
+ current_tab = "posters"
success_url = reverse_lazy("com:poster_list")
-
- def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["app"] = "com"
- return kwargs
+ extra_context = {"app": "com"}
-class PosterEditView(PosterEditBaseView):
+class PosterEditView(ComTabsMixin, PosterEditBaseView):
"""Edit communication poster."""
+ current_tab = "posters"
success_url = reverse_lazy("com:poster_list")
-
- def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["app"] = "com"
- return kwargs
+ extra_context = {"app": "com"}
class PosterDeleteView(PosterDeleteBaseView):
@@ -721,44 +665,39 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list")
-class PosterModerateListView(PosterAdminViewMixin, ListView):
+class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""Moderate list communication poster."""
current_tab = "posters"
model = Poster
template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all()
-
- def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["app"] = "com"
- return kwargs
+ permission_required = "com.moderate_poster"
+ extra_context = {"app": "com"}
-class PosterModerateView(PosterAdminViewMixin, View):
+class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster."""
+ current_tab = "posters"
+ permission_required = "com.moderate_poster"
+ extra_context = {"app": "com"}
+
def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"])
- if obj.can_be_moderated_by(request.user):
- obj.is_moderated = True
- obj.moderator = request.user
- obj.save()
- return redirect("com:poster_moderate_list")
- raise PermissionDenied
-
- def get_context_data(self, **kwargs):
- kwargs = super(PosterModerateListView, self).get_context_data(**kwargs)
- kwargs["app"] = "com"
- return kwargs
+ obj.is_moderated = True
+ obj.moderator = request.user
+ obj.save()
+ return redirect("com:poster_moderate_list")
-class ScreenListView(IsComAdminMixin, ComTabsMixin, ListView):
+class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""List communication screens."""
current_tab = "screens"
model = Screen
template_name = "com/screen_list.jinja"
+ permission_required = "com.view_screen"
class ScreenSlideshowView(DetailView):
@@ -769,12 +708,12 @@ class ScreenSlideshowView(DetailView):
template_name = "com/screen_slideshow.jinja"
def get_context_data(self, **kwargs):
- kwargs = super().get_context_data(**kwargs)
- kwargs["posters"] = self.object.active_posters()
- return kwargs
+ return super().get_context_data(**kwargs) | {
+ "posters": self.object.active_posters()
+ }
-class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
+class ScreenCreateView(PermissionRequiredMixin, ComTabsMixin, CreateView):
"""Create communication screen."""
current_tab = "screens"
@@ -782,9 +721,10 @@ class ScreenCreateView(IsComAdminMixin, ComTabsMixin, CreateView):
fields = ["name"]
template_name = "core/create.jinja"
success_url = reverse_lazy("com:screen_list")
+ permission_required = "com.add_screen"
-class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
+class ScreenEditView(PermissionRequiredMixin, ComTabsMixin, UpdateView):
"""Edit communication screen."""
pk_url_kwarg = "screen_id"
@@ -793,9 +733,10 @@ class ScreenEditView(IsComAdminMixin, ComTabsMixin, UpdateView):
fields = ["name"]
template_name = "com/screen_edit.jinja"
success_url = reverse_lazy("com:screen_list")
+ permission_required = "com.change_screen"
-class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
+class ScreenDeleteView(PermissionRequiredMixin, ComTabsMixin, DeleteView):
"""Delete communication screen."""
pk_url_kwarg = "screen_id"
@@ -803,3 +744,4 @@ class ScreenDeleteView(IsComAdminMixin, ComTabsMixin, DeleteView):
model = Screen
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:screen_list")
+ permission_required = "com.delete_screen"
diff --git a/core/api.py b/core/api.py
index 06b32989..af4daff5 100644
--- a/core/api.py
+++ b/core/api.py
@@ -25,6 +25,7 @@ from core.schemas import (
UserFamilySchema,
UserFilterSchema,
UserProfileSchema,
+ UserSchema,
)
from core.templatetags.renderer import markdown
@@ -69,16 +70,22 @@ class MailingListController(ControllerBase):
return data
-@api_controller("/user", permissions=[CanAccessLookup])
+@api_controller("/user")
class UserController(ControllerBase):
- @route.get("", response=list[UserProfileSchema])
+ @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks)
+ @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
+ def fetch_user(self, user_id: int):
+ """Fetch a single user"""
+ return self.get_object_or_exception(User, id=user_id)
+
@route.get(
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
+ permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
diff --git a/core/auth/mixins.py b/core/auth/mixins.py
index dfba3f11..c4e603a9 100644
--- a/core/auth/mixins.py
+++ b/core/auth/mixins.py
@@ -29,8 +29,14 @@ from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.utils.functional import cached_property
+from django.utils.translation import gettext as _
from django.views.generic.base import View
+from club.models import Club
+
if TYPE_CHECKING:
from django.db.models import Model
@@ -297,3 +303,50 @@ class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id
+
+
+class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
+ """Require that the user has the required perm or is the board of the club.
+
+ This mixin can be used in any view that is called from a url
+ having a `club_id` kwarg.
+
+ Example:
+
+ In `urls.py` :
+ ```python
+ urlpatterns = [
+ path("foo//bar/", FooView.as_view())
+ ]
+ ```
+
+ In `views.py` :
+
+ ```python
+ # this view is available to users that either have the
+ # "foo.view_foo" permission or are in the board of the club
+ # which id was given in the url
+ class FooView(PermissionOrClubBoardRequiredMixin, View):
+ permission_required = "foo.view_foo"
+ ```
+ """
+
+ club_pk_url_kwarg = "club_id"
+
+ @cached_property
+ def club(self):
+ club_id: str | int = self.kwargs.pop(self.club_pk_url_kwarg, None)
+ if club_id is None:
+ return None
+ if isinstance(club_id, int) or club_id.isdigit():
+ return get_object_or_404(Club, pk=club_id)
+ raise Http404(_("No club found with id %(id)s") % {"id": club_id})
+
+ def has_permission(self):
+ if self.request.user.is_anonymous:
+ return False
+ if super().has_permission():
+ return True
+ return self.club is not None and any(
+ g.id == self.club.board_group_id for g in self.request.user.cached_groups
+ )
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index f9456712..38803dc8 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -94,7 +94,11 @@ class Command(BaseCommand):
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),
+ date_of_birth=(
+ None
+ if random.random() < 0.2
+ else self.faker.date_of_birth(minimum_age=15, maximum_age=25)
+ ),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),
diff --git a/core/models.py b/core/models.py
index 64b4b778..f2a799c3 100644
--- a/core/models.py
+++ b/core/models.py
@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
- if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
+ if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
return True
return self.is_root
@@ -569,9 +569,15 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
if hasattr(obj, "edit_groups"):
- for pk in obj.edit_groups.values_list("pk", flat=True):
- if self.is_in_group(pk=pk):
- return True
+ if (
+ hasattr(obj, "_prefetched_objects_cache")
+ and "edit_groups" in obj._prefetched_objects_cache
+ ):
+ pks = [g.id for g in obj.edit_groups.all()]
+ else:
+ pks = list(obj.edit_groups.values_list("id", flat=True))
+ if any(self.is_in_group(pk=pk) for pk in pks):
+ return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj)
@@ -581,9 +587,18 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
if hasattr(obj, "view_groups"):
- for pk in obj.view_groups.values_list("pk", flat=True):
- if self.is_in_group(pk=pk):
- return True
+ # if "view_groups" has already been prefetched, use
+ # the prefetch cache, else fetch only the ids, to make
+ # the query lighter.
+ if (
+ hasattr(obj, "_prefetched_objects_cache")
+ and "view_groups" in obj._prefetched_objects_cache
+ ):
+ pks = [g.id for g in obj.view_groups.all()]
+ else:
+ pks = list(obj.view_groups.values_list("id", flat=True))
+ if any(self.is_in_group(pk=pk) for pk in pks):
+ return True
return self.can_edit(obj)
def can_be_edited_by(self, user):
@@ -1384,9 +1399,9 @@ class Page(models.Model):
@cached_property
def is_club_page(self):
- club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
- return club_root_page is not None and (
- self == club_root_page or club_root_page in self.get_parent_list()
+ return (
+ self.name == settings.SITH_CLUB_ROOT_PAGE
+ or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()]
)
@cached_property
diff --git a/core/schemas.py b/core/schemas.py
index b5f5991f..5231d859 100644
--- a/core/schemas.py
+++ b/core/schemas.py
@@ -34,6 +34,22 @@ class SimpleUserSchema(ModelSchema):
fields = ["id", "nick_name", "first_name", "last_name"]
+class UserSchema(ModelSchema):
+ class Meta:
+ model = User
+ fields = [
+ "id",
+ "nick_name",
+ "first_name",
+ "last_name",
+ "date_of_birth",
+ "email",
+ "role",
+ "quote",
+ "promo",
+ ]
+
+
class UserProfileSchema(ModelSchema):
"""The necessary information to show a user profile"""
diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja
index 225abcfd..b3cf07bb 100644
--- a/core/templates/core/base.jinja
+++ b/core/templates/core/base.jinja
@@ -2,8 +2,14 @@
{% block head %}
- {% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM
+ {% block title %}Association des Étudiants de l'UTBM{% endblock %}
+
diff --git a/core/templates/core/page_list.jinja b/core/templates/core/page_list.jinja
index dfb8ea48..7a64035b 100644
--- a/core/templates/core/page_list.jinja
+++ b/core/templates/core/page_list.jinja
@@ -5,16 +5,12 @@
{% endblock %}
{% block content %}
- {% if page_list %}
-