- {{ news.get_type_display() }} |
{{ news.title }} |
{{ news.summary|markdown }} |
{{ news.club }} |
{{ user_profile_link(news.author) }} |
{{ user_profile_link(news.moderator) }} |
- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} |
- {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} |
+ {{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
+ {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }} |
+ {{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
+ {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} |
{% trans %}View{% endtrans %}
{% trans %}Edit{% endtrans %}
{% trans %}Remove{% endtrans %}
@@ -282,7 +135,6 @@
- {% trans %}Type{% endtrans %} |
{% trans %}Title{% endtrans %} |
{% trans %}Summary{% endtrans %} |
{% trans %}Club{% endtrans %} |
@@ -295,15 +147,14 @@
{% for news in events.filter(is_moderated=False) %}
- {{ news.get_type_display() }} |
{{ news.title }} |
{{ news.summary|markdown }} |
{{ news.club }} |
{{ user_profile_link(news.author) }} |
- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} |
- {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }} |
+ {{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }}
+ {{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }} |
+ {{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }}
+ {{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }} |
{% trans %}View{% endtrans %}
{% trans %}Edit{% endtrans %}
{% trans %}Moderate{% endtrans %}
diff --git a/com/templates/com/news_detail.jinja b/com/templates/com/news_detail.jinja
index 238515ed..454eb2ef 100644
--- a/com/templates/com/news_detail.jinja
+++ b/com/templates/com/news_detail.jinja
@@ -25,10 +25,10 @@
{{ news.title }}
- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
- {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}
+ {{ date.start_date|localtime|date(DATETIME_FORMAT) }}
+ {{ date.start_date|localtime|time(DATETIME_FORMAT) }} -
+ {{ date.end_date|localtime|date(DATETIME_FORMAT) }}
+ {{ date.end_date|localtime|time(DATETIME_FORMAT) }}
{{ news.summary|markdown }}
diff --git a/com/templates/com/news_edit.jinja b/com/templates/com/news_edit.jinja
index 74040dc8..e3302fb5 100644
--- a/com/templates/com/news_edit.jinja
+++ b/com/templates/com/news_edit.jinja
@@ -10,21 +10,6 @@
{% endblock %}
{% block content %}
- {% if 'preview' in request.POST.keys() %}
-
- {{ form.instance.title }}
-
- {{ form.instance.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
- {{ form.instance.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
- {{ form.instance.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
- {{ form.instance.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}
-
- {{ form.instance.club or "Club" }}
- {{ form.instance.summary|markdown }}
- {{ form.instance.content|markdown }}
- {% trans %}Author: {% endtrans %} {{ user_profile_link(form.instance.author) }}
-
- {% endif %}
{% if object %}
{% trans %}Edit news{% endtrans %}
{% else %}
@@ -33,103 +18,73 @@
{% endblock %}
-
-{% block script %}
- {{ super() }}
-
-{% endblock %}
-
-
diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja
index e0f5c4e5..168a95b4 100644
--- a/com/templates/com/news_list.jinja
+++ b/com/templates/com/news_list.jinja
@@ -15,37 +15,21 @@
{% endblock %}
{% block content %}
- {% if user.is_com_admin %}
-
-
- {% endif %}
- {% for news in object_list.filter(type="NOTICE") %}
-
-
- {{ news.summary|markdown }}
-
- {% endfor %}
-
- {% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
-
-
-
- {{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
- {{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
- {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}
-
- {{ news.summary|markdown }}
-
- {% endfor %}
-
- {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__type="EVENT", news__is_moderated=True).datetimes('start_date', 'day') %}
+ {% set events_dates = NewsDate.objects.filter(end_date__gte=timezone.now(), start_date__lte=timezone.now()+timedelta(days=5), news__is_moderated=True).datetimes('start_date', 'day') %}
{% trans %}Events today and the next few days{% endtrans %}
+ {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
+
+
+ {% trans %}Create news{% endtrans %}
+
+ {% endif %}
+ {% if user.is_com_admin %}
+ {% trans %}Administrate news{% endtrans %}
+
+ {% endif %}
{% if events_dates %}
{% for d in events_dates %}
@@ -57,113 +41,104 @@
- {% for news in object_list.filter(dates__start_date__gte=d,
- dates__start_date__lte=d+timedelta(days=1),
- type="EVENT").exclude(dates__end_date__lt=timezone.now())
- .order_by('dates__start_date') %}
-
-
- {% if news.club.logo %}
-
- {% else %}
-
- {% endif %}
-
-
-
-
- {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
- {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}
-
- {{ news.summary|markdown }}
-
-
+
+
+
+ {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
+ {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}
+
+ {{ news.summary|markdown }}
+
+ {{ fb_quick(news) }}
+ {{ tweet_quick(news) }}
+
+
+
+ {% endfor %}
+
+
{% endfor %}
+ {% else %}
+
+ {% trans %}Nothing to come...{% endtrans %}
-
- {% endfor %}
-{% else %}
-
- {% trans %}Nothing to come...{% endtrans %}
-
-{% endif %}
+ {% endif %}
-
- {% trans %}All coming events{% endtrans %}
-
-
-
-
-
-
-
- {% trans %}Links{% endtrans %}
-
- {% trans %}Our services{% endtrans %}
-
-
- {% trans %}Social media{% endtrans %}
-
+ {% trans %}All coming events{% endtrans %}
+
-
-
- {% trans %}Birthdays{% endtrans %}
-
- {%- if user.was_subscribed -%}
-
- {%- for year, users in birthdays -%}
+
+
+
+
+ {% trans %}Birthdays{% endtrans %}
+
+ {%- if user.was_subscribed -%}
+
+ {%- for year, users in birthdays -%}
+ -
+ {% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
+
+
+ {%- endfor -%}
+
+ {%- else -%}
+ {% trans %}You need to subscribe to access this content{% endtrans %}
+ {%- endif -%}
+
+
-
-
-
-
-
{% endblock %}
diff --git a/com/tests/test_models.py b/com/tests/test_models.py
new file mode 100644
index 00000000..41be34ee
--- /dev/null
+++ b/com/tests/test_models.py
@@ -0,0 +1,42 @@
+import itertools
+
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+from model_bakery import baker
+
+from com.models import News
+from core.models import User
+
+
+class TestNewsViewableBy(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ News.objects.all().delete()
+ cls.users = baker.make(User, _quantity=3, _bulk_create=True)
+ # There are six news and six authors.
+ # Each author has one moderated and one non-moderated news
+ cls.news = baker.make(
+ News,
+ author=itertools.cycle(cls.users),
+ is_moderated=iter([True, True, True, False, False, False]),
+ _quantity=6,
+ _bulk_create=True,
+ )
+
+ def test_admin_can_view_everything(self):
+ """Test with a user that can view non moderated news."""
+ user = baker.make(
+ User,
+ user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
+ )
+ assert set(News.objects.viewable_by(user)) == set(self.news)
+
+ def test_normal_user_can_view_moderated_and_self_news(self):
+ """Test that basic users can view moderated news and news they authored."""
+ user = self.news[0].author
+ assert set(News.objects.viewable_by(user)) == {
+ self.news[0],
+ self.news[1],
+ self.news[2],
+ self.news[3],
+ }
diff --git a/com/tests/test_views.py b/com/tests/test_views.py
index 3f98bfdc..100a83ef 100644
--- a/com/tests/test_views.py
+++ b/com/tests/test_views.py
@@ -12,6 +12,9 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
+from datetime import timedelta
+from unittest.mock import patch
+
import pytest
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -20,9 +23,12 @@ from django.urls import reverse
from django.utils import html
from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _
+from model_bakery import baker
+from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
-from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
+from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
+from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -137,15 +143,8 @@ class TestNews(TestCase):
@classmethod
def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity")
- new = News.objects.create(
- title="dummy new",
- summary="This is a dummy new",
- content="Look at that beautiful dummy new",
- author=User.objects.get(username="subscriber"),
- club=Club.objects.first(),
- )
- cls.new = new
- cls.author = new.author
+ cls.new = baker.make(News)
+ cls.author = cls.new.author
cls.sli = User.objects.get(username="sli")
cls.anonymous = AnonymousUser()
@@ -160,13 +159,13 @@ class TestNews(TestCase):
def test_news_viewer(self):
"""Test that moderated news can be viewed by anyone
- and not moderated news only by com admins.
+ and not moderated news only by com admins and by their author.
"""
- # by default a news isn't moderated
+ # by default news aren't moderated
assert self.new.can_be_viewed_by(self.com_admin)
+ assert self.new.can_be_viewed_by(self.author)
assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous)
- assert not self.new.can_be_viewed_by(self.author)
self.new.is_moderated = True
self.new.save()
@@ -176,11 +175,11 @@ class TestNews(TestCase):
assert self.new.can_be_viewed_by(self.author)
def test_news_editor(self):
- """Test that only com admins can edit news."""
+ """Test that only com admins and the original author can edit news."""
assert self.new.can_be_edited_by(self.com_admin)
+ assert self.new.can_be_edited_by(self.author)
assert not self.new.can_be_edited_by(self.sli)
assert not self.new.can_be_edited_by(self.anonymous)
- assert not self.new.can_be_edited_by(self.author)
class TestWeekmailArticle(TestCase):
@@ -230,3 +229,93 @@ class TestPoster(TestCase):
assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli)
+
+
+class TestNewsCreation(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.club = baker.make(Club)
+ cls.user = subscriber_user.make()
+ baker.make(Membership, user=cls.user, club=cls.club, role=5)
+
+ def setUp(self):
+ self.client.force_login(self.user)
+ self.start = now() + timedelta(days=1)
+ self.end = self.start + timedelta(hours=5)
+ self.valid_payload = {
+ "title": "Test news",
+ "summary": "This is a test news",
+ "content": "This is a test news",
+ "club": self.club.pk,
+ "is_weekly": False,
+ "start_date": self.start,
+ "end_date": self.end,
+ }
+
+ def test_create_news(self):
+ response = self.client.post(reverse("com:news_new"), self.valid_payload)
+ created = News.objects.order_by("id").last()
+ assertRedirects(response, created.get_absolute_url())
+ assert created.title == "Test news"
+ assert not created.is_moderated
+ dates = list(created.dates.values("start_date", "end_date"))
+ assert dates == [{"start_date": self.start, "end_date": self.end}]
+
+ def test_create_news_multiple_dates(self):
+ self.valid_payload["is_weekly"] = True
+ self.valid_payload["occurrences"] = 2
+ response = self.client.post(reverse("com:news_new"), self.valid_payload)
+ created = News.objects.order_by("id").last()
+
+ assertRedirects(response, created.get_absolute_url())
+ dates = list(
+ created.dates.values("start_date", "end_date").order_by("start_date")
+ )
+ assert dates == [
+ {"start_date": self.start, "end_date": self.end},
+ {
+ "start_date": self.start + timedelta(days=7),
+ "end_date": self.end + timedelta(days=7),
+ },
+ ]
+
+ def test_edit_news(self):
+ news = baker.make(News, author=self.user, is_moderated=True)
+ baker.make(
+ NewsDate,
+ news=news,
+ start_date=self.start + timedelta(hours=1),
+ end_date=self.end + timedelta(hours=1),
+ _quantity=2,
+ )
+
+ response = self.client.post(
+ reverse("com:news_edit", kwargs={"news_id": news.id}), self.valid_payload
+ )
+ created = News.objects.order_by("id").last()
+ assertRedirects(response, created.get_absolute_url())
+ assert created.title == "Test news"
+ assert not created.is_moderated
+ dates = list(created.dates.values("start_date", "end_date"))
+ assert dates == [{"start_date": self.start, "end_date": self.end}]
+
+ def test_ics_updated(self):
+ """Test that the internal ICS is updated when news are created"""
+
+ # we will just test that the ICS is modified.
+ # Checking that the ICS is *well* modified is up to the ICS tests
+ with patch("com.calendar.IcsCalendar.make_internal") as mocked:
+ self.client.post(reverse("com:news_new"), self.valid_payload)
+ mocked.assert_called()
+
+ # The ICS file should also change after an update
+ self.valid_payload["is_weekly"] = True
+ self.valid_payload["occurrences"] = 2
+ last_news = News.objects.order_by("id").last()
+
+ with patch("com.calendar.IcsCalendar.make_internal") as mocked:
+ self.client.post(
+ reverse("com:news_edit", kwargs={"news_id": last_news.id}),
+ self.valid_payload,
+ )
+ mocked.assert_called()
diff --git a/com/urls.py b/com/urls.py
index 304e5312..592e653b 100644
--- a/com/urls.py
+++ b/com/urls.py
@@ -25,9 +25,9 @@ from com.views import (
NewsCreateView,
NewsDeleteView,
NewsDetailView,
- NewsEditView,
NewsListView,
NewsModerateView,
+ NewsUpdateView,
PosterCreateView,
PosterDeleteView,
PosterEditView,
@@ -75,11 +75,11 @@ urlpatterns = [
path("news/", NewsListView.as_view(), name="news_list"),
path("news/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"),
+ path("news//edit/", NewsUpdateView.as_view(), name="news_edit"),
path("news//delete/", NewsDeleteView.as_view(), name="news_delete"),
path(
"news//moderate/", NewsModerateView.as_view(), name="news_moderate"
),
- path("news//edit/", NewsEditView.as_view(), name="news_edit"),
path("news//", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path(
diff --git a/com/views.py b/com/views.py
index f9993b3c..a6faf214 100644
--- a/com/views.py
+++ b/com/views.py
@@ -24,11 +24,12 @@
import itertools
from datetime import timedelta
from smtplib import SMTPRecipientsRefused
+from typing import Any
-from django import forms
from django.conf import settings
+from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied, ValidationError
-from django.db.models import Exists, Max, OuterRef
+from django.db.models import Max
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@@ -37,21 +38,19 @@ from django.utils import timezone
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
-from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing
+from com.calendar import IcsCalendar
+from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
-from core.models import Notification, User
-from core.views import (
- CanCreateMixin,
- CanEditMixin,
+from core.auth.mixins import (
CanEditPropMixin,
CanViewMixin,
- QuickNotifMixin,
- TabedViewMixin,
+ PermissionOrAuthorRequiredMixin,
)
-from core.views.forms import SelectDateTime
+from core.models import User
+from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput
# Sith object
@@ -59,92 +58,47 @@ from core.views.widgets.markdown import MarkdownInput
sith = Sith.objects.first
-class PosterForm(forms.ModelForm):
- class Meta:
- model = Poster
- fields = [
- "name",
- "file",
- "club",
- "screens",
- "date_begin",
- "date_end",
- "display_time",
- ]
- widgets = {"screens": forms.CheckboxSelectMultiple}
- help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
-
- date_begin = forms.DateTimeField(
- label=_("Start date"),
- widget=SelectDateTime,
- required=True,
- initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
- )
- date_end = forms.DateTimeField(
- label=_("End date"), widget=SelectDateTime, required=False
- )
-
- def __init__(self, *args, **kwargs):
- self.user = kwargs.pop("user", None)
- super().__init__(*args, **kwargs)
- if self.user and not self.user.is_com_admin:
- self.fields["club"].queryset = Club.objects.filter(
- id__in=self.user.clubs_with_rights
- )
- self.fields.pop("display_time")
-
-
class ComTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return _("Communication administration")
def get_list_of_tabs(self):
- tab_list = []
- tab_list.append(
- {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
- )
- tab_list.append(
+ return [
+ {"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")},
{
"url": reverse("com:weekmail_destinations"),
"slug": "weekmail_destinations",
"name": _("Weekmail destinations"),
- }
- )
- tab_list.append(
- {"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
- )
- tab_list.append(
+ },
+ {
+ "url": reverse("com:info_edit"),
+ "slug": "info",
+ "name": _("Info message"),
+ },
{
"url": reverse("com:alert_edit"),
"slug": "alert",
"name": _("Alert message"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse("com:poster_list"),
"slug": "posters",
"name": _("Posters list"),
- }
- )
- tab_list.append(
+ },
{
"url": reverse("com:screen_list"),
"slug": "screens",
"name": _("Screens list"),
- }
- )
- return tab_list
+ },
+ ]
-class IsComAdminMixin(View):
+class IsComAdminMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin:
raise PermissionDenied
@@ -184,167 +138,79 @@ class WeekmailDestinationEditView(ComEditView):
# News
-class NewsForm(forms.ModelForm):
- class Meta:
- model = News
- fields = ["type", "title", "club", "summary", "content", "author"]
- widgets = {
- "author": forms.HiddenInput,
- "type": forms.RadioSelect,
- "summary": MarkdownInput,
- "content": MarkdownInput,
+class NewsCreateView(PermissionRequiredMixin, CreateView):
+ """View to either create or update News."""
+
+ model = News
+ form_class = NewsForm
+ template_name = "com/news_edit.jinja"
+ permission_required = "com.add_news"
+
+ def get_date_form_kwargs(self) -> dict[str, Any]:
+ """Get initial data for NewsDateForm"""
+ if self.request.method == "POST":
+ return {"data": self.request.POST}
+ return {}
+
+ def get_form_kwargs(self):
+ return super().get_form_kwargs() | {
+ "author": self.request.user,
+ "date_form": NewsDateForm(**self.get_date_form_kwargs()),
}
- start_date = forms.DateTimeField(
- label=_("Start date"), widget=SelectDateTime, required=False
- )
- end_date = forms.DateTimeField(
- label=_("End date"), widget=SelectDateTime, required=False
- )
- until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
-
- automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
-
- def clean(self):
- self.cleaned_data = super().clean()
- if self.cleaned_data["type"] != "NOTICE":
- if not self.cleaned_data["start_date"]:
- self.add_error(
- "start_date", ValidationError(_("This field is required."))
- )
- if not self.cleaned_data["end_date"]:
- self.add_error(
- "end_date", ValidationError(_("This field is required."))
- )
- if (
- not self.has_error("start_date")
- and not self.has_error("end_date")
- and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
- ):
- self.add_error(
- "end_date",
- ValidationError(_("An event cannot end before its beginning.")),
- )
- if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
- self.add_error("until", ValidationError(_("This field is required.")))
- return self.cleaned_data
-
- def save(self, *args, **kwargs):
- ret = super().save()
- self.instance.dates.all().delete()
- if self.instance.type == "EVENT" or self.instance.type == "CALL":
- NewsDate(
- start_date=self.cleaned_data["start_date"],
- end_date=self.cleaned_data["end_date"],
- news=self.instance,
- ).save()
- elif self.instance.type == "WEEKLY":
- start_date = self.cleaned_data["start_date"]
- end_date = self.cleaned_data["end_date"]
- while start_date <= self.cleaned_data["until"]:
- NewsDate(
- start_date=start_date, end_date=end_date, news=self.instance
- ).save()
- start_date += timedelta(days=7)
- end_date += timedelta(days=7)
- return ret
+ def get_initial(self):
+ init = super().get_initial()
+ # if the id of a club is provided, select it by default
+ if club_id := self.request.GET.get("club"):
+ init["club"] = Club.objects.filter(id=club_id).first()
+ return init
-class NewsEditView(CanEditMixin, UpdateView):
+class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
form_class = NewsForm
template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id"
-
- def get_initial(self):
- news_date: NewsDate = self.object.dates.order_by("id").first()
- if news_date is None:
- return {"start_date": None, "end_date": None}
- return {"start_date": news_date.start_date, "end_date": news_date.end_date}
-
- def post(self, request, *args, **kwargs):
- form = self.get_form()
- if form.is_valid() and "preview" not in request.POST:
- return self.form_valid(form)
- else:
- return self.form_invalid(form)
+ permission_required = "com.edit_news"
def form_valid(self, form):
- self.object = form.save()
- if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
- self.object.moderator = self.request.user
- self.object.is_moderated = True
- self.object.save()
- else:
- self.object.is_moderated = False
- self.object.save()
- unread_notif_subquery = Notification.objects.filter(
- user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
- )
- for user in User.objects.filter(
- ~Exists(unread_notif_subquery),
- groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
- ):
- Notification.objects.create(
- user=user,
- url=self.object.get_absolute_url(),
- type="NEWS_MODERATION",
- )
- return super().form_valid(form)
+ response = super().form_valid(form) # Does the saving part
+ IcsCalendar.make_internal()
+ return response
+
+ def get_date_form_kwargs(self) -> dict[str, Any]:
+ """Get initial data for NewsDateForm"""
+ response = {}
+ if self.request.method == "POST":
+ response["data"] = self.request.POST
+ dates = list(self.object.dates.order_by("id"))
+ if len(dates) == 0:
+ return {}
+ response["instance"] = dates[0]
+ occurrences = NewsDateForm.get_occurrences(len(dates))
+ if occurrences is not None:
+ response["initial"] = {"is_weekly": True, "occurrences": occurrences}
+ return response
+
+ def get_form_kwargs(self):
+ return super().get_form_kwargs() | {
+ "author": self.request.user,
+ "date_form": NewsDateForm(**self.get_date_form_kwargs()),
+ }
-class NewsCreateView(CanCreateMixin, CreateView):
- model = News
- form_class = NewsForm
- template_name = "com/news_edit.jinja"
-
- def get_initial(self):
- init = {"author": self.request.user}
- if "club" not in self.request.GET:
- return init
- init["club"] = Club.objects.filter(id=self.request.GET["club"]).first()
- return init
-
- def post(self, request, *args, **kwargs):
- form = self.get_form()
- if form.is_valid() and "preview" not in request.POST:
- return self.form_valid(form)
- else:
- self.object = form.instance
- return self.form_invalid(form)
-
- def form_valid(self, form):
- self.object = form.save()
- if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
- self.object.moderator = self.request.user
- self.object.is_moderated = True
- self.object.save()
- else:
- unread_notif_subquery = Notification.objects.filter(
- user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
- )
- for user in User.objects.filter(
- ~Exists(unread_notif_subquery),
- groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
- ):
- Notification.objects.create(
- user=user,
- url=reverse("com:news_admin_list"),
- type="NEWS_MODERATION",
- )
- return super().form_valid(form)
-
-
-class NewsDeleteView(CanEditMixin, DeleteView):
+class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
model = News
pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:news_admin_list")
+ permission_required = "com.delete_news"
-class NewsModerateView(CanEditMixin, SingleObjectMixin):
+class NewsModerateView(PermissionRequiredMixin, DetailView):
model = News
pk_url_kwarg = "news_id"
+ permission_required = "com.moderate_news"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
@@ -359,17 +225,23 @@ class NewsModerateView(CanEditMixin, SingleObjectMixin):
return redirect("com:news_admin_list")
-class NewsAdminListView(CanEditMixin, ListView):
+class NewsAdminListView(PermissionRequiredMixin, ListView):
model = News
template_name = "com/news_admin_list.jinja"
- queryset = News.objects.all()
+ queryset = News.objects.select_related(
+ "club", "author", "moderator"
+ ).prefetch_related("dates")
+ permission_required = ["com.moderate_news", "com.delete_news"]
-class NewsListView(CanViewMixin, ListView):
+class NewsListView(ListView):
model = News
template_name = "com/news_list.jinja"
queryset = News.objects.filter(is_moderated=True)
+ def get_queryset(self):
+ return super().get_queryset().viewable_by(self.request.user)
+
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["NewsDate"] = NewsDate
@@ -390,6 +262,10 @@ class NewsDetailView(CanViewMixin, DetailView):
model = News
template_name = "com/news_detail.jinja"
pk_url_kwarg = "news_id"
+ queryset = News.objects.select_related("club", "author", "moderator")
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
# Weekmail
diff --git a/core/api.py b/core/api.py
index 1662cb84..e1b3bbbd 100644
--- a/core/api.py
+++ b/core/api.py
@@ -11,10 +11,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from club.models import Mailing
-from core.api_permissions import (
- CanAccessLookup,
- CanView,
-)
+from core.auth.api_permissions import CanAccessLookup, CanView
from core.models import Group, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
diff --git a/core/auth/__init__.py b/core/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/api_permissions.py b/core/auth/api_permissions.py
similarity index 99%
rename from core/api_permissions.py
rename to core/auth/api_permissions.py
index f4da67af..4d83143e 100644
--- a/core/api_permissions.py
+++ b/core/auth/api_permissions.py
@@ -3,7 +3,8 @@
Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`).
-Examples:
+Example:
+ ```python
# restrict all the routes of this controller
# to subscribed users
@api_controller("/foo", permissions=[IsSubscriber])
@@ -33,6 +34,7 @@ Examples:
]
def bar_delete(self, bar_id: int):
# ...
+ ```
"""
from typing import Any
diff --git a/core/auth_backends.py b/core/auth/backends.py
similarity index 100%
rename from core/auth_backends.py
rename to core/auth/backends.py
diff --git a/core/auth/mixins.py b/core/auth/mixins.py
new file mode 100644
index 00000000..974e9bd1
--- /dev/null
+++ b/core/auth/mixins.py
@@ -0,0 +1,287 @@
+#
+# Copyright 2016,2017
+# - Skia
+# - Sli
+#
+# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
+# http://ae.utbm.fr.
+#
+# This program is free software; you can redistribute it and/or modify it under
+# the terms of the GNU General Public License a published by the Free Software
+# Foundation; either version 3 of the License, or (at your option) any later
+# version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
+# Place - Suite 330, Boston, MA 02111-1307, USA.
+#
+#
+from __future__ import annotations
+
+import types
+import warnings
+from typing import TYPE_CHECKING, Any, LiteralString
+
+from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.views.generic.base import View
+
+if TYPE_CHECKING:
+ from django.db.models import Model
+
+ from core.models import User
+
+
+def can_edit_prop(obj: Any, user: User) -> bool:
+ """Can the user edit the properties of the object.
+
+ Args:
+ obj: Object to test for permission
+ user: core.models.User to test permissions against
+
+ Returns:
+ True if user is authorized to edit object properties else False
+
+ Example:
+ ```python
+ if not can_edit_prop(self.object ,request.user):
+ raise PermissionDenied
+ ```
+ """
+ return obj is None or user.is_owner(obj)
+
+
+def can_edit(obj: Any, user: User) -> bool:
+ """Can the user edit the object.
+
+ Args:
+ obj: Object to test for permission
+ user: core.models.User to test permissions against
+
+ Returns:
+ True if user is authorized to edit object else False
+
+ Example:
+ ```python
+ if not can_edit(self.object, request.user):
+ raise PermissionDenied
+ ```
+ """
+ if obj is None or user.can_edit(obj):
+ return True
+ return can_edit_prop(obj, user)
+
+
+def can_view(obj: Any, user: User) -> bool:
+ """Can the user see the object.
+
+ Args:
+ obj: Object to test for permission
+ user: core.models.User to test permissions against
+
+ Returns:
+ True if user is authorized to see object else False
+
+ Example:
+ ```python
+ if not can_view(self.object ,request.user):
+ raise PermissionDenied
+ ```
+ """
+ if obj is None or user.can_view(obj):
+ return True
+ return can_edit(obj, user)
+
+
+class GenericContentPermissionMixinBuilder(View):
+ """Used to build permission mixins.
+
+ This view protect any child view that would be showing an object that is restricted based
+ on two properties.
+
+ Attributes:
+ raised_error: permission to be raised
+ """
+
+ raised_error = PermissionDenied
+
+ @staticmethod
+ def permission_function(obj: Any, user: User) -> bool:
+ """Function to test permission with."""
+ return False
+
+ @classmethod
+ def get_permission_function(cls, obj, user):
+ return cls.permission_function(obj, user)
+
+ def dispatch(self, request, *arg, **kwargs):
+ if hasattr(self, "get_object") and callable(self.get_object):
+ self.object = self.get_object()
+ if not self.get_permission_function(self.object, request.user):
+ raise self.raised_error
+ return super().dispatch(request, *arg, **kwargs)
+
+ # If we get here, it's a ListView
+
+ queryset = self.get_queryset()
+ l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
+ if not l_id and queryset.count() != 0:
+ raise self.raised_error
+ self._get_queryset = self.get_queryset
+
+ def get_qs(self2):
+ return self2._get_queryset().filter(id__in=l_id)
+
+ self.get_queryset = types.MethodType(get_qs, self)
+ return super().dispatch(request, *arg, **kwargs)
+
+
+class CanCreateMixin(View):
+ """Protect any child view that would create an object.
+
+ Raises:
+ PermissionDenied:
+ If the user has not the necessary permission
+ to create the object of the view.
+ """
+
+ def __init_subclass__(cls, **kwargs):
+ warnings.warn(
+ f"{cls.__name__} is deprecated and should be replaced "
+ "by other permission verification mecanism.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init_subclass__(**kwargs)
+
+ def __init__(self, *args, **kwargs):
+ warnings.warn(
+ f"{self.__class__.__name__} is deprecated and should be replaced "
+ "by other permission verification mecanism.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(*args, **kwargs)
+
+ def dispatch(self, request, *arg, **kwargs):
+ res = super().dispatch(request, *arg, **kwargs)
+ if not request.user.is_authenticated:
+ raise PermissionDenied
+ return res
+
+ def form_valid(self, form):
+ obj = form.instance
+ if can_edit_prop(obj, self.request.user):
+ return super().form_valid(form)
+ raise PermissionDenied
+
+
+class CanEditPropMixin(GenericContentPermissionMixinBuilder):
+ """Ensure the user has owner permissions on the child view object.
+
+ In other word, you can make a view with this view as parent,
+ and it will be retricted to the users that are in the
+ object's owner_group or that pass the `obj.can_be_viewed_by` test.
+
+ Raises:
+ PermissionDenied: If the user cannot see the object
+ """
+
+ permission_function = can_edit_prop
+
+
+class CanEditMixin(GenericContentPermissionMixinBuilder):
+ """Ensure the user has permission to edit this view's object.
+
+ Raises:
+ PermissionDenied: if the user cannot edit this view's object.
+ """
+
+ permission_function = can_edit
+
+
+class CanViewMixin(GenericContentPermissionMixinBuilder):
+ """Ensure the user has permission to view this view's object.
+
+ Raises:
+ PermissionDenied: if the user cannot edit this view's object.
+ """
+
+ permission_function = can_view
+
+
+class FormerSubscriberMixin(AccessMixin):
+ """Check if the user was at least an old subscriber.
+
+ Raises:
+ PermissionDenied: if the user never subscribed.
+ """
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.was_subscribed:
+ raise PermissionDenied
+ return super().dispatch(request, *args, **kwargs)
+
+
+class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
+ """Require that the user has the required perm or is the object author.
+
+ This mixin can be used in combination with `DetailView`,
+ or another base class that implements the `get_object` method.
+
+ Example:
+ In the following code, a user will be able
+ to edit news if he has the `com.change_news` permission
+ or if he tries to edit his own news :
+
+ ```python
+ class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
+ model = News
+ author_field = "author"
+ permission_required = "com.change_news"
+ ```
+
+ This is more or less equivalent to :
+
+ ```python
+ class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
+ model = News
+
+ def dispatch(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ if not (
+ user.has_perm("com.change_news")
+ or self.object.author == request.user
+ ):
+ raise PermissionDenied
+ return super().dispatch(request, *args, **kwargs)
+ ```
+ """
+
+ author_field: LiteralString = "author"
+
+ def has_permission(self):
+ if not hasattr(self, "get_object"):
+ raise ImproperlyConfigured(
+ f"{self.__class__.__name__} is missing the "
+ "get_object attribute. "
+ f"Define {self.__class__.__name__}.get_object, "
+ "or inherit from a class that implement it (like DetailView)"
+ )
+ if super().has_permission():
+ return True
+ if self.request.user.is_anonymous:
+ return False
+ obj: Model = self.get_object()
+ if not self.author_field.endswith("_id"):
+ # getting the related model could trigger a db query
+ # so we will rather get the foreign value than
+ # the object itself.
+ self.author_field += "_id"
+ author_id = getattr(obj, self.author_field, None)
+ return author_id == self.request.user.id
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index e3d6d8e4..5e0f099d 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -460,6 +460,7 @@ Welcome to the wiki page!
limit_age=18,
)
cons = Product.objects.create(
+ id=settings.SITH_ECOCUP_CONS,
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
@@ -469,6 +470,7 @@ Welcome to the wiki page!
club=main_club,
)
dcons = Product.objects.create(
+ id=settings.SITH_ECOCUP_DECO,
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
@@ -676,7 +678,6 @@ Welcome to the wiki page!
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
- type="EVENT",
club=bar_club,
author=subscriber,
is_moderated=True,
@@ -696,7 +697,6 @@ Welcome to the wiki page!
"Viens donc t'enjailler avec les autres barmans aux "
"frais du BdF! \\o/"
),
- type="EVENT",
club=bar_club,
author=subscriber,
is_moderated=True,
@@ -713,7 +713,6 @@ Welcome to the wiki page!
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
- type="EVENT",
club=bar_club,
author=subscriber,
is_moderated=True,
@@ -730,7 +729,6 @@ Welcome to the wiki page!
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
- type="EVENT",
club=bar_club,
author=subscriber,
is_moderated=True,
@@ -749,7 +747,6 @@ Welcome to the wiki page!
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
- type="WEEKLY",
club=troll,
author=subscriber,
is_moderated=True,
@@ -897,6 +894,9 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers")
+ subscribers.permissions.add(
+ *list(perms.filter(codename__in=["add_news", "add_uvcommentreport"]))
+ )
old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add(
*list(
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index fbef3ec2..7acec959 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -5,6 +5,7 @@ from typing import Iterator
from dateutil.relativedelta import relativedelta
from django.conf import settings
+from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand
from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
@@ -38,26 +39,10 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
- users = [
- User(
- username=self.faker.user_name(),
- first_name=self.faker.first_name(),
- last_name=self.faker.last_name(),
- date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
- email=self.faker.email(),
- phone=self.faker.phone_number(),
- address=self.faker.address(),
- )
- for _ in range(600)
- ]
- # there may a duplicate or two
- # Not a problem, we will just have 599 users instead of 600
- User.objects.bulk_create(users, ignore_conflicts=True)
- users = list(User.objects.order_by("-id")[: len(users)])
-
+ users = self.create_users()
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
- self.create_subscriptions(users)
+ self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list(
@@ -102,11 +87,34 @@ class Command(BaseCommand):
self.stdout.write("Done")
+ def create_users(self) -> list[User]:
+ password = make_password("plop")
+ users = [
+ User(
+ username=self.faker.user_name(),
+ first_name=self.faker.first_name(),
+ last_name=self.faker.last_name(),
+ date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
+ email=self.faker.email(),
+ phone=self.faker.phone_number(),
+ address=self.faker.address(),
+ password=password,
+ )
+ for _ in range(600)
+ ]
+ # there may a duplicate or two
+ # Not a problem, we will just have 599 users instead of 600
+ users = User.objects.bulk_create(users, ignore_conflicts=True)
+ users = list(User.objects.order_by("-id")[: len(users)])
+ public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID)
+ public_group.users.add(*users)
+ return users
+
def create_subscriptions(self, users: list[User]):
- def prepare_subscription(user: User, start_date: date) -> Subscription:
+ def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
- sub = Subscription(member=user, payment_method=payment_method)
+ sub = Subscription(member=_user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration)
return sub
@@ -130,6 +138,10 @@ class Command(BaseCommand):
user, self.faker.past_date(sub.subscription_end)
)
subscriptions.append(sub)
+ old_subscriber_group = Group.objects.get(
+ pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
+ )
+ old_subscriber_group.users.add(*users)
Subscription.objects.bulk_create(subscriptions)
Customer.objects.bulk_create(customers, ignore_conflicts=True)
diff --git a/core/models.py b/core/models.py
index 278182ac..b1caa912 100644
--- a/core/models.py
+++ b/core/models.py
@@ -29,6 +29,7 @@ import os
import string
import unicodedata
from datetime import timedelta
+from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self
@@ -50,6 +51,7 @@ from django.utils.html import escape
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
+from PIL import Image
if TYPE_CHECKING:
from pydantic import NonNegativeInt
@@ -320,12 +322,16 @@ class User(AbstractUser):
return self.get_display_name()
def save(self, *args, **kwargs):
+ adding = self._state.adding
with transaction.atomic():
- if self.id:
+ if not adding:
old = User.objects.filter(id=self.id).first()
if old and old.username != self.username:
self._change_username(self.username)
super().save(*args, **kwargs)
+ if adding:
+ # All users are in the public group.
+ self.groups.add(settings.SITH_GROUP_PUBLIC_ID)
def get_absolute_url(self) -> str:
return reverse("core:user_profile", kwargs={"user_id": self.pk})
@@ -380,12 +386,8 @@ class User(AbstractUser):
raise ValueError("You must either provide the id or the name of the group")
if group is None:
return False
- if group.id == settings.SITH_GROUP_PUBLIC_ID:
- return True
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
- if group.id == settings.SITH_GROUP_OLD_SUBSCRIBERS_ID:
- return self.was_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return group in self.cached_groups
@@ -988,17 +990,11 @@ class SithFile(models.Model):
if self.is_folder:
if self.file:
try:
- import imghdr
-
- if imghdr.what(None, self.file.read()) not in [
- "gif",
- "png",
- "jpeg",
- ]:
- self.file.delete()
- self.file = None
- except: # noqa E722 I don't know the exception that can be raised
- self.file = None
+ Image.open(BytesIO(self.file.read()))
+ except Image.UnidentifiedImageError as e:
+ raise ValidationError(
+ _("This is not a valid folder thumbnail")
+ ) from e
self.mime_type = "inode/directory"
if self.is_file and (self.file is None or self.file == ""):
raise ValidationError(_("You must provide a file"))
diff --git a/core/static/bundled/core/read-more-index.ts b/core/static/bundled/core/read-more-index.ts
new file mode 100644
index 00000000..52e095a3
--- /dev/null
+++ b/core/static/bundled/core/read-more-index.ts
@@ -0,0 +1,73 @@
+import clip from "@arendjr/text-clipper";
+
+/*
+ This script adds a way to have a 'show more / show less' button
+ on some text content.
+
+ The usage is very simple, you just have to add the attribute `show-more`
+ with the desired max size to the element you want to add the button to.
+ This script does html matching and is able to properly cut rendered markdown.
+
+ Example usage:
+
+ My very long text will be cut by this script
+
+*/
+
+function showMore(element: HTMLElement) {
+ if (!element.hasAttribute("show-more")) {
+ return;
+ }
+
+ // Mark element as loaded so we can hide unloaded
+ // tags with css and avoid blinking text
+ element.setAttribute("show-more-loaded", "");
+
+ const fullContent = element.innerHTML;
+ const clippedContent = clip(
+ element.innerHTML,
+ Number.parseInt(element.getAttribute("show-more") as string),
+ {
+ html: true,
+ },
+ );
+
+ // If already at the desired size, we don't do anything
+ if (clippedContent === fullContent) {
+ return;
+ }
+
+ const actionLink = document.createElement("a");
+ actionLink.setAttribute("class", "show-more-link");
+
+ let opened = false;
+
+ const setText = () => {
+ if (opened) {
+ element.innerHTML = fullContent;
+ actionLink.innerText = gettext("Show less");
+ } else {
+ element.innerHTML = clippedContent;
+ actionLink.innerText = gettext("Show more");
+ }
+ element.appendChild(document.createElement("br"));
+ element.appendChild(actionLink);
+ };
+
+ const toggle = () => {
+ opened = !opened;
+ setText();
+ };
+
+ setText();
+ actionLink.addEventListener("click", (event) => {
+ event.preventDefault();
+ toggle();
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ for (const elem of document.querySelectorAll("[show-more]")) {
+ showMore(elem as HTMLElement);
+ }
+});
diff --git a/core/static/core/components/ajax-select.scss b/core/static/core/components/ajax-select.scss
index cb9d466c..59d146fc 100644
--- a/core/static/core/components/ajax-select.scss
+++ b/core/static/core/components/ajax-select.scss
@@ -1,11 +1,27 @@
+.ts-wrapper.multi .ts-control {
+ min-width: calc(100% - 0.2rem);
+}
+
/* This also requires ajax-select-index.css */
.ts-dropdown {
+ width: calc(100% - 0.2rem);
+ left: 0.1rem;
+ top: calc(100% - 0.2rem - var(--nf-input-border-bottom-width));
+ border: var(--nf-input-border-color) var(--nf-input-border-width) solid;
+ border-top: none;
+ border-bottom-width: var(--nf-input-border-bottom-width);
+
+ .option.active {
+ background-color: #e5eafa;
+ color: inherit;
+ }
.select-item {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
+ overflow: hidden;
img {
height: 40px;
@@ -16,19 +32,44 @@
}
}
-.ts-wrapper {
- margin: 5px;
+.ts-wrapper.single {
+ > .ts-control {
+ box-shadow: none;
+ max-width: 300px;
+ background-color: var(--nf-input-background-color);
+
+ &::after {
+ content: none;
+ }
+ }
+
+ > .ts-dropdown {
+ max-width: 300px;
+ }
}
-.ts-wrapper.single {
- width: 263px; // same length as regular text inputs
+.ts-wrapper input[type="text"] {
+ border: none;
+ border-radius: 0;
+}
+
+.ts-wrapper.multi, .ts-wrapper.single {
+ .ts-control:has(input:focus) {
+ outline: none;
+ border-color: var(--nf-input-focus-border-color);
+ box-shadow: none;
+ }
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
border-left: 1px solid #aaa;
}
-.ts-wrapper.multi .ts-control {
+.ts-wrapper.multi.has-items .ts-control {
+ padding: calc(var(--nf-input-size) * 0.65);
+ display: flex;
+ gap: calc(var(--nf-input-size) / 3);
+
[data-value],
[data-value].active {
background-image: none;
@@ -37,19 +78,17 @@
border: 1px solid #aaa;
border-radius: 4px;
display: inline-block;
- margin-left: 5px;
- margin-top: 5px;
- margin-bottom: 5px;
padding-right: 10px;
padding-left: 10px;
text-shadow: none;
box-shadow: none;
+
+ .remove {
+ vertical-align: baseline;
+ }
}
}
-.ts-dropdown {
- .option.active {
- background-color: #e5eafa;
- color: inherit;
- }
-}
\ No newline at end of file
+.ts-wrapper.focus .ts-control {
+ box-shadow: none;
+}
diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss
index 1d0fa1bc..9982e77f 100644
--- a/core/static/core/forms.scss
+++ b/core/static/core/forms.scss
@@ -48,7 +48,8 @@
input,
textarea[type="text"],
- [type="number"] {
+ [type="number"],
+ .ts-control {
border: none;
text-decoration: none;
background-color: $background-button-color;
@@ -69,7 +70,7 @@
font-family: sans-serif;
}
- select {
+ select, .ts-control {
border: none;
text-decoration: none;
font-size: 1.2em;
@@ -177,7 +178,7 @@ form {
}
// wrap texts
- label, legend, ul.errorlist>li, .helptext {
+ label, legend, ul.errorlist > li, .helptext {
text-wrap: wrap;
}
@@ -218,23 +219,25 @@ form {
}
}
- input[type="text"],
- input[type="email"],
- input[type="tel"],
- input[type="url"],
- input[type="password"],
- input[type="number"],
- input[type="date"],
- input[type="week"],
- input[type="time"],
- input[type="month"],
- input[type="search"],
- textarea,
- select {
- min-width: 300px;
+ :not(.ts-control) > {
+ input[type="text"],
+ input[type="email"],
+ input[type="tel"],
+ input[type="url"],
+ input[type="password"],
+ input[type="number"],
+ input[type="date"],
+ input[type="week"],
+ input[type="time"],
+ input[type="search"],
+ textarea,
+ input[type="month"],
+ select {
+ min-width: 300px;
- &.grow {
- width: 95%;
+ &.grow {
+ width: 95%;
+ }
}
}
@@ -253,7 +256,8 @@ form {
input[type="month"],
input[type="search"],
textarea,
- select {
+ select,
+ .ts-control {
background: var(--nf-input-background-color);
font-size: var(--nf-input-font-size);
border-color: var(--nf-input-border-color);
@@ -661,7 +665,9 @@ form {
}
&:checked {
- background: var(--nf-input-focus-border-color) none initial;
+ background: none;
+ background-position: 0 0;
+ background-color: var(--nf-input-focus-border-color);
&::after {
transform: translateY(-50%) translateX(
@@ -713,7 +719,11 @@ form {
// ---------------- SELECT
- select {
+ select,
+ .ts-wrapper.multi .ts-control,
+ .ts-wrapper.single .ts-control,
+ .ts-wrapper.single.input-active .ts-control {
+ background-color: var(--nf-input-background-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
background-repeat: no-repeat;
diff --git a/core/static/core/style.scss b/core/static/core/style.scss
index 913733d6..a89d33cf 100644
--- a/core/static/core/style.scss
+++ b/core/static/core/style.scss
@@ -131,6 +131,10 @@ body {
display: none !important;
}
+[show-more]:not([show-more-loaded]) {
+ display: none !important;
+}
+
/*--------------------------------HEADER-------------------------------*/
#popupheader {
@@ -432,8 +436,8 @@ body {
$row-gap: 0.5rem;
&.gap {
- column-gap: var($col-gap);
- row-gap: var($row-gap);
+ column-gap: $col-gap;
+ row-gap: $row-gap;
}
@for $i from 2 through 5 {
diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja
index 17b9befa..ef184fbf 100644
--- a/core/templates/core/base.jinja
+++ b/core/templates/core/base.jinja
@@ -125,15 +125,14 @@
navbar.style.setProperty("display", current === "none" ? "block" : "none");
}
- $(document).keydown(function (e) {
- if ($(e.target).is('input')) { return }
- if ($(e.target).is('textarea')) { return }
- if ($(e.target).is('select')) { return }
- if (e.keyCode === 83) {
- $("#search").focus();
- return false;
+ document.addEventListener("keydown", (e) => {
+ // Looking at the `s` key when not typing in a form
+ if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
+ return;
}
- });
+ document.getElementById("search").focus();
+ e.preventDefault(); // Don't type the character in the focused search input
+ })
{% endblock %}
| |