diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml
index 909bf757..2d2aae89 100644
--- a/.github/actions/setup_project/action.yml
+++ b/.github/actions/setup_project/action.yml
@@ -4,7 +4,7 @@ runs:
using: composite
steps:
- name: Install apt packages
- uses: awalsh128/cache-apt-pkgs-action@latest
+ uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a436879..57f36d6f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,7 @@ jobs:
uv run coverage report
uv run coverage html
- name: Archive code coverage results
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
- name: coverage-report
+ name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
diff --git a/club/views.py b/club/views.py
index de5ccaee..767f5788 100644
--- a/club/views.py
+++ b/club/views.py
@@ -256,7 +256,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user
- kwargs["club"] = self.get_object()
+ kwargs["club"] = self.object
kwargs["club_members"] = self.members
return kwargs
@@ -273,9 +273,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
users = data.pop("users", [])
users_old = data.pop("users_old", [])
for user in users:
- Membership(club=self.get_object(), user=user, **data).save()
+ Membership(club=self.object, user=user, **data).save()
for user in users_old:
- membership = self.get_object().get_membership_for(user)
+ membership = self.object.get_membership_for(user)
membership.end_date = timezone.now()
membership.save()
return resp
@@ -285,9 +285,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
- return reverse_lazy(
- "club:club_members", kwargs={"club_id": self.get_object().id}
- )
+ return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id})
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts
index 11a15a32..3c78f98f 100644
--- a/com/static/bundled/com/components/ics-calendar-index.ts
+++ b/com/static/bundled/com/components/ics-calendar-index.ts
@@ -152,6 +152,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
async connectedCallback() {
super.connectedCallback();
+ const cacheInvalidate = `?invalidate=${Date.now()}`;
this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale],
@@ -161,11 +162,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
headerToolbar: this.currentToolbar(),
eventSources: [
{
- url: await makeUrl(calendarCalendarInternal),
+ url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
},
{
- url: await makeUrl(calendarCalendarExternal),
+ url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
},
],
diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss
index 21aa55d7..6c86cce0 100644
--- a/com/static/com/components/ics-calendar.scss
+++ b/com/static/com/components/ics-calendar.scss
@@ -75,10 +75,10 @@ ics-calendar {
}
td {
- overflow-x: visible; // Show events on multiple days
+ overflow: visible; // Show events on multiple days
}
- //Reset from style.scss
+ //Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
@@ -86,13 +86,13 @@ ics-calendar {
margin: 0px;
}
- // Reset from style.scss
+ // Reset from style.scss
thead {
background-color: white;
color: black;
}
- // Reset from style.scss
+ // Reset from style.scss
tbody>tr {
&:nth-child(even):not(.highlight) {
background: white;
diff --git a/com/static/com/css/news-list.scss b/com/static/com/css/news-list.scss
index bcbf8273..d073c4ac 100644
--- a/com/static/com/css/news-list.scss
+++ b/com/static/com/css/news-list.scss
@@ -36,6 +36,11 @@
&:not(:first-of-type) {
margin: 2em 0 1em 0;
}
+
+ .feed {
+ float: right;
+ color: #f26522;
+ }
}
@media screen and (max-width: $small-devices) {
diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja
index 168a95b4..0f1f4301 100644
--- a/com/templates/com/news_list.jinja
+++ b/com/templates/com/news_list.jinja
@@ -8,6 +8,9 @@
{% block additional_css %}
+
+ {# Atom feed discovery, not really css but also goes there #}
+
{% endblock %}
{% block additional_js %}
@@ -19,7 +22,10 @@
{% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}
-
{% trans %}Events today and the next few days{% endtrans %}
+
+ {% trans %}Events today and the next few days{% endtrans %}
+
+
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
@@ -73,7 +79,10 @@
{% endif %}
-
{% trans %}All coming events{% endtrans %}
+
+ {% trans %}All coming events{% endtrans %}
+
+
diff --git a/com/tests/test_views.py b/com/tests/test_views.py
index 100a83ef..f80839ab 100644
--- a/com/tests/test_views.py
+++ b/com/tests/test_views.py
@@ -17,6 +17,7 @@ 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 TestCase
from django.urls import reverse
@@ -24,7 +25,7 @@ from django.utils import html
from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _
from model_bakery import baker
-from pytest_django.asserts import assertRedirects
+from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
@@ -319,3 +320,15 @@ class TestNewsCreation(TestCase):
self.valid_payload,
)
mocked.assert_called()
+
+
+@pytest.mark.django_db
+def test_feed(client):
+ """Smoke test that checks that the atom feed is working"""
+ Site.objects.clear_cache()
+ with assertNumQueries(2):
+ # get sith domain with Site api: 1 request
+ # get all news and related info: 1 request
+ resp = client.get(reverse("com:news_feed"))
+ assert resp.status_code == 200
+ assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8"
diff --git a/com/urls.py b/com/urls.py
index 592e653b..8afbfd12 100644
--- a/com/urls.py
+++ b/com/urls.py
@@ -25,6 +25,7 @@ from com.views import (
NewsCreateView,
NewsDeleteView,
NewsDetailView,
+ NewsFeed,
NewsListView,
NewsModerateView,
NewsUpdateView,
@@ -73,6 +74,7 @@ urlpatterns = [
name="weekmail_article_edit",
),
path("news/", NewsListView.as_view(), name="news_list"),
+ path("news/feed/", NewsFeed(), name="news_feed"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news//edit/", NewsUpdateView.as_view(), name="news_edit"),
diff --git a/com/views.py b/com/views.py
index a6faf214..0ab8fc1c 100644
--- a/com/views.py
+++ b/com/views.py
@@ -26,8 +26,10 @@ from datetime import timedelta
from smtplib import SMTPRecipientsRefused
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.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max
from django.forms.models import modelform_factory
@@ -268,6 +270,34 @@ class NewsDetailView(CanViewMixin, DetailView):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
+class NewsFeed(Feed):
+ title = _("News")
+ link = reverse_lazy("com:news_list")
+ description = _("All incoming events")
+
+ def items(self):
+ return (
+ NewsDate.objects.filter(
+ news__is_moderated=True,
+ end_date__gte=timezone.now() - (relativedelta(months=6)),
+ )
+ .select_related("news", "news__author")
+ .order_by("-start_date")
+ )
+
+ def item_title(self, item: NewsDate):
+ return item.news.title
+
+ def item_description(self, item: NewsDate):
+ return item.news.summary
+
+ def item_link(self, item: NewsDate):
+ return item.news.get_absolute_url()
+
+ def item_author_name(self, item: NewsDate):
+ return item.news.author.get_display_name()
+
+
# Weekmail
diff --git a/core/auth/api_permissions.py b/core/auth/api_permissions.py
index 4d83143e..6a28f13c 100644
--- a/core/auth/api_permissions.py
+++ b/core/auth/api_permissions.py
@@ -37,8 +37,11 @@ Example:
```
"""
+import operator
+from functools import reduce
from typing import Any
+from django.contrib.auth.models import Permission
from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
@@ -56,6 +59,46 @@ class IsInGroup(BasePermission):
return request.user.is_in_group(pk=self._group_pk)
+class HasPerm(BasePermission):
+ """Check that the user has the required perm.
+
+ If multiple perms are given, a comparer function can also be passed,
+ in order to change the way perms are checked.
+
+ Example:
+ ```python
+ # this route will require both permissions
+ @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
+ def foo(self): ...
+
+ # This route will require at least one of the perm,
+ # but it's not mandatory to have all of them
+ @route.put(
+ "/bar",
+ permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
+ )
+ def bar(self): ...
+ """
+
+ def __init__(
+ self, perms: str | Permission | list[str | Permission], op=operator.and_
+ ):
+ """
+ Args:
+ perms: a permission or a list of permissions the user must have
+ op: An operator to combine multiple permissions (in most cases,
+ it will be either `operator.and_` or `operator.or_`)
+ """
+ super().__init__()
+ if not isinstance(perms, (list, tuple, set)):
+ perms = [perms]
+ self._operator = op
+ self._perms = perms
+
+ def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
+ return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
+
+
class IsRoot(BasePermission):
"""Check that the user is root."""
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 5e0f099d..53638699 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -92,7 +92,12 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.")
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
- Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
+
+ site = Site.objects.get_current()
+ site.domain = settings.SITH_URL
+ site.name = settings.SITH_NAME
+ site.save()
+
groups = self._create_groups()
self._create_ban_groups()
@@ -120,6 +125,11 @@ class Command(BaseCommand):
unix_name=settings.SITH_MAIN_CLUB["unix_name"],
address=settings.SITH_MAIN_CLUB["address"],
)
+ main_club.board_group.permissions.add(
+ *Permission.objects.filter(
+ codename__in=["view_subscription", "add_subscription"]
+ )
+ )
bar_club = Club.objects.create(
id=2,
name=settings.SITH_BAR_MANAGER["name"],
@@ -895,13 +905,16 @@ Welcome to the wiki page!
subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(
- *list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
+ *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
)
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
+ "view_uv",
+ "view_uvcomment",
+ "add_uvcommentreport",
"view_user",
"view_picture",
"view_album",
@@ -973,9 +986,9 @@ Welcome to the wiki page!
)
pedagogy_admin.permissions.add(
*list(
- perms.filter(content_type__app_label="pedagogy").values_list(
- "pk", flat=True
- )
+ perms.filter(content_type__app_label="pedagogy")
+ .exclude(codename__in=["change_uvcomment"])
+ .values_list("pk", flat=True)
)
)
self.reset_index("core", "auth")
diff --git a/core/models.py b/core/models.py
index b1caa912..4748f311 100644
--- a/core/models.py
+++ b/core/models.py
@@ -417,29 +417,6 @@ class User(AbstractUser):
def is_board_member(self) -> bool:
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
- @cached_property
- def can_read_subscription_history(self) -> bool:
- if self.is_root or self.is_board_member:
- return True
-
- from club.models import Club
-
- for club in Club.objects.filter(
- id__in=settings.SITH_CAN_READ_SUBSCRIPTION_HISTORY
- ):
- if club in self.clubs_with_rights:
- return True
- return False
-
- @cached_property
- def can_create_subscription(self) -> bool:
- return self.is_root or (
- self.memberships.board()
- .ongoing()
- .filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
- .exists()
- )
-
@cached_property
def is_launderette_manager(self):
from club.models import Club
@@ -679,14 +656,6 @@ class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
- @property
- def can_create_subscription(self):
- return False
-
- @property
- def can_read_subscription_history(self):
- return False
-
@property
def was_subscribed(self):
return False
diff --git a/core/templates/core/edit.jinja b/core/templates/core/edit.jinja
index 25c6bd74..82c7a035 100644
--- a/core/templates/core/edit.jinja
+++ b/core/templates/core/edit.jinja
@@ -1,19 +1,40 @@
{% extends "core/base.jinja" %}
+{# if the template context has the `object_name` variable,
+ then this one will be used in the page title,
+ instead of the result of `str(object)` #}
+{% if object and not object_name %}
+ {% set object_name=object %}
+{% endif %}
+
{% block title %}
- {% if object %}
- {% trans obj=object %}Edit {{ obj }}{% endtrans %}
+ {% if object_name %}
+ {% trans name=object_name %}Edit {{ name }}{% endtrans %}
{% else %}
{% trans %}Save{% endtrans %}
{% endif %}
{% endblock %}
{% block content %}
- {% if object %}
- {% trans obj=object %}Edit {{ obj }}{% endtrans %}
+ {% if object_name %}
+ {% trans name=object_name %}Edit {{ name }}{% endtrans %}
{% else %}
{% trans %}Save{% endtrans %}
{% endif %}
+ {% if messages %}
+
+
+ {% for message in messages %}
+ {% if message.level_tag == "success" %}
+ {{ message }}
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+ {% endif %}