3 Commits

Author SHA1 Message Date
f477346f1e allow redis and postgres to run in docker compose 2025-01-11 01:12:17 +01:00
8cc23f01fd use .env for project configuration 2025-01-11 01:12:12 +01:00
d456a1d9d8 use .env for project configuration 2025-01-10 22:47:35 +01:00
153 changed files with 4250 additions and 6789 deletions

View File

@ -1,17 +1,83 @@
HTTPS=off HTTPS=off
SITH_DEBUG=true DEBUG=true
# This is not the real key used in prod # This is not the real key used in prod
SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2 SECRET_KEY=(4sjxvhz@m5$0a$j0_pqicnc$s!vbve)z+&++m%g%bjhlz4+g2
# comment the sqlite line and uncomment the postgres one to switch the dbms
DATABASE_URL=sqlite:///db.sqlite3 DATABASE_URL=sqlite:///db.sqlite3
# uncomment the next line if you want to use a postgres database
#DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith #DATABASE_URL=postgres://user:password@127.0.0.1:5432/sith
CACHE_URL=redis://127.0.0.1:6379/0
REDIS_PORT=7963 MEDIA_ROOT=data
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0 STATIC_ROOT=static
# Used to select which other services to run alongside DEFAULT_FROM_EMAIL=bibou@git.an
# manage.py, pytest and runserver SITH_COM_EMAIL=bibou_com@git.an
PROCFILE_STATIC=Procfile.static
PROCFILE_SERVICE=Procfile.service HONEYPOT_VALUE=content
HONEYPOT_FIELD_NAME=body2
HONEYPOT_FIELD_NAME_FORUM=message2
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
EMAIL_HOST=localhost
EMAIL_PORT=25
SITH_URL=127.0.0.1:8000
SITH_NAME="AE UTBM"
SITH_MAIN_CLUB_ID=1
SITH_GROUP_ROOT_ID=1
SITH_GROUP_PUBLIC_ID=2
SITH_GROUP_SUBSCRIBERS_ID=3
SITH_GROUP_OLD_SUBSCRIBERS_ID=4
SITH_GROUP_ACCOUNTING_ADMIN_ID=5
SITH_GROUP_COM_ADMIN_ID=6
SITH_GROUP_COUNTER_ADMIN_ID=7
SITH_GROUP_SAS_ADMIN_ID=8
SITH_GROUP_FORUM_ADMIN_ID=9
SITH_GROUP_PEDAGOGY_ADMIN_ID=10
SITH_GROUP_BANNED_ALCOHOL_ID=11
SITH_GROUP_BANNED_COUNTER_ID=12
SITH_GROUP_BANNED_SUBSCRIPTION_ID=13
SITH_CLUB_REFOUND_ID=89
SITH_COUNTER_REFOUND_ID=38
SITH_PRODUCT_REFOUND_ID=5
# Counter
SITH_COUNTER_ACCOUNT_DUMP_ID=39
# Defines which product type is the refilling type, and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING=3
SITH_ECOCUP_CONS=1152
SITH_ECOCUP_DECO=1151
# Defines which product is the one year subscription and which one is the six month subscription
SITH_PRODUCT_SUBSCRIPTION_ONE_SEMESTER=1
SITH_PRODUCT_SUBSCRIPTION_TWO_SEMESTERS=2
SITH_PRODUCTTYPE_SUBSCRIPTION=2
# Defines which clubs let its members the ability to see users subscription history
SITH_CAN_CREATE_SUBSCRIPTION_HISTORY=1
SITH_CAN_READ_SUBSCRIPTION_HISTORY=1
# SAS variables
SITH_SAS_ROOT_DIR_ID=4
# ET variables
SITH_EBOUTIC_CB_ENABLED=true
SITH_EBOUTIC_ET_URL="https://preprod-tpeweb.e-transactions.fr/cgi/MYchoix_pagepaiement.cgi"
SITH_EBOUTIC_PBX_SITE=1999888
SITH_EBOUTIC_PBX_RANG=32
SITH_EBOUTIC_PBX_IDENTIFIANT=2
SITH_EBOUTIC_HMAC_KEY=0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF
SITH_EBOUTIC_PUB_KEY_PATH=sith/et_keys/pubkey.pem
SITH_MAILING_FETCH_KEY=IloveMails
SENTRY_DSN=
SENTRY_ENV=production

View File

@ -4,16 +4,11 @@ runs:
using: composite using: composite
steps: steps:
- name: Install apt packages - name: Install apt packages
uses: awalsh128/cache-apt-pkgs-action@v1.4.3 uses: awalsh128/cache-apt-pkgs-action@latest
with: with:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
- name: Install Redis
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@v5
with: with:

View File

@ -10,7 +10,6 @@ on:
env: env:
SECRET_KEY: notTheRealOne SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3 DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
jobs: jobs:
pre-commit: pre-commit:
@ -46,7 +45,7 @@ jobs:
uv run coverage report uv run coverage report
uv run coverage html uv run coverage html
- name: Archive code coverage results - name: Archive code coverage results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: coverage-report-${{ matrix.pytest-mark }} name: coverage-report
path: coverage_report path: coverage_report

9
.gitignore vendored
View File

@ -18,14 +18,7 @@ sith/search_indexes/
.coverage .coverage
coverage_report/ coverage_report/
node_modules/ node_modules/
.env
*.pid
# compiled documentation # compiled documentation
site/ site/
.env
### Redis ###
# Ignore redis binary dump (dump.rdb) files
*.rdb

View File

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

View File

@ -1 +0,0 @@
redis: redis-server --port $REDIS_PORT

View File

@ -1 +0,0 @@
bundler: npm run serve

View File

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

View File

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

View File

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

View File

@ -7,17 +7,3 @@ class ClubSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club
fields = ["id", "name"] fields = ["id", "name"]
class ClubProfileSchema(ModelSchema):
"""The infos needed to display a simple club profile."""
class Meta:
model = Club
fields = ["id", "name", "logo"]
url: str
@staticmethod
def resolve_url(obj: Club) -> str:
return obj.get_absolute_url()

View File

@ -213,9 +213,9 @@ class TestMembershipQuerySet(TestClub):
memberships[1].club.members_group, memberships[1].club.members_group,
memberships[1].club.board_group, memberships[1].club.board_group,
} }
assert set(user.groups.all()).issuperset(club_groups) assert set(user.groups.all()) == club_groups
user.memberships.all().delete() user.memberships.all().delete()
assert set(user.groups.all()).isdisjoint(club_groups) assert user.groups.all().count() == 0
class TestClubModel(TestClub): class TestClubModel(TestClub):

View File

@ -25,7 +25,6 @@
import csv import csv
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import Sum from django.db.models import Sum
@ -50,15 +49,17 @@ from com.views import (
PosterEditBaseView, PosterEditBaseView,
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import ( from core.models import PageRev
from core.views import (
CanCreateMixin, CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
DetailFormView,
PageEditViewBase,
TabedViewMixin,
UserIsRootMixin,
) )
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase
from core.views.mixins import TabedViewMixin
from counter.models import Selling from counter.models import Selling
@ -256,7 +257,7 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["request_user"] = self.request.user kwargs["request_user"] = self.request.user
kwargs["club"] = self.object kwargs["club"] = self.get_object()
kwargs["club_members"] = self.members kwargs["club_members"] = self.members
return kwargs return kwargs
@ -273,9 +274,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
users = data.pop("users", []) users = data.pop("users", [])
users_old = data.pop("users_old", []) users_old = data.pop("users_old", [])
for user in users: for user in users:
Membership(club=self.object, user=user, **data).save() Membership(club=self.get_object(), user=user, **data).save()
for user in users_old: for user in users_old:
membership = self.object.get_membership_for(user) membership = self.get_object().get_membership_for(user)
membership.end_date = timezone.now() membership.end_date = timezone.now()
membership.save() membership.save()
return resp return resp
@ -285,7 +286,9 @@ class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:club_members", kwargs={"club_id": self.object.id}) return reverse_lazy(
"club:club_members", kwargs={"club_id": self.get_object().id}
)
class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView): class ClubOldMembersView(ClubTabsMixin, CanViewMixin, DetailView):
@ -471,14 +474,13 @@ class ClubEditPropView(ClubTabsMixin, CanEditPropMixin, UpdateView):
current_tab = "props" current_tab = "props"
class ClubCreateView(PermissionRequiredMixin, CreateView): class ClubCreateView(CanCreateMixin, CreateView):
"""Create a club (for the Sith admin).""" """Create a club (for the Sith admin)."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
fields = ["name", "unix_name", "parent"] fields = ["name", "unix_name", "parent"]
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, DetailView): class MembershipSetOldView(CanEditMixin, DetailView):
@ -510,13 +512,12 @@ class MembershipSetOldView(CanEditMixin, DetailView):
) )
class MembershipDeleteView(PermissionRequiredMixin, DeleteView): class MembershipDeleteView(UserIsRootMixin, DeleteView):
"""Delete a membership (for admins only).""" """Delete a membership (for admins only)."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "club.delete_membership"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})

View File

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

View File

@ -1,18 +1,10 @@
from pathlib import Path from pathlib import Path
from typing import Literal
from django.conf import settings from django.conf import settings
from django.http import Http404, HttpResponse from django.http import Http404
from ninja import Query from ninja_extra import ControllerBase, api_controller, route
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.models import News, NewsDate
from com.schemas import NewsDateFilterSchema, NewsDateSchema
from core.auth.api_permissions import HasPerm
from core.views.files import send_raw_file from core.views.files import send_raw_file
@ -25,7 +17,7 @@ class CalendarController(ControllerBase):
"""Return the ICS file of the AE Google Calendar """Return the ICS file of the AE Google Calendar
Because of Google's cors rules, we can't just do a request to google ics Because of Google's cors rules, we can't just do a request to google ics
from the frontend. Google is blocking CORS request in its responses headers. from the frontend. Google is blocking CORS request in it's responses headers.
The only way to do it from the frontend is to use Google Calendar API with an API key The only way to do it from the frontend is to use Google Calendar API with an API key
This is not especially desirable as your API key is going to be provided to the frontend. This is not especially desirable as your API key is going to be provided to the frontend.
@ -38,67 +30,3 @@ class CalendarController(ControllerBase):
@route.get("/internal.ics", url_name="calendar_internal") @route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self): def calendar_internal(self):
return send_raw_file(IcsCalendar.get_internal()) return send_raw_file(IcsCalendar.get_internal())
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
return HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
content_type="text/calendar",
)
@api_controller("/news")
class NewsController(ControllerBase):
@route.patch(
"/{int:news_id}/publish",
permissions=[HasPerm("com.moderate_news")],
url_name="moderate_news",
)
def publish_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
if not news.is_published:
news.is_published = True
news.moderator = self.context.request.user
news.save()
@route.patch(
"/{int:news_id}/unpublish",
permissions=[HasPerm("com.moderate_news")],
url_name="unpublish_news",
)
def unpublish_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
if news.is_published:
news.is_published = False
news.moderator = self.context.request.user
news.save()
@route.delete(
"/{int:news_id}",
permissions=[HasPerm("com.delete_news")],
url_name="delete_news",
)
def delete_news(self, news_id: int):
news = self.get_object_or_exception(News, id=news_id)
news.delete()
@route.get(
"/date",
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def fetch_news_dates(
self,
filters: Query[NewsDateFilterSchema],
text_format: Literal["md", "html"] = "md",
):
return filters.filter(
NewsDate.objects.viewable_by(self.context.request.user)
.order_by("start_date")
.select_related("news", "news__club")
)

View File

@ -2,10 +2,9 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import final from typing import final
import requests import urllib3
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.db.models import F, QuerySet
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ical.calendar import Calendar from ical.calendar import Calendar
@ -13,7 +12,6 @@ from ical.calendar_stream import IcsCalendarStream
from ical.event import Event from ical.event import Event
from com.models import NewsDate from com.models import NewsDate
from core.models import User
@final @final
@ -37,15 +35,16 @@ class IcsCalendar:
@classmethod @classmethod
def make_external(cls) -> Path | None: def make_external(cls) -> Path | None:
calendar = requests.get( calendar = urllib3.request(
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics" "GET",
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
) )
if not calendar.ok: if calendar.status != 200:
return None return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f: with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.content) _ = f.write(calendar.data)
return cls._EXTERNAL_CALENDAR return cls._EXTERNAL_CALENDAR
@classmethod @classmethod
@ -57,38 +56,21 @@ class IcsCalendar:
@classmethod @classmethod
def make_internal(cls) -> Path: def make_internal(cls) -> Path:
# Updated through a post_save signal on News in com.signals # Updated through a post_save signal on News in com.signals
# Create a file so we can offload the download to the reverse proxy if available
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(
cls.ics_from_queryset(
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
)
)
)
return cls._INTERNAL_CALENDAR
@classmethod
def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_published=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
calendar = Calendar() calendar = Calendar()
for news_date in queryset.annotate(news_title=F("news__title")): for news_date in NewsDate.objects.filter(
news__is_moderated=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
).prefetch_related("news"):
event = Event( event = Event(
summary=news_date.news_title, summary=news_date.news.title,
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_date, end=news_date.end_date,
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}), url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
) )
calendar.events.append(event) calendar.events.append(event)
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8") # Create a file so we can offload the download to the reverse proxy if available
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
return cls._INTERNAL_CALENDAR

View File

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

View File

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

View File

@ -1,16 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("com", "0008_alter_news_options_alter_newsdate_options_and_more")]
operations = [
migrations.RenameField(
model_name="news", old_name="is_moderated", new_name="is_published"
),
migrations.AlterField(
model_name="news",
name="is_published",
field=models.BooleanField(default=False, verbose_name="is published"),
),
]

View File

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

View File

@ -1,58 +0,0 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.controllers import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
class NewsSchema(ModelSchema):
class Meta:
model = News
fields = ["id", "title", "summary", "is_published"]
club: ClubProfileSchema
url: str
@staticmethod
def resolve_summary(obj: News) -> str:
# if this is returned from a route that allows the
# user to choose the text format (md or html)
# and the user chose "html", convert the markdown to html
context: RouteContext = service_resolver(RouteContext)
if context.kwargs.get("text_format", "") == "html":
return markdown(obj.summary)
return obj.summary
@staticmethod
def resolve_url(obj: News) -> str:
return obj.get_absolute_url()
class NewsDateSchema(ModelSchema):
"""Basic infos about an event occurrence.
Warning:
This uses [NewsSchema][], which itself
uses [ClubProfileSchema][club.schemas.ClubProfileSchema].
Don't forget the appropriated `select_related`.
"""
class Meta:
model = NewsDate
fields = ["id", "start_date", "end_date"]
news: NewsSchema

View File

@ -7,33 +7,20 @@ import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
calendarCalendarExternal,
calendarCalendarInternal,
calendarCalendarUnpublished,
newsDeleteNews,
newsPublishNews,
newsUnpublishNews,
} from "#openapi";
@registerComponent("ics-calendar") @registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") { export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete"]; static observedAttributes = ["locale"];
private calendar: Calendar; private calendar: Calendar;
private locale = "en"; private locale = "en";
private canModerate = false;
private canDelete = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) { attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") { if (name !== "locale") {
this.locale = newValue; return;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
} }
this.locale = newValue;
} }
isMobile() { isMobile() {
@ -67,104 +54,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}).format(date); }).format(date);
} }
getNewsId(event: EventImpl) {
return Number.parseInt(
event.url
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
);
}
async refreshEvents() {
this.click(); // Remove focus from popup
// We can't just refresh events because some ics files are in
// local browser cache (especially internal.ics)
// To invalidate the cache, we need to remove the source and add it again
this.calendar.removeAllEventSources();
for (const source of await this.getEventSources()) {
this.calendar.addEventSource(source);
}
this.calendar.refetchEvents();
}
async publishNews(id: number) {
await newsPublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-publish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async unpublishNews(id: number) {
await newsUnpublishNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-unpublish", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async deleteNews(id: number) {
await newsDeleteNews({
path: {
// biome-ignore lint/style/useNamingConvention: python API
news_id: id,
},
});
this.dispatchEvent(
new CustomEvent("calendar-delete", {
bubbles: true,
detail: {
id: id,
},
}),
);
await this.refreshEvents();
}
async getEventSources() {
const cacheInvalidate = `?invalidate=${Date.now()}`;
return [
{
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
className: "internal",
},
{
url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`,
format: "ics",
className: "external",
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
format: "ics",
color: "red",
className: "unpublished",
},
];
}
createEventDetailPopup(event: EventClickArg) { createEventDetailPopup(event: EventClickArg) {
// Delete previous popup // Delete previous popup
const oldPopup = document.getElementById("event-details"); const oldPopup = document.getElementById("event-details");
@ -223,47 +112,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
return makePopupInfo(url, "fa-solid fa-link"); return makePopupInfo(url, "fa-solid fa-link");
}; };
const makePopupTools = (event: EventImpl) => {
if (event.source.internalEventSource.ui.classNames.includes("external")) {
return null;
}
if (!(this.canDelete || this.canModerate)) {
return null;
}
const newsId = this.getNewsId(event);
const div = document.createElement("div");
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
button.setAttribute("class", "btn btn-green");
button.onclick = () => {
this.publishNews(newsId);
};
div.appendChild(button);
} else {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
button.setAttribute("class", "btn btn-orange");
button.onclick = () => {
this.unpublishNews(newsId);
};
div.appendChild(button);
}
}
if (this.canDelete) {
const button = document.createElement("button");
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
button.setAttribute("class", "btn btn-red");
button.onclick = () => {
this.deleteNews(newsId);
};
div.appendChild(button);
}
return makePopupInfo(div, "fa-solid fa-toolbox");
};
// Create new popup // Create new popup
const popup = document.createElement("div"); const popup = document.createElement("div");
const popupContainer = document.createElement("div"); const popupContainer = document.createElement("div");
@ -283,11 +131,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
popupContainer.appendChild(url); popupContainer.appendChild(url);
} }
const tools = makePopupTools(event.event);
if (tools !== null) {
popupContainer.appendChild(tools);
}
popup.appendChild(popupContainer); popup.appendChild(popupContainer);
// We can't just add the element relative to the one we want to appear under // We can't just add the element relative to the one we want to appear under
@ -316,7 +159,16 @@ export class IcsCalendar extends inheritHtmlElement("div") {
locale: this.locale, locale: this.locale,
initialView: this.currentView(), initialView: this.currentView(),
headerToolbar: this.currentToolbar(), headerToolbar: this.currentToolbar(),
eventSources: await this.getEventSources(), eventSources: [
{
url: await makeUrl(calendarCalendarInternal),
format: "ics",
},
{
url: await makeUrl(calendarCalendarExternal),
format: "ics",
},
],
windowResize: () => { windowResize: () => {
this.calendar.changeView(this.currentView()); this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar()); this.calendar.setOption("headerToolbar", this.currentToolbar());

View File

@ -1,81 +0,0 @@
import { exportToHtml } from "#core:utils/globals";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
PENDING: 1,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
PUBLISHED: 2,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DELETED: 3,
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
exportToHtml("AlertState", AlertState);
document.addEventListener("alpine:init", () => {
Alpine.data("moderationAlert", (newsId: number) => ({
state: AlertState.PENDING,
newsId: newsId as number,
loading: false,
async publishNews() {
this.loading = true;
// biome-ignore lint/style/useNamingConvention: api is snake case
await newsPublishNews({ path: { news_id: this.newsId } });
this.state = AlertState.PUBLISHED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false;
},
async deleteNews() {
this.loading = true;
// biome-ignore lint/style/useNamingConvention: api is snake case
await newsDeleteNews({ path: { news_id: this.newsId } });
this.state = AlertState.DELETED;
this.$dispatch("news-moderated", { newsId: this.newsId, state: this.state });
this.loading = false;
},
/**
* Event receiver for when news dates are moderated.
*
* If the moderated date is linked to the same news
* as the one this moderation alert is attached to,
* then set the alert state to the same as the moderated one.
*/
dispatchModeration(event: CustomEvent) {
if (event.detail.newsId === this.newsId) {
this.state = event.detail.state;
}
},
/**
* Query the server to know the number of news dates that would be moderated
* if this one is moderated.
*/
async nbToPublish(): Promise<number> {
// What we want here is the count attribute of the response.
// We don't care about the actual results,
// so we ask for the minimum page size possible.
const response = await newsFetchNewsDates({
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
},
weeklyEventWarningMessage(nbEvents: number): string {
return interpolate(
gettext(
"This event will take place every week for %s weeks. " +
"If you publish or delete this event, " +
"it will also be published (or deleted) for the following weeks.",
),
[nbEvents],
);
},
}));
});

View File

@ -1,67 +0,0 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
startDate: startDate,
currentPage: 1,
pageSize: 6,
hasNext: true,
loading: false,
newsDates: [] as NewsDateSchema[],
async loadMore() {
this.loading = true;
const response = await newsFetchNewsDates({
query: {
after: this.startDate.toISOString(),
// biome-ignore lint/style/useNamingConvention: api is snake_case
text_format: "html",
page: this.currentPage,
// biome-ignore lint/style/useNamingConvention: api is snake_case
page_size: this.pageSize,
},
});
if (response.response.status === 404) {
this.hasNext = false;
} else if (response.data.next === null) {
this.newsDates.push(...response.data.results);
this.hasNext = false;
} else {
this.newsDates.push(...response.data.results);
this.currentPage += 1;
}
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
...date,
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: new Date(date.start_date),
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: new Date(date.end_date),
}),
)
.reduce(
(acc: Record<string, ParsedNewsDateSchema[]>, date: ParsedNewsDateSchema) => {
const key = date.start_date.toDateString();
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(date);
return acc;
},
{},
);
},
}));
});

View File

@ -75,10 +75,10 @@ ics-calendar {
} }
td { td {
overflow: visible; // Show events on multiple days overflow-x: visible; // Show events on multiple days
} }
//Reset from style.scss //Reset from style.scss
table { table {
box-shadow: none; box-shadow: none;
border-radius: 0px; border-radius: 0px;
@ -86,13 +86,13 @@ ics-calendar {
margin: 0px; margin: 0px;
} }
// Reset from style.scss // Reset from style.scss
thead { thead {
background-color: white; background-color: white;
color: black; color: black;
} }
// Reset from style.scss // Reset from style.scss
tbody>tr { tbody>tr {
&:nth-child(even):not(.highlight) { &:nth-child(even):not(.highlight) {
background: white; background: white;

View File

@ -36,11 +36,6 @@
&:not(:first-of-type) { &:not(:first-of-type) {
margin: 2em 0 1em 0; margin: 2em 0 1em 0;
} }
.feed {
float: right;
color: #f26522;
}
} }
@media screen and (max-width: $small-devices) { @media screen and (max-width: $small-devices) {
@ -51,20 +46,6 @@
} }
} }
/* UPCOMING EVENTS */
#upcoming-events {
max-height: 600px;
overflow-y: scroll;
#load-more-news-button {
text-align: center;
button {
width: 150px;
}
}
}
/* LINKS/BIRTHDAYS */ /* LINKS/BIRTHDAYS */
#links, #links,
#birthdays { #birthdays {
@ -185,24 +166,54 @@
} }
.news_event { .news_event {
display: flex; display: block;
flex-direction: column; padding: 0.4em;
gap: .5em;
padding: 1em; &:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
}
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
max-width: 9em;
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
header {
img { img {
height: 75px; max-height: 6em;
max-width: 8em;
display: block;
margin: 0 auto;
} }
.header_content { }
display: flex;
flex-direction: column;
justify-content: center;
gap: .2rem;
h4 { .news_date {
margin-top: 0; font-size: 100%;
text-transform: uppercase; }
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
} }
} }
} }
@ -212,6 +223,70 @@
/* END EVENTS TODAY AND NEXT FEW DAYS */ /* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
.news_coming_soon {
display: list-item;
list-style-type: square;
list-style-position: inside;
margin-left: 1em;
padding-left: 0;
a {
font-weight: bold;
text-transform: uppercase;
}
.news_date {
font-size: 0.9em;
}
}
/* END COMING SOON */
/* NOTICES */
.news_notice {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px;
h4 {
margin: 0;
}
.news_content {
margin-left: 1em;
}
}
/* END NOTICES */
/* CALLS */
.news_call {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px;
h4 {
margin: 0;
}
.news_date {
font-size: 0.9em;
}
.news_content {
margin-left: 1em;
}
}
/* END CALLS */
.news_empty { .news_empty {
margin-left: 1em; margin-left: 1em;
} }

View File

@ -1,127 +0,0 @@
{% macro news_moderation_alert(news, user, alpineState = None) %}
{# An alert to display on top of unpublished news,
with actions to either publish or delete them.
The current state of the alert is accessible through
the given `alpineState` variable.
This state is a `AlertState`, as defined in `moderation-alert-index.ts`
This comes in three flavours :
- You can pass the `News` object itself to the macro.
In this case, if `request.user` can publish news,
it will perform an additional db query to know if it is a recurring event.
- You can also give only the news id.
In this case, a server request will be issued to know
if it is a recurring event.
- Finally, you can pass the name of an alpine variable, which value is the id.
In this case, a server request will be issued to know
if it is a recurring event.
Example with full `News` object :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news, user, "state") }}
</div>
```
With an id :
```jinja
<div x-data="{state: AlertState.PENDING}">
{{ news_moderation_alert(news.id, user, "state") }}
</div>
```
An with an alpine variable
```jinja
<div x-data="{state: AlertState.PENDING, newsId: {{ news.id }}">
{{ news_moderation_alert("newsId", user, "state") }}
</div>
```
Args:
news: (News | int | string)
Either the `News` object to which this alert is related,
or its id, or the name of an Alpine which value is its id
user: The request.user
alpineState: An alpine variable name
Warning:
If you use this macro, you must also include `moderation-alert-index.ts`
in your template.
#}
<div
{% if news is integer or news is string %}
x-data="moderationAlert({{ news }})"
{% else %}
x-data="moderationAlert({{ news.id }})"
{% endif %}
{# the news-moderated is received when a moderation alert is deleted or moderated #}
@news-moderated.window="dispatchModeration($event)"
{% if alpineState %}
x-model="{{ alpineState }}"
x-modelable="state"
{% endif %}
>
<template x-if="state === AlertState.PENDING">
<div class="alert alert-yellow">
<div class="alert-main">
<strong>{% trans %}Waiting publication{% endtrans %}</strong>
<p>
{% trans trimmed %}
This news isn't published and is visible
only by its author and the communication admins.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
It will stay hidden for other users until it has been published.
{% endtrans %}
</p>
{% if user.has_perm("com.moderate_news") %}
{# This is an additional query for each non-moderated news,
but it will be executed only for admin users, and only one time
(if they do their job and moderated news as soon as they see them),
so it's still reasonable #}
<div
{% if news is integer or news is string %}
x-data="{ nbEvents: 0 }"
x-init="nbEvents = await nbToPublish()"
{% else %}
x-data="{ nbEvents: {{ news.dates.count() }} }"
{% endif %}
>
<template x-if="nbEvents > 1">
<div>
<br>
<strong>{% trans %}Weekly event{% endtrans %}</strong>
<p x-text="weeklyEventWarningMessage(nbEvents)"></p>
</div>
</template>
</div>
{% endif %}
</div>
{% if user.has_perm("com.moderate_news") %}
<span class="alert-aside" :aria-busy="loading">
<button class="btn btn-green" @click="publishNews()" :disabled="loading">
<i class="fa fa-check"></i> {% trans %}Publish{% endtrans %}
</button>
{% endif %}
{% if user.has_perm("com.delete_news") %}
<button class="btn btn-red" @click="deleteNews()" :disabled="loading">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</span>
{% endif %}
</div>
</template>
<template x-if="state === AlertState.PUBLISHED">
<div class="alert alert-green">
{% trans %}News published{% endtrans %}
</div>
</template>
<template x-if="state === AlertState.DELETED">
<div class="alert alert-red">
{% trans %}News deleted{% endtrans %}
</div>
</template>
</div>
{% endmacro %}

View File

@ -10,13 +10,78 @@
<p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p> <p><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></p>
<hr />
<h4>{% trans %}Notices{% endtrans %}</h4>
{% set notices = object_list.filter(type="NOTICE").distinct().order_by('id') %}
<h5>{% trans %}Displayed notices{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Notices to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in notices.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr /> <hr />
<h4>{% trans %}Weeklies{% endtrans %}</h4> <h4>{% trans %}Weeklies{% endtrans %}</h4>
{% set weeklies = object_list.filter(dates__end_date__gte=timezone.now()).distinct().order_by('id') %} {% set weeklies = object_list.filter(type="WEEKLY", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed weeklies{% endtrans %}</h5> <h5>{% trans %}Displayed weeklies{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -27,8 +92,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in weeklies.filter(is_published=True) %} {% for news in weeklies.filter(is_moderated=True) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -47,7 +113,7 @@
</td> </td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
@ -58,6 +124,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -67,8 +134,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in weeklies.filter(is_published=False) %} {% for news in weeklies.filter(is_moderated=False) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
@ -86,20 +154,22 @@
</td> </td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<hr /> <hr />
<h4>{% trans %}Events{% endtrans %}</h4> <h4>{% trans %}Calls{% endtrans %}</h4>
{% set events = object_list.filter(dates__end_date__gte=timezone.now()).order_by('id') %} {% set calls = object_list.filter(type="CALL", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed events{% endtrans %}</h5> <h5>{% trans %}Displayed calls{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -111,30 +181,32 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in events.filter(is_published=True) %} {% for news in calls.filter(is_moderated=True) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td> <td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td> <td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Unpublish{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h5>{% trans %}Events to moderate{% endtrans %}</h5> <h5>{% trans %}Calls to moderate{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td> <td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td> <td>{% trans %}Club{% endtrans %}</td>
@ -145,19 +217,96 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for news in events.filter(is_published=False) %} {% for news in calls.filter(is_moderated=False) %}
<tr> <tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td> <td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td> <td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td> <td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td> <td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.all()[0].start_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].start_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.all()[0].end_date|localtime|date(DATETIME_FORMAT) }} <td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.all()[0].end_date|localtime|time(DATETIME_FORMAT) }}</td> {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a> <td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr />
<h4>{% trans %}Events{% endtrans %}</h4>
{% set events = object_list.filter(type="EVENT", dates__end_date__gte=timezone.now()).distinct().order_by('id') %}
<h5>{% trans %}Displayed events{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Moderator{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=True) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ user_profile_link(news.moderator) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}?remove">{% trans %}Remove{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h5>{% trans %}Events to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
<td>{% trans %}Type{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Summary{% endtrans %}</td>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Author{% endtrans %}</td>
<td>{% trans %}Start{% endtrans %}</td>
<td>{% trans %}End{% endtrans %}</td>
<td>{% trans %}Actions{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for news in events.filter(is_moderated=False) %}
<tr>
<td>{{ news.get_type_display() }}</td>
<td>{{ news.title }}</td>
<td>{{ news.summary|markdown }}</td>
<td><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></td>
<td>{{ user_profile_link(news.author) }}</td>
<td>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</td>
<td>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</td>
<td><a href="{{ url('com:news_detail', news_id=news.id) }}">{% trans %}View{% endtrans %}</a>
<a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit{% endtrans %}</a>
<a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a>
<a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a> <a href="{{ url('com:news_delete', news_id=news.id) }}">{% trans %}Delete{% endtrans %}</a>
</td> </td>
</tr> </tr>

View File

@ -1,6 +1,5 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %} {% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
{% from "com/macros.jinja" import news_moderation_alert %}
{% block title %} {% block title %}
{% trans %}News{% endtrans %} - {% trans %}News{% endtrans %} -
@ -17,49 +16,39 @@
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}"> <link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %} {% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
{% endblock %}
{% block content %} {% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p> <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<div x-data="{newsState: AlertState.PENDING}"> <section id="news_details">
<div class="club_logo">
{% if not news.is_published %} <img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" />
{{ news_moderation_alert(news, user, "newsState") }} <a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
{% endif %} </div>
<article id="news_details" x-show="newsState !== AlertState.DELETED"> <h4>{{ news.title }}</h4>
<div class="club_logo"> <p class="date">
<img src="{{ link_news_logo(news)}}" alt="{{ news.club }}" /> <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a> {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
</div> </div>
<h4>{{ news.title }}</h4> </div>
<p class="date"> </section>
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}
<p>{% trans %}Moderator: {% endtrans %}{{ user_profile_link(news.moderator) }}</p>
{% elif user.is_com_admin %}
<p> <a href="{{ url('com:news_moderate', news_id=news.id) }}">{% trans %}Publish{% endtrans %}</a></p>
{% endif %}
{% if user.can_edit(news) %}
<p> <a href="{{ url('com:news_edit', news_id=news.id) }}">{% trans %}Edit (will be moderated again){% endtrans %}</a></p>
{% endif %}
</div>
</div>
</article>
</div>
{% endblock %} {% endblock %}

View File

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

View File

@ -1,5 +1,5 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from 'core/macros.jinja' import tweet_quick, fb_quick %}
{% block title %} {% block title %}
{% trans %}News{% endtrans %} {% trans %}News{% endtrans %}
@ -8,271 +8,162 @@
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}"> <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}"> <link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
{# Atom feed discovery, not really css but also goes there #}
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
{% endblock %} {% endblock %}
{% block additional_js %} {% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script> <script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if user.is_com_admin %}
<div id="news_admin">
<a class="button" href="{{ url('com:news_admin_list') }}">{% trans %}Administrate news{% endtrans %}</a>
</div>
<br>
{% endif %}
<div id="news"> <div id="news">
<div id="left_column" class="news_column"> <div id="left_column" class="news_column">
<h3> {% for news in object_list.filter(type="NOTICE") %}
{% trans %}Events today and the next few days{% endtrans %} <section class="news_notice">
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a> <h4><a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
</h3> <div class="news_content">{{ news.summary|markdown }}</div>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %} </section>
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}"> {% endfor %}
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %} {% for news in object_list.filter(dates__start_date__lte=timezone.now(), dates__end_date__gte=timezone.now(), type="CALL") %}
</a> <section class="news_call">
{% endif %} <h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
{% if user.is_com_admin %} <div class="news_date">
<a class="btn btn-blue" href="{{ url('com:news_admin_list') }}"> <span>{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{% trans %}Administrate news{% endtrans %} {{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
</a> <span>{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
<br> {{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
{% endif %}
<section id="upcoming-events">
{% if not news_dates %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div> </div>
{% else %} <div class="news_content">{{ news.summary|markdown }}</div>
{% for day, dates_group in news_dates.items() %} </section>
<div class="news_events_group"> {% endfor %}
<div class="news_events_group_date">
<div> {% 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') %}
<div>{{ day|date('D') }}</div> <h3>{% trans %}Events today and the next few days{% endtrans %}</h3>
<div class="day">{{ day|date('d') }}</div> {% if events_dates %}
<div>{{ day|date('b') }}</div> {% for d in events_dates %}
</div> <div class="news_events_group">
</div> <div class="news_events_group_date">
<div class="news_events_group_items"> <div>
{% for date in dates_group %} <div>{{ d|localtime|date('D') }}</div>
<article <div class="day">{{ d|localtime|date('d') }}</div>
class="news_event" <div>{{ d|localtime|date('b') }}</div>
{%- if not date.news.is_published -%}
x-data="{newsState: AlertState.PENDING}"
{% else %}
x-data="{newsState: AlertState.DISPLAYED}"
{%- endif -%}
>
{# if a non published news is in the object list,
the logged user is either an admin or the news author #}
{{ news_moderation_alert(date.news, user, "newsState") }}
<div
x-show="newsState !== AlertState.DELETED"
>
<header class="row gap">
{% if date.news.club.logo %}
<img src="{{ date.news.club.logo.url }}" alt="{{ date.news.club }}"/>
{% else %}
<img src="{{ static("com/img/news.png") }}" alt="{{ date.news.club }}"/>
{% endif %}
<div class="header_content">
<h4>
<a href="{{ url('com:news_detail', news_id=date.news_id) }}">
{{ date.news.title }}
</a>
</h4>
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
<div class="news_date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
</div>
</header>
<div class="news_content markdown">
{{ date.news.summary|markdown }}
</div>
</div>
</article>
{% endfor %}
</div> </div>
</div> </div>
{% endfor %} <div class="news_events_group_items">
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))"> {% for news in object_list.filter(dates__start_date__gte=d,
<template x-for="newsList in Object.values(groupedDates())"> dates__start_date__lte=d+timedelta(days=1),
<div class="news_events_group"> type="EVENT").exclude(dates__end_date__lt=timezone.now())
<div class="news_events_group_date"> .order_by('dates__start_date') %}
<div x-data="{day: newsList[0].start_date}"> <section class="news_event">
<div x-text="day.toLocaleString('{{ get_language() }}', { weekday: 'short' }).substring(0, 3)"></div> <div class="club_logo">
<div {% if news.club.logo %}
class="day" <img src="{{ news.club.logo.url }}" alt="{{ news.club }}" />
x-text="day.toLocaleString('{{ get_language() }}', { day: 'numeric' })" {% else %}
></div> <img src="{{ static("com/img/news.png") }}" alt="{{ news.club }}" />
<div x-text="day.toLocaleString('{{ get_language() }}', { month: 'short' }).substring(0, 3)"></div> {% endif %}
</div>
<h4> <a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a></h4>
<div><a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a></div>
<div class="news_date">
<span>{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }}</span> -
<span>{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</div>
<div class="news_content">{{ news.summary|markdown }}
<div class="button_bar">
{{ fb_quick(news) }}
{{ tweet_quick(news) }}
</div> </div>
</div> </div>
<div class="news_events_group_items"> </section>
<template x-for="newsDate in newsList" :key="newsDate.id"> {% endfor %}
<article
class="news_event"
x-data="{ newsState: newsDate.news.is_published ? AlertState.PUBLISHED : AlertState.PENDING }"
>
<template x-if="!newsDate.news.is_published">
{{ news_moderation_alert("newsDate.news.id", user, "newsState") }}
</template>
<div x-show="newsState !== AlertState.DELETED">
<header class="row gap">
<img
:src="newsDate.news.club.logo || '{{ static("com/img/news.png") }}'"
:alt="newsDate.news.club.name"
/>
<div class="header_content">
<h4>
<a :href="newsDate.news.url" x-text="newsDate.news.title"></a>
</h4>
<a :href="newsDate.news.club.url" x-text="newsDate.news.club.name"></a>
<div class="news_date">
<time
:datetime="newsDate.start_date.toISOString()"
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
></time> -
<time
:datetime="newsDate.end_date.toISOString()"
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
></time>
</div>
</div>
</header>
{# The API returns a summary in html.
It's generated from our markdown subset, so it should be safe #}
<div class="news_content markdown" x-html="newsDate.news.summary"></div>
</div>
</article>
</template>
</div>
</div>
</template>
<div id="load-more-news-button" :aria-busy="loading">
<button class="btn btn-grey" x-show="!loading && hasNext" @click="loadMore()">
{% trans %}See more{% endtrans %} &nbsp;<i class="fa fa-arrow-down"></i>
</button>
<p x-show="!loading && !hasNext">
<em>
{% trans trimmed %}
It was too short.
You already reached the end of the upcoming events list.
{% endtrans %}
</em>
</p>
</div>
</div>
{% endif %}
</section>
<h3>
{% trans %}All coming events{% endtrans %}
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
<ics-calendar
x-data
x-ref="calendar"
@news-moderated.window="
if ($event.target !== $refs.calendar){
// Avoid triggering a refresh with a dispatch
// from the calendar itself
$refs.calendar.refreshEvents($event);
}
"
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
locale="{{ get_language() }}"
can_moderate="{{ user.has_perm("com.moderate_news") }}"
can_delete="{{ user.has_perm("com.delete_news") }}"
></ics-calendar>
</div>
<div id="right_column">
<div id="links">
<h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content">
<h4>{% trans %}Our services{% endtrans %}</h4>
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">
{% trans %}Discord AE{% endtrans %}
</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">
{% trans %}Dev Team{% endtrans %}
</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">
Facebook
</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">
Instagram
</a>
</li>
</ul>
</div> </div>
</div>
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.has_perm("core.view_user") -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
<ul>
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{%- endfor -%}
</ul>
</li>
{%- endfor -%}
</ul>
{%- elif not user.was_subscribed -%}
{# The user cannot view birthdays, because he never subscribed #}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- else -%}
{# There is another reason why user cannot view birthdays (maybe he is banned)
but we cannot know exactly what is this reason #}
<p>{% trans %}You cannot access this content{% endtrans %}</p>
{%- endif -%}
</div> </div>
</div> {% endfor %}
{% else %}
<div class="news_empty">
<em>{% trans %}Nothing to come...{% endtrans %}</em>
</div>
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3>
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
</div>
<div id="right_column">
<div id="links">
<h3>{% trans %}Links{% endtrans %}</h3>
<div id="links_content">
<h4>{% trans %}Our services{% endtrans %}</h4>
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
</li>
</ul>
<br>
<h4>{% trans %}Social media{% endtrans %}</h4>
<ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/u6EuMfyGaJ">{% trans %}Dev Team{% endtrans %}</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
</li>
</ul>
</div> </div>
</div> </div>
<div id="birthdays">
<h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content">
{%- if user.was_subscribed -%}
<ul class="birthdays_year">
{%- for year, users in birthdays -%}
<li>
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
<ul>
{%- for u in users -%}
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
{%- endfor -%}
</ul>
</li>
{%- endfor -%}
</ul>
{%- else -%}
<p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- endif -%}
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -3,32 +3,24 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from urllib.parse import quote
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.http import HttpResponse from django.http import HttpResponse
from django.test import Client, TestCase from django.test.client import Client
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertNumQueries
from com.calendar import IcsCalendar from com.calendar import IcsCalendar
from com.models import News, NewsDate
from core.markdown import markdown
from core.models import User
@dataclass @dataclass
class MockResponse: class MockResponse:
ok: bool status: int
value: str value: str
@property @property
def content(self): def data(self):
return self.value.encode("utf8") return self.value.encode("utf8")
@ -46,7 +38,7 @@ class TestExternalCalendar:
@pytest.fixture @pytest.fixture
def mock_request(self): def mock_request(self):
mock = MagicMock() mock = MagicMock()
with patch("requests.get", mock): with patch("urllib3.request", mock):
yield mock yield mock
@pytest.fixture @pytest.fixture
@ -60,12 +52,15 @@ class TestExternalCalendar:
def clear_cache(self): def clear_cache(self):
IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True) IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
def test_fetch_error(self, client: Client, mock_request: MagicMock): @pytest.mark.parametrize("error_code", [403, 404, 500])
mock_request.return_value = MockResponse(ok=False, value="not allowed") def test_fetch_error(
self, client: Client, mock_request: MagicMock, error_code: int
):
mock_request.return_value = MockResponse(error_code, "not allowed")
assert client.get(reverse("api:calendar_external")).status_code == 404 assert client.get(reverse("api:calendar_external")).status_code == 404
def test_fetch_success(self, client: Client, mock_request: MagicMock): def test_fetch_success(self, client: Client, mock_request: MagicMock):
external_response = MockResponse(ok=True, value="Definitely an ICS") external_response = MockResponse(200, "Definitely an ICS")
mock_request.return_value = external_response mock_request.return_value = external_response
response = client.get(reverse("api:calendar_external")) response = client.get(reverse("api:calendar_external"))
assert response.status_code == 200 assert response.status_code == 200
@ -125,126 +120,3 @@ class TestInternalCalendar:
out_file = accel_redirect_to_file(response) out_file = accel_redirect_to_file(response)
assert out_file is not None assert out_file is not None
assert out_file.exists() assert out_file.exists()
@pytest.mark.django_db
class TestModerateNews:
@pytest.mark.parametrize("news_is_published", [True, False])
def test_moderation_ok(self, client: Client, news_is_published: bool): # noqa FBT
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="moderate_news")]
)
# The API call should work even if the news is initially moderated.
# In the latter case, the result should be a noop, rather than an error.
news = baker.make(News, is_published=news_is_published)
initial_moderator = news.moderator
client.force_login(user)
response = client.patch(
reverse("api:moderate_news", kwargs={"news_id": news.id})
)
# if it wasn't moderated, it should now be moderated and the moderator should
# be the user that made the request.
# If it was already moderated, it should be a no-op, but not an error
assert response.status_code == 200
news.refresh_from_db()
assert news.is_published
if not news_is_published:
assert news.moderator == user
else:
assert news.moderator == initial_moderator
def test_moderation_forbidden(self, client: Client):
user = baker.make(User)
news = baker.make(News, is_published=False)
client.force_login(user)
response = client.patch(
reverse("api:moderate_news", kwargs={"news_id": news.id})
)
assert response.status_code == 403
news.refresh_from_db()
assert not news.is_published
@pytest.mark.django_db
class TestDeleteNews:
def test_delete_news_ok(self, client: Client):
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="delete_news")]
)
news = baker.make(News)
client.force_login(user)
response = client.delete(
reverse("api:delete_news", kwargs={"news_id": news.id})
)
assert response.status_code == 200
assert not News.objects.filter(id=news.id).exists()
def test_delete_news_forbidden(self, client: Client):
user = baker.make(User)
news = baker.make(News)
client.force_login(user)
response = client.delete(
reverse("api:delete_news", kwargs={"news_id": news.id})
)
assert response.status_code == 403
assert News.objects.filter(id=news.id).exists()
class TestFetchNewsDates(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
cls.dates = baker.make(
NewsDate,
_quantity=5,
_bulk_create=True,
start_date=seq(value=now(), increment_by=timedelta(days=1)),
end_date=seq(
value=now() + timedelta(hours=2), increment_by=timedelta(days=1)
),
news=iter(
baker.make(News, is_published=True, _quantity=5, _bulk_create=True)
),
)
cls.dates.append(
baker.make(
NewsDate,
start_date=now() + timedelta(days=2, hours=1),
end_date=now() + timedelta(days=2, hours=5),
news=baker.make(News, is_published=True),
)
)
cls.dates.sort(key=lambda d: d.start_date)
def test_num_queries(self):
with assertNumQueries(2):
self.client.get(reverse("api:fetch_news_dates"))
def test_html_format(self):
"""Test that when the summary is asked in html, the summary is in html."""
summary_1 = "# First event\nThere is something happening.\n"
self.dates[0].news.summary = summary_1
self.dates[0].news.save()
summary_2 = (
"# Second event\n"
"There is something happening **for real**.\n"
"Everything is [here](https://youtu.be/dQw4w9WgXcQ)\n"
)
self.dates[1].news.summary = summary_2
self.dates[1].news.save()
response = self.client.get(
reverse("api:fetch_news_dates") + "?page_size=2&text_format=html"
)
assert response.status_code == 200
dates = response.json()["results"]
assert dates[0]["news"]["summary"] == markdown(summary_1)
assert dates[1]["news"]["summary"] == markdown(summary_2)
def test_fetch(self):
after = quote((now() + timedelta(days=1)).isoformat())
response = self.client.get(
reverse("api:fetch_news_dates") + f"?page_size=3&after={after}"
)
assert response.status_code == 200
dates = response.json()["results"]
assert [d["id"] for d in dates] == [d.id for d in self.dates[1:4]]

View File

@ -1,42 +0,0 @@
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_published=iter([True, True, True, False, False, False]),
_quantity=6,
_bulk_create=True,
)
def test_admin_can_view_everything(self):
"""Test with a user that can view non moderated news."""
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_unmoderated_news")],
)
assert set(News.objects.viewable_by(user)) == set(self.news)
def test_normal_user_can_view_moderated_and_self_news(self):
"""Test that basic users can view moderated news and news they authored."""
user = self.news[0].author
assert set(News.objects.viewable_by(user)) == {
self.news[0],
self.news[1],
self.news[2],
self.news[3],
}

View File

@ -12,24 +12,17 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from datetime import timedelta
from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import html from django.utils import html
from django.utils.timezone import localtime, now from django.utils.timezone import localtime, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@ -144,8 +137,15 @@ class TestNews(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.com_admin = User.objects.get(username="comunity") cls.com_admin = User.objects.get(username="comunity")
cls.new = baker.make(News) new = News.objects.create(
cls.author = cls.new.author 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.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.anonymous = AnonymousUser() cls.anonymous = AnonymousUser()
@ -160,15 +160,15 @@ class TestNews(TestCase):
def test_news_viewer(self): def test_news_viewer(self):
"""Test that moderated news can be viewed by anyone """Test that moderated news can be viewed by anyone
and not moderated news only by com admins and by their author. and not moderated news only by com admins.
""" """
# by default news aren't moderated # by default a news isn't moderated
assert self.new.can_be_viewed_by(self.com_admin) assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.author)
assert not self.new.can_be_viewed_by(self.sli) assert not self.new.can_be_viewed_by(self.sli)
assert not self.new.can_be_viewed_by(self.anonymous) assert not self.new.can_be_viewed_by(self.anonymous)
assert not self.new.can_be_viewed_by(self.author)
self.new.is_published = True self.new.is_moderated = True
self.new.save() self.new.save()
assert self.new.can_be_viewed_by(self.com_admin) assert self.new.can_be_viewed_by(self.com_admin)
assert self.new.can_be_viewed_by(self.sli) assert self.new.can_be_viewed_by(self.sli)
@ -176,11 +176,11 @@ class TestNews(TestCase):
assert self.new.can_be_viewed_by(self.author) assert self.new.can_be_viewed_by(self.author)
def test_news_editor(self): def test_news_editor(self):
"""Test that only com admins and the original author can edit news.""" """Test that only com admins can edit news."""
assert self.new.can_be_edited_by(self.com_admin) assert self.new.can_be_edited_by(self.com_admin)
assert self.new.can_be_edited_by(self.author)
assert not self.new.can_be_edited_by(self.sli) assert not self.new.can_be_edited_by(self.sli)
assert not self.new.can_be_edited_by(self.anonymous) assert not self.new.can_be_edited_by(self.anonymous)
assert not self.new.can_be_edited_by(self.author)
class TestWeekmailArticle(TestCase): class TestWeekmailArticle(TestCase):
@ -230,105 +230,3 @@ class TestPoster(TestCase):
assert not self.poster.is_owned_by(self.susbcriber) assert not self.poster.is_owned_by(self.susbcriber)
assert self.poster.is_owned_by(self.sli) assert self.poster.is_owned_by(self.sli)
class TestNewsCreation(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
def setUp(self):
self.client.force_login(self.user)
self.start = now() + timedelta(days=1)
self.end = self.start + timedelta(hours=5)
self.valid_payload = {
"title": "Test news",
"summary": "This is a test news",
"content": "This is a test news",
"club": self.club.pk,
"is_weekly": False,
"start_date": self.start,
"end_date": self.end,
}
def test_create_news(self):
response = self.client.post(reverse("com:news_new"), self.valid_payload)
created = News.objects.order_by("id").last()
assertRedirects(response, created.get_absolute_url())
assert created.title == "Test news"
assert not created.is_published
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_published=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_published
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()
@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"

View File

@ -25,10 +25,9 @@ from com.views import (
NewsCreateView, NewsCreateView,
NewsDeleteView, NewsDeleteView,
NewsDetailView, NewsDetailView,
NewsFeed, NewsEditView,
NewsListView, NewsListView,
NewsModerateView, NewsModerateView,
NewsUpdateView,
PosterCreateView, PosterCreateView,
PosterDeleteView, PosterDeleteView,
PosterEditView, PosterEditView,
@ -74,14 +73,13 @@ urlpatterns = [
name="weekmail_article_edit", name="weekmail_article_edit",
), ),
path("news/", NewsListView.as_view(), name="news_list"), 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/admin/", NewsAdminListView.as_view(), name="news_admin_list"),
path("news/create/", NewsCreateView.as_view(), name="news_new"), path("news/create/", NewsCreateView.as_view(), name="news_new"),
path("news/<int:news_id>/edit/", NewsUpdateView.as_view(), name="news_edit"),
path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"), path("news/<int:news_id>/delete/", NewsDeleteView.as_view(), name="news_delete"),
path( path(
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate" "news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
), ),
path("news/<int:news_id>/edit/", NewsEditView.as_view(), name="news_edit"),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"), path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"), path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path( path(

View File

@ -22,37 +22,36 @@
# #
# #
import itertools import itertools
from datetime import date, timedelta from datetime import timedelta
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from typing import Any
from dateutil.relativedelta import relativedelta from django import forms
from django.conf import settings 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.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Exists, Max, OuterRef
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate, now from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing from club.models import Club, Mailing
from com.calendar import IcsCalendar
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
from core.auth.mixins import ( from core.models import Notification, User
from core.views import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
PermissionOrAuthorRequiredMixin, QuickNotifMixin,
TabedViewMixin,
) )
from core.models import User from core.views.forms import SelectDateTime
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from core.views.widgets.markdown import MarkdownInput from core.views.widgets.markdown import MarkdownInput
# Sith object # Sith object
@ -60,47 +59,92 @@ from core.views.widgets.markdown import MarkdownInput
sith = Sith.objects.first sith = Sith.objects.first
class PosterForm(forms.ModelForm):
class Meta:
model = Poster
fields = [
"name",
"file",
"club",
"screens",
"date_begin",
"date_end",
"display_time",
]
widgets = {"screens": forms.CheckboxSelectMultiple}
help_texts = {"file": _("Format: 16:9 | Resolution: 1920x1080")}
date_begin = forms.DateTimeField(
label=_("Start date"),
widget=SelectDateTime,
required=True,
initial=timezone.now().strftime("%Y-%m-%d %H:%M:%S"),
)
date_end = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=False
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
if self.user and not self.user.is_com_admin:
self.fields["club"].queryset = Club.objects.filter(
id__in=self.user.clubs_with_rights
)
self.fields.pop("display_time")
class ComTabsMixin(TabedViewMixin): class ComTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
return _("Communication administration") return _("Communication administration")
def get_list_of_tabs(self): def get_list_of_tabs(self):
return [ tab_list = []
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}, tab_list.append(
{"url": reverse("com:weekmail"), "slug": "weekmail", "name": _("Weekmail")}
)
tab_list.append(
{ {
"url": reverse("com:weekmail_destinations"), "url": reverse("com:weekmail_destinations"),
"slug": "weekmail_destinations", "slug": "weekmail_destinations",
"name": _("Weekmail destinations"), "name": _("Weekmail destinations"),
}, }
{ )
"url": reverse("com:info_edit"), tab_list.append(
"slug": "info", {"url": reverse("com:info_edit"), "slug": "info", "name": _("Info message")}
"name": _("Info message"), )
}, tab_list.append(
{ {
"url": reverse("com:alert_edit"), "url": reverse("com:alert_edit"),
"slug": "alert", "slug": "alert",
"name": _("Alert message"), "name": _("Alert message"),
}, }
)
tab_list.append(
{ {
"url": reverse("com:mailing_admin"), "url": reverse("com:mailing_admin"),
"slug": "mailings", "slug": "mailings",
"name": _("Mailing lists administration"), "name": _("Mailing lists administration"),
}, }
)
tab_list.append(
{ {
"url": reverse("com:poster_list"), "url": reverse("com:poster_list"),
"slug": "posters", "slug": "posters",
"name": _("Posters list"), "name": _("Posters list"),
}, }
)
tab_list.append(
{ {
"url": reverse("com:screen_list"), "url": reverse("com:screen_list"),
"slug": "screens", "slug": "screens",
"name": _("Screens list"), "name": _("Screens list"),
}, }
] )
return tab_list
class IsComAdminMixin(AccessMixin): class IsComAdminMixin(View):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not request.user.is_com_admin: if not request.user.is_com_admin:
raise PermissionDenied raise PermissionDenied
@ -140,86 +184,174 @@ class WeekmailDestinationEditView(ComEditView):
# News # News
class NewsCreateView(PermissionRequiredMixin, CreateView): class NewsForm(forms.ModelForm):
"""View to either create or update News.""" class Meta:
model = News
model = News fields = ["type", "title", "club", "summary", "content", "author"]
form_class = NewsForm widgets = {
template_name = "com/news_edit.jinja" "author": forms.HiddenInput,
permission_required = "com.add_news" "type": forms.RadioSelect,
"summary": MarkdownInput,
def get_date_form_kwargs(self) -> dict[str, Any]: "content": MarkdownInput,
"""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()),
} }
def get_initial(self): start_date = forms.DateTimeField(
init = super().get_initial() label=_("Start date"), widget=SelectDateTime, required=False
# if the id of a club is provided, select it by default )
if club_id := self.request.GET.get("club"): end_date = forms.DateTimeField(
init["club"] = Club.objects.filter(id=club_id).first() label=_("End date"), widget=SelectDateTime, required=False
return init )
until = forms.DateTimeField(label=_("Until"), widget=SelectDateTime, required=False)
automoderation = forms.BooleanField(label=_("Automoderation"), required=False)
def clean(self):
self.cleaned_data = super().clean()
if self.cleaned_data["type"] != "NOTICE":
if not self.cleaned_data["start_date"]:
self.add_error(
"start_date", ValidationError(_("This field is required."))
)
if not self.cleaned_data["end_date"]:
self.add_error(
"end_date", ValidationError(_("This field is required."))
)
if (
not self.has_error("start_date")
and not self.has_error("end_date")
and self.cleaned_data["start_date"] > self.cleaned_data["end_date"]
):
self.add_error(
"end_date",
ValidationError(_("An event cannot end before its beginning.")),
)
if self.cleaned_data["type"] == "WEEKLY" and not self.cleaned_data["until"]:
self.add_error("until", ValidationError(_("This field is required.")))
return self.cleaned_data
def save(self, *args, **kwargs):
ret = super().save()
self.instance.dates.all().delete()
if self.instance.type == "EVENT" or self.instance.type == "CALL":
NewsDate(
start_date=self.cleaned_data["start_date"],
end_date=self.cleaned_data["end_date"],
news=self.instance,
).save()
elif self.instance.type == "WEEKLY":
start_date = self.cleaned_data["start_date"]
end_date = self.cleaned_data["end_date"]
while start_date <= self.cleaned_data["until"]:
NewsDate(
start_date=start_date, end_date=end_date, news=self.instance
).save()
start_date += timedelta(days=7)
end_date += timedelta(days=7)
return ret
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): class NewsEditView(CanEditMixin, UpdateView):
model = News model = News
form_class = NewsForm form_class = NewsForm
template_name = "com/news_edit.jinja" template_name = "com/news_edit.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.edit_news"
def get_initial(self):
news_date: NewsDate = self.object.dates.order_by("id").first()
if news_date is None:
return {"start_date": None, "end_date": None}
return {"start_date": news_date.start_date, "end_date": news_date.end_date}
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and "preview" not in request.POST:
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(form) # Does the saving part self.object = form.save()
IcsCalendar.make_internal() if form.cleaned_data["automoderation"] and self.request.user.is_com_admin:
return response self.object.moderator = self.request.user
self.object.is_moderated = True
def get_date_form_kwargs(self) -> dict[str, Any]: self.object.save()
"""Get initial data for NewsDateForm""" else:
response = {} self.object.is_moderated = False
if self.request.method == "POST": self.object.save()
response["data"] = self.request.POST unread_notif_subquery = Notification.objects.filter(
dates = list(self.object.dates.order_by("id")) user=OuterRef("pk"), type="NEWS_MODERATION", viewed=False
if len(dates) == 0: )
return {} for user in User.objects.filter(
response["instance"] = dates[0] ~Exists(unread_notif_subquery),
occurrences = NewsDateForm.get_occurrences(len(dates)) groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
if occurrences is not None: ):
response["initial"] = {"is_weekly": True, "occurrences": occurrences} Notification.objects.create(
return response user=user,
url=self.object.get_absolute_url(),
def get_form_kwargs(self): type="NEWS_MODERATION",
return super().get_form_kwargs() | { )
"author": self.request.user, return super().form_valid(form)
"date_form": NewsDateForm(**self.get_date_form_kwargs()),
}
class NewsDeleteView(PermissionOrAuthorRequiredMixin, DeleteView): 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):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("com:news_admin_list") success_url = reverse_lazy("com:news_admin_list")
permission_required = "com.delete_news"
class NewsModerateView(PermissionRequiredMixin, DetailView): class NewsModerateView(CanEditMixin, SingleObjectMixin):
model = News model = News
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
permission_required = "com.moderate_news"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if "remove" in request.GET: if "remove" in request.GET:
self.object.is_published = False self.object.is_moderated = False
else: else:
self.object.is_published = True self.object.is_moderated = True
self.object.moderator = request.user self.object.moderator = request.user
self.object.save() self.object.save()
if "next" in self.request.GET: if "next" in self.request.GET:
@ -227,112 +359,37 @@ class NewsModerateView(PermissionRequiredMixin, DetailView):
return redirect("com:news_admin_list") return redirect("com:news_admin_list")
class NewsAdminListView(PermissionRequiredMixin, ListView): class NewsAdminListView(CanEditMixin, ListView):
model = News model = News
template_name = "com/news_admin_list.jinja" template_name = "com/news_admin_list.jinja"
queryset = News.objects.select_related( queryset = News.objects.all()
"club", "author", "moderator"
).prefetch_related("dates")
permission_required = ["com.moderate_news", "com.delete_news"]
class NewsListView(TemplateView): class NewsListView(CanViewMixin, ListView):
model = News
template_name = "com/news_list.jinja" template_name = "com/news_list.jinja"
queryset = News.objects.filter(is_moderated=True)
def get_birthdays(self): def get_context_data(self, **kwargs):
if not self.request.user.has_perm("core.view_user"): kwargs = super().get_context_data(**kwargs)
return [] kwargs["NewsDate"] = NewsDate
return itertools.groupby( kwargs["timedelta"] = timedelta
kwargs["birthdays"] = itertools.groupby(
User.objects.filter( User.objects.filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_subscriber_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"]) .filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year, key=lambda u: u.date_of_birth.year,
) )
return kwargs
def get_last_day(self) -> date | None:
"""Get the last day when news will be displayed
The returned day is the third one where something happen.
For example, if there are 6 events : A on 15/03, B and C on 17/03,
D on 20/03, E on 21/03 and F on 22/03 ;
then the result is 20/03.
"""
dates = list(
NewsDate.objects.filter(end_date__gt=now())
.order_by("start_date")
.values_list("start_date__date", flat=True)
.distinct()[:4]
)
return dates[-1] if len(dates) > 0 else None
def get_news_dates(self, until: date) -> dict[date, list[date]]:
"""Return the event dates to display.
The selected events are the ones that happens between
right now and the given day (included).
"""
return {
date: list(dates)
for date, dates in itertools.groupby(
NewsDate.objects.viewable_by(self.request.user)
.filter(end_date__gt=now(), start_date__date__lte=until)
.order_by("start_date")
.select_related("news", "news__club"),
key=lambda d: d.start_date.date(),
)
}
def get_context_data(self, **kwargs):
last_day = self.get_last_day()
return super().get_context_data(**kwargs) | {
"news_dates": self.get_news_dates(until=last_day)
if last_day is not None
else {},
"birthdays": self.get_birthdays(),
"last_day": last_day,
}
class NewsDetailView(CanViewMixin, DetailView): class NewsDetailView(CanViewMixin, DetailView):
model = News model = News
template_name = "com/news_detail.jinja" template_name = "com/news_detail.jinja"
pk_url_kwarg = "news_id" pk_url_kwarg = "news_id"
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
class NewsFeed(Feed):
title = _("News")
link = reverse_lazy("com:news_list")
description = _("All incoming events")
def items(self):
return (
NewsDate.objects.filter(
news__is_published=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 # Weekmail

View File

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

View File

@ -3,8 +3,7 @@
Some permissions are global (like `IsInGroup` or `IsRoot`), Some permissions are global (like `IsInGroup` or `IsRoot`),
and some others are per-object (like `CanView` or `CanEdit`). and some others are per-object (like `CanView` or `CanEdit`).
Example: Examples:
```python
# restrict all the routes of this controller # restrict all the routes of this controller
# to subscribed users # to subscribed users
@api_controller("/foo", permissions=[IsSubscriber]) @api_controller("/foo", permissions=[IsSubscriber])
@ -34,14 +33,10 @@ Example:
] ]
def bar_delete(self, bar_id: int): def bar_delete(self, bar_id: int):
# ... # ...
```
""" """
import operator
from functools import reduce
from typing import Any from typing import Any
from django.contrib.auth.models import Permission
from django.http import HttpRequest from django.http import HttpRequest
from ninja_extra import ControllerBase from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission from ninja_extra.permissions import BasePermission
@ -59,46 +54,6 @@ class IsInGroup(BasePermission):
return request.user.is_in_group(pk=self._group_pk) 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): class IsRoot(BasePermission):
"""Check that the user is root.""" """Check that the user is root."""

View File

View File

@ -1,287 +0,0 @@
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# - Sli <antoine@bartuccio.fr>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
from django.views.generic.base import View
if TYPE_CHECKING:
from django.db.models import Model
from core.models import User
def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Example:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Example:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Example:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
"""Require that the user has the required perm or is the object author.
This mixin can be used in combination with `DetailView`,
or another base class that implements the `get_object` method.
Example:
In the following code, a user will be able
to edit news if he has the `com.change_news` permission
or if he tries to edit his own news :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
author_field = "author"
permission_required = "com.change_news"
```
This is more or less equivalent to :
```python
class NewsEditView(PermissionOrAuthorRequiredMixin, DetailView):
model = News
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if not (
user.has_perm("com.change_news")
or self.object.author == request.user
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
```
"""
author_field: LiteralString = "author"
def has_permission(self):
if not hasattr(self, "get_object"):
raise ImproperlyConfigured(
f"{self.__class__.__name__} is missing the "
"get_object attribute. "
f"Define {self.__class__.__name__}.get_object, "
"or inherit from a class that implement it (like DetailView)"
)
if super().has_permission():
return True
if self.request.user.is_anonymous:
return False
obj: Model = self.get_object()
if not self.author_field.endswith("_id"):
# getting the related model could trigger a db query
# so we will rather get the foreign value than
# the object itself.
self.author_field += "_id"
author_id = getattr(obj, self.author_field, None)
return author_id == self.request.user.id

View File

@ -92,12 +92,7 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.") raise Exception("Never call this command in prod. Never.")
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an") 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() groups = self._create_groups()
self._create_ban_groups() self._create_ban_groups()
@ -125,11 +120,6 @@ class Command(BaseCommand):
unix_name=settings.SITH_MAIN_CLUB["unix_name"], unix_name=settings.SITH_MAIN_CLUB["unix_name"],
address=settings.SITH_MAIN_CLUB["address"], 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( bar_club = Club.objects.create(
id=2, id=2,
name=settings.SITH_BAR_MANAGER["name"], name=settings.SITH_BAR_MANAGER["name"],
@ -169,7 +159,7 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0) self.now = timezone.now().replace(hour=12)
skia = User.objects.create_user( skia = User.objects.create_user(
username="skia", username="skia",
@ -681,16 +671,17 @@ Welcome to the wiki page!
friday = self.now friday = self.now
while friday.weekday() != 4: while friday.weekday() != 4:
friday += timedelta(hours=6) friday += timedelta(hours=6)
friday.replace(hour=20, minute=0) friday.replace(hour=20, minute=0, second=0)
# Event # Event
news_dates = [] news_dates = []
n = News.objects.create( n = News.objects.create(
title="Apero barman", title="Apero barman",
summary="Viens boire un coup avec les barmans", summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_moderated=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -704,11 +695,13 @@ Welcome to the wiki page!
title="Repas barman", title="Repas barman",
summary="Enjoy la fin du semestre!", summary="Enjoy la fin du semestre!",
content=( content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/" "Viens donc t'enjailler avec les autres barmans aux "
"frais du BdF! \\o/"
), ),
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_moderated=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -722,9 +715,10 @@ Welcome to the wiki page!
title="Repas fromager", title="Repas fromager",
summary="Wien manger du l'bon fromeug'", summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_moderated=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -738,9 +732,10 @@ Welcome to the wiki page!
title="SdF", title="SdF",
summary="Enjoy la fin des finaux!", summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
type="EVENT",
club=bar_club, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_moderated=True,
moderator=skia, moderator=skia,
) )
news_dates.append( news_dates.append(
@ -756,9 +751,10 @@ Welcome to the wiki page!
summary="Viens jouer!", summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens " content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
type="WEEKLY",
club=troll, club=troll,
author=subscriber, author=subscriber,
is_published=True, is_moderated=True,
moderator=skia, moderator=skia,
) )
news_dates.extend( news_dates.extend(
@ -903,17 +899,11 @@ Welcome to the wiki page!
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Subscribers")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
)
old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(
perms.filter( perms.filter(
codename__in=[ codename__in=[
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"view_user", "view_user",
"view_picture", "view_picture",
"view_album", "view_album",
@ -985,9 +975,9 @@ Welcome to the wiki page!
) )
pedagogy_admin.permissions.add( pedagogy_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="pedagogy") perms.filter(content_type__app_label="pedagogy").values_list(
.exclude(codename__in=["change_uvcomment"]) "pk", flat=True
.values_list("pk", flat=True) )
) )
) )
self.reset_index("core", "auth") self.reset_index("core", "auth")

View File

@ -5,7 +5,6 @@ from typing import Iterator
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Count, Exists, Min, OuterRef, Subquery from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now from django.utils.timezone import localdate, make_aware, now
@ -39,10 +38,26 @@ class Command(BaseCommand):
raise Exception("Never call this command in prod. Never.") raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = self.create_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)])
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers) self.create_subscriptions(users)
self.stdout.write("Creating club memberships...") self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers]) users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
subscribers_now = list( subscribers_now = list(
@ -87,34 +102,11 @@ class Command(BaseCommand):
self.stdout.write("Done") self.stdout.write("Done")
def create_users(self) -> list[User]:
password = make_password("plop")
users = [
User(
username=self.faker.user_name(),
first_name=self.faker.first_name(),
last_name=self.faker.last_name(),
date_of_birth=self.faker.date_of_birth(minimum_age=15, maximum_age=25),
email=self.faker.email(),
phone=self.faker.phone_number(),
address=self.faker.address(),
password=password,
)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
users = User.objects.bulk_create(users, ignore_conflicts=True)
users = list(User.objects.order_by("-id")[: len(users)])
public_group = Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID)
public_group.users.add(*users)
return users
def create_subscriptions(self, users: list[User]): def create_subscriptions(self, users: list[User]):
def prepare_subscription(_user: User, start_date: date) -> Subscription: def prepare_subscription(user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0] payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4) duration = random.randint(1, 4)
sub = Subscription(member=_user, payment_method=payment_method) sub = Subscription(member=user, payment_method=payment_method)
sub.subscription_start = sub.compute_start(d=start_date, duration=duration) sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
sub.subscription_end = sub.compute_end(duration) sub.subscription_end = sub.compute_end(duration)
return sub return sub
@ -138,10 +130,6 @@ class Command(BaseCommand):
user, self.faker.past_date(sub.subscription_end) user, self.faker.past_date(sub.subscription_end)
) )
subscriptions.append(sub) subscriptions.append(sub)
old_subscriber_group = Group.objects.get(
pk=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
)
old_subscriber_group.users.add(*users)
Subscription.objects.bulk_create(subscriptions) Subscription.objects.bulk_create(subscriptions)
Customer.objects.bulk_create(customers, ignore_conflicts=True) Customer.objects.bulk_create(customers, ignore_conflicts=True)

View File

@ -16,6 +16,7 @@
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand): class Command(BaseCommand):
@ -29,7 +30,7 @@ class Command(BaseCommand):
if not data_dir.is_dir(): if not data_dir.is_dir():
data_dir.mkdir() data_dir.mkdir()
db_path = settings.BASE_DIR / "db.sqlite3" db_path = settings.BASE_DIR / "db.sqlite3"
if db_path.exists(): if db_path.exists() or connection.vendor != "sqlite":
call_command("flush", "--noinput") call_command("flush", "--noinput")
self.stdout.write("Existing database reset") self.stdout.write("Existing database reset")
call_command("migrate") call_command("migrate")

View File

@ -1,16 +0,0 @@
# Generated by Django 4.2.17 on 2025-02-25 14:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="userban",
options={"verbose_name": "user ban", "verbose_name_plural": "user bans"},
),
]

View File

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

View File

@ -1,3 +0,0 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();

View File

@ -0,0 +1,101 @@
import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/**
* @typedef Picture
* @property {number} id
* @property {string} name
* @property {number} size
* @property {string} date
* @property {UserProfile} owner
* @property {string} full_size_url
* @property {string} compressed_url
* @property {string} thumb_url
* @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/
/**
* @typedef PicturePageConfig
* @property {number} userId Id of the user to get the pictures from
**/
/**
* Load user picture page with a nice download bar
* @param {PicturePageConfig} config
**/
window.loadPicturePage = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", () => ({
isDownloading: false,
loading: true,
pictures: [],
albums: {},
async init() {
this.pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { users_identified: [config.userId] },
});
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
}, {});
this.loading = false;
},
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = () => {
bar.value++;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
types: {},
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p) => {
const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});
};

View File

@ -665,9 +665,7 @@ form {
} }
&:checked { &:checked {
background: none; background: var(--nf-input-focus-border-color) none initial;
background-position: 0 0;
background-color: var(--nf-input-focus-border-color);
&::after { &::after {
transform: translateY(-50%) translateX( transform: translateY(-50%) translateX(

View File

@ -106,7 +106,6 @@ $hovered-red-text-color: #ff4d4d;
color: $text-color; color: $text-color;
font-weight: normal; font-weight: normal;
line-height: 1.3em; line-height: 1.3em;
font-family: "Twemoji Country Flags", sans-serif;
&:hover { &:hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;

View File

@ -244,20 +244,6 @@ body {
} }
} }
&.btn-green {
$bg-color: rgba(0, 210, 83, 0.4);
background-color: $bg-color;
color: $black-color;
&:not(:disabled):hover {
background-color: darken($bg-color, 15%);
}
&:disabled {
background-color: lighten($bg-color, 15%);
}
}
&.btn-red { &.btn-red {
background-color: #fc8181; background-color: #fc8181;
color: black; color: black;
@ -272,26 +258,9 @@ body {
} }
} }
&.btn-orange { i {
background-color: #fcbf81; margin-right: 4px;
color: black;
&:not(:disabled):hover {
background-color: darken(#fcbf81, 15%);
}
&:disabled {
background-color: lighten(#fcbf81, 15%);
color: grey;
}
} }
&:not(.btn-no-text) {
i {
margin-right: 4px;
}
}
} }
/** /**
@ -464,11 +433,11 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
$col-gap: 1rem; $col-gap: 1rem;
$row-gap: $col-gap / 3; $row-gap: 0.5rem;
&.gap { &.gap {
column-gap: $col-gap; column-gap: var($col-gap);
row-gap: $row-gap; row-gap: var($row-gap);
} }
@for $i from 2 through 5 { @for $i from 2 through 5 {

View File

@ -23,7 +23,6 @@
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<!-- Jquery declared here to be accessible in every django widgets --> <!-- Jquery declared here to be accessible in every django widgets -->
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script> <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>

View File

@ -1,40 +1,19 @@
{% extends "core/base.jinja" %} {% 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 %} {% block title %}
{% if object_name %} {% if object %}
{% trans name=object_name %}Edit {{ name }}{% endtrans %} {% trans obj=object %}Edit {{ obj }}{% endtrans %}
{% else %} {% else %}
{% trans %}Save{% endtrans %} {% trans %}Save{% endtrans %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if object_name %} {% if object %}
<h2>{% trans name=object_name %}Edit {{ name }}{% endtrans %}</h2> <h2>{% trans obj=object %}Edit {{ obj }}{% endtrans %}</h2>
{% else %} {% else %}
<h2>{% trans %}Save{% endtrans %}</h2> <h2>{% trans %}Save{% endtrans %}</h2>
{% endif %} {% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}

View File

@ -39,6 +39,14 @@
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a> <a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %} {%- endmacro %}
{% macro fb_quick(news) -%}
<a rel="nofollow" target="#" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}" class="fb fa-brands fa-facebook fa-2x"></a>
{%- endmacro %}
{% macro tweet_quick(news) -%}
<a rel="nofollow" target="#" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}" class="twitter fa-brands fa-twitter-square fa-2x"></a>
{%- endmacro %}
{% macro user_mini_profile(user) %} {% macro user_mini_profile(user) %}
<div class="user_mini_profile"> <div class="user_mini_profile">
<div class="user_mini_profile_infos"> <div class="user_mini_profile_infos">

View File

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

View File

@ -166,7 +166,7 @@
</div> </div>
{% endif %} {% endif %}
<br> <br>
{% if profile.was_subscribed and (user == profile or user.has_perm("subscription.view_subscription")) %} {% if profile.was_subscribed and (user == profile or user.can_read_subscription_history)%}
<div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak> <div class="collapse" :class="{'shadow': collapsed}" x-data="{collapsed: false}" x-cloak>
<div class="collapse-header clickable" @click="collapsed = !collapsed"> <div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text"> <span class="collapse-header-text">
@ -197,9 +197,9 @@
</table> </table>
</div> </div>
</div> </div>
<hr>
{% endif %} {% endif %}
<hr>
<div> <div>
{% if user.is_root or user.is_board_member %} {% if user.is_root or user.is_board_member %}
<form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post"> <form class="form-gifts" action="{{ url('core:user_gift_create', user_id=profile.id) }}" method="post">

View File

@ -1,13 +1,11 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "sas/macros.jinja" import download_button %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static('bundled/sas/user/pictures-index.ts') }}"></script> <script type="module" src="{{ static('bundled/user/pictures-index.js') }}"></script>
<script type="module" src="{{ static('bundled/sas/pictures-download-index.ts') }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -15,28 +13,33 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<main x-data="user_pictures({ userId: {{ object.id }} })"> <main x-data="user_pictures">
{% if user.id == object.id %} {% if user.id == object.id %}
{{ download_button(_("Download all my pictures")) }} <div x-show="pictures.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue"
@click="downloadZip()"
>
<i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %}
</button>
<progress x-ref="progress" x-show="isDownloading"></progress>
</div>
{% endif %} {% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak> <template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<section> <section>
<br /> <br />
<div class="row"> <h4 x-text="album"></h4>
<h4 x-text="album"></h4>
{% if user.id == object.id %}
&nbsp;{{ download_button("") }}
{% endif %}
</div>
<div class="photos"> <div class="photos">
<template x-for="picture in pictures"> <template x-for="picture in pictures">
<a :href="picture.sas_url"> <a :href="`/sas/picture/${picture.id}`">
<div <div
class="photo" class="photo"
:class="{not_moderated: !picture.is_moderated}" :class="{not_moderated: !picture.is_moderated}"
:style="`background-image: url(${picture.thumb_url})`"
> >
<img :src="picture.thumb_url" :alt="picture.name" loading="lazy" />
<template x-if="!picture.is_moderated"> <template x-if="!picture.is_moderated">
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
@ -53,3 +56,13 @@
<div class="photos" :aria-busy="loading"></div> <div class="photos" :aria-busy="loading"></div>
</main> </main>
{% endblock content %} {% endblock content %}
{% block script %}
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadPicturePage({ userId: {{ object.id }} });
})
</script>
{% endblock script %}

View File

@ -13,7 +13,7 @@
<h3>{% trans %}User Tools{% endtrans %}</h3> <h3>{% trans %}User Tools{% endtrans %}</h3>
<div class="container"> <div class="container">
{% if user.has_perm("subscription.view_userban") or user.is_root or user.is_board_member %} {% if user.can_create_subscription or user.is_root or user.is_board_member %}
<div> <div>
<h4>{% trans %}Sith management{% endtrans %}</h4> <h4>{% trans %}Sith management{% endtrans %}</h4>
<ul> <ul>
@ -21,16 +21,16 @@
<li><a href="{{ url('core:group_list') }}">{% trans %}Groups{% endtrans %}</a></li> <li><a href="{{ url('core:group_list') }}">{% trans %}Groups{% endtrans %}</a></li>
<li><a href="{{ url('rootplace:merge') }}">{% trans %}Merge users{% endtrans %}</a></li> <li><a href="{{ url('rootplace:merge') }}">{% trans %}Merge users{% endtrans %}</a></li>
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li> <li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
<li> <li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
<a href="{{ url('rootplace:delete_forum_messages') }}">
{% trans %}Delete user's forum messages{% endtrans %}
</a>
</li>
{% endif %} {% endif %}
{% if user.has_perm("core.view_userban") %} {% if user.has_perm("core.view_userban") %}
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li> <li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
{% endif %} {% endif %}
{% if user.can_create_subscription or user.is_root %}
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
{% endif %}
{% if user.is_board_member or user.is_root %} {% if user.is_board_member or user.is_root %}
<li><a href="{{ url('subscription:stats') }}">{% trans %}Subscription stats{% endtrans %}</a></li>
<li><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></li> <li><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -42,202 +42,152 @@
{% set is_admin_on_a_counter = true %} {% set is_admin_on_a_counter = true %}
{% endfor %} {% endfor %}
{% if is_admin_on_a_counter or user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %} {% if
<div> is_admin_on_a_counter
<h4>{% trans %}Counters{% endtrans %}</h4> or user.is_root
<ul> or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) %} %}
<li>
<a href="{{ url('counter:admin_list') }}">
{% trans %}General counters management{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:product_list') }}">
{% trans %}Products management{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:product_type_list') }}">
{% trans %}Product types management{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:cash_summary_list') }}">
{% trans %}Cash register summaries{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:invoices_call') }}">
{% trans %}Invoices call{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('counter:eticket_list') }}">
{% trans %}Etickets{% endtrans %}
</a>
</li>
{% endif %}
</ul>
<ul>
{% for b in settings.SITH_COUNTER_BARS %}
{% if user.is_in_group(name=b[1]+" admin") %}
{% set c = Counter.objects.filter(id=b[0]).first() %}
<li class="rows counter">
<a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
<span>
<span>
<a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">
{% trans %}Edit{% endtrans %}
</a>
<a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">
{% trans %}Stats{% endtrans %}
</a>
</span>
</span>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<div>
<h4>{% trans %}Accounting{% endtrans %}</h4>
<ul>
{% if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) %}
<li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
<li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
<li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
{% endif %}
{% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
{%- for b in m.club.bank_accounts.all() %}
<li class="rows">
<strong>{% trans %}Bank account: {% endtrans %}</strong>
<a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
</li>
{%- endfor %}
{% if m.club.club_account.exists() -%}
{% for ca in m.club.club_account.all() %}
<li class="rows">
<strong>{% trans %}Club account: {% endtrans %}</strong>
<a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
</li>
{%- endfor %}
{%- endif -%}
{%- endfor %}
</ul>
</div>
{% endif %}
{% if user.is_root or user.is_com_admin or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<div>
<h4>{% trans %}Communication{% endtrans %}</h4>
<ul>
{% if user.is_com_admin or user.is_root %}
<li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
<li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
<li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
<li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
<li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
<li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
<li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
<li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
<li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% if user.has_perm("subscription.add_subscription") or user.has_perm("auth.change_perm") or user.is_root or user.is_board_member %}
<div>
<h4>{% trans %}Subscriptions{% endtrans %}</h4>
<ul>
{% if user.has_perm("subscription.add_subscription") %}
<li>
<a href="{{ url("subscription:subscription") }}">
{% trans %}New subscription{% endtrans %}
</a>
</li>
{% endif %}
{% if user.has_perm("auth.change_permission") %}
<li>
<a href="{{ url("subscription:perms") }}">
{% trans %}Manage permissions{% endtrans %}
</a>
</li>
{% endif %}
{% if user.is_root or user.is_board_member %}
<li>
<a href="{{ url("subscription:stats") }}">
{% trans %}Subscription stats{% endtrans %}
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{% if user.memberships.filter(end_date=None).all().count() > 0 %}
<div>
<h4>{% trans %}Club tools{% endtrans %}</h4>
<ul>
{% for m in user.memberships.filter(end_date=None).all() %}
<li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
<div>
<h4>{% trans %}Pedagogy{% endtrans %}</h4>
<ul>
{% if user.has_perm("pedagogy.add_uv") %}
<li>
<a href="{{ url("pedagogy:uv_create") }}">
{% trans %}Create UV{% endtrans %}
</a>
</li>
{% endif %}
{% if user.has_perm("pedagogy.delete_uvcomment") %}
<li>
<a href="{{ url("pedagogy:moderation") }}">
{% trans %}Moderate comments{% endtrans %}
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
<div> <div>
<h4>{% trans %}Elections{% endtrans %}</h4> <h4>{% trans %}Counters{% endtrans %}</h4>
<ul> <ul>
<li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li> {% if user.is_root
<li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li> or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
{%- if user.is_subscribed -%} %}
<li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li> <li><a href="{{ url('counter:admin_list') }}">{% trans %}General counters management{% endtrans %}</a></li>
{%- endif -%} <li><a href="{{ url('counter:product_list') }}">{% trans %}Products management{% endtrans %}</a></li>
</ul> <li><a href="{{ url('counter:product_type_list') }}">{% trans %}Product types management{% endtrans %}</a></li>
</div> <li><a href="{{ url('counter:cash_summary_list') }}">{% trans %}Cash register summaries{% endtrans %}</a></li>
<li><a href="{{ url('counter:invoices_call') }}">{% trans %}Invoices call{% endtrans %}</a></li>
<li><a href="{{ url('counter:eticket_list') }}">{% trans %}Etickets{% endtrans %}</a></li>
{% endif %}
</ul>
<ul>
{% for b in settings.SITH_COUNTER_BARS %}
{% if user.is_in_group(name=b[1]+" admin") %}
{% set c = Counter.objects.filter(id=b[0]).first() %}
<div> <li class="rows counter">
<h4>{% trans %}Other tools{% endtrans %}</h4> <a href="{{ url('counter:details', counter_id=b[0]) }}">{{ b[1] }}</a>
<ul>
<li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li> <span>
</ul> <span>
</div> <a class="button" href="{{ url('counter:admin', counter_id=b[0]) }}">{% trans %}Edit{% endtrans %}</a>
</div> <a class="button" href="{{ url('counter:stats', counter_id=b[0]) }}">{% trans %}Stats{% endtrans %}</a>
</main> </span>
</span>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
{% if
user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
or user.memberships.ongoing().filter(role__gte=7).count() > 10
%}
<div>
<h4>{% trans %}Accounting{% endtrans %}</h4>
<ul>
{% if user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
%}
<li><a href="{{ url('accounting:refound_account') }}">{% trans %}Refound Account{% endtrans %}</a></li>
<li><a href="{{ url('accounting:bank_list') }}">{% trans %}General accounting{% endtrans %}</a></li>
<li><a href="{{ url('accounting:co_list') }}">{% trans %}Company list{% endtrans %}</a></li>
{% endif %}
{% for m in user.memberships.filter(end_date=None).filter(role__gte=7).all() -%}
{%- for b in m.club.bank_accounts.all() %}
<li class="rows">
<strong>{% trans %}Bank account: {% endtrans %}</strong>
<a href="{{ url('accounting:bank_details', b_account_id=b.id) }}">{{ b }}</a>
</li>
{%- endfor %}
{% if m.club.club_account.exists() -%}
{% for ca in m.club.club_account.all() %}
<li class="rows">
<strong>{% trans %}Club account: {% endtrans %}</strong>
<a href="{{ url('accounting:club_details', c_account_id=ca.id) }}">{{ ca }}</a>
</li>
{%- endfor %}
{%- endif -%}
{%- endfor %}
</ul>
</div>
{% endif %}
{% if
user.is_root
or user.is_com_admin
or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
%}
<div>
<h4>{% trans %}Communication{% endtrans %}</h4>
<ul>
{% if user.is_com_admin or user.is_root %}
<li><a href="{{ url('com:weekmail_article') }}">{% trans %}Create weekmail article{% endtrans %}</a></li>
<li><a href="{{ url('com:weekmail') }}">{% trans %}Weekmail{% endtrans %}</a></li>
<li><a href="{{ url('com:weekmail_destinations') }}">{% trans %}Weekmail destinations{% endtrans %}</a></li>
<li><a href="{{ url('com:news_new') }}">{% trans %}Create news{% endtrans %}</a></li>
<li><a href="{{ url('com:news_admin_list') }}">{% trans %}Moderate news{% endtrans %}</a></li>
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
<li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
<li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
<li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
<li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
{% endif %}
{% if user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
<li><a href="{{ url('sas:moderation') }}">{% trans %}Moderate pictures{% endtrans %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% if user.memberships.filter(end_date=None).all().count() > 0 %}
<div>
<h4>{% trans %}Club tools{% endtrans %}</h4>
<ul>
{% for m in user.memberships.filter(end_date=None).all() %}
<li><a href="{{ url('club:tools', club_id=m.club.id) }}">{{ m.club }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if
user.is_root
or user.is_in_group(pk=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID)
%}
<div>
<h4>{% trans %}Pedagogy{% endtrans %}</h4>
<ul>
<li><a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a></li>
</ul>
</div>
{% endif %}
<div>
<h4>{% trans %}Elections{% endtrans %}</h4>
<ul>
<li><a href="{{ url('election:list') }}">{% trans %}See available elections{% endtrans %}</a></li>
<li><a href="{{ url('election:list_archived') }}">{% trans %}See archived elections{% endtrans %}</a></li>
{%- if user.is_subscribed -%}
<li><a href="{{ url('election:create') }}">{% trans %}Create a new election{% endtrans %}</a></li>
{%- endif -%}
</ul>
</div>
<div>
<h4>{% trans %}Other tools{% endtrans %}</h4>
<ul>
<li><a href="{{ url('trombi:user_tools') }}">{% trans %}Trombi tools{% endtrans %}</a></li>
</ul>
</div>
</div>
</main>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -64,6 +64,40 @@ class TestImageAccess:
assert not picture.is_owned_by(user) assert not picture.is_owned_by(user)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"core:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:
# - renaming a page # - renaming a page
# - changing a page's parent --> check that page's children's full_name # - changing a page's parent --> check that page's children's full_name

View File

@ -8,15 +8,13 @@ from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe, foreign_key
from pytest_django.asserts import assertRedirects
from com.models import News
from core.baker_recipes import ( from core.baker_recipes import (
old_subscriber_user, old_subscriber_user,
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import Group, User from core.models import User
from counter.models import Counter, Refilling, Selling from counter.models import Counter, Refilling, Selling
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@ -24,8 +22,6 @@ from eboutic.models import Invoice, InvoiceItem
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
@ -191,31 +187,3 @@ def test_generate_username(first_name: str, last_name: str, expected: str):
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com") new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
new_user.generate_username() new_user.generate_username()
assert new_user.username == expected assert new_user.username == expected
@pytest.mark.django_db
def test_user_added_to_public_group():
"""Test that newly created users are added to the public group"""
user = baker.make(User)
assert user.groups.filter(pk=settings.SITH_GROUP_PUBLIC_ID).exists()
assert user.is_in_group(pk=settings.SITH_GROUP_PUBLIC_ID)
@pytest.mark.django_db
def test_user_update_groups(client: Client):
client.force_login(baker.make(User, is_superuser=True))
manageable_groups = baker.make(Group, is_manually_manageable=True, _quantity=3)
hidden_groups = baker.make(Group, is_manually_manageable=False, _quantity=4)
user = baker.make(User, groups=[*manageable_groups[1:], *hidden_groups[:3]])
response = client.post(
reverse("core:user_groups", kwargs={"user_id": user.id}),
data={"groups": [manageable_groups[0].id, manageable_groups[1].id]},
)
assertRedirects(response, user.get_absolute_url())
# only the manually manageable groups should have changed
assert set(user.groups.all()) == {
Group.objects.get(pk=settings.SITH_GROUP_PUBLIC_ID),
manageable_groups[0],
manageable_groups[1],
*hidden_groups[:3],
}

View File

@ -23,7 +23,6 @@
# #
from django.urls import path, re_path, register_converter from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
from core.converters import ( from core.converters import (
BooleanStringConverter, BooleanStringConverter,
@ -69,6 +68,7 @@ from core.views import (
UserGodfathersView, UserGodfathersView,
UserListView, UserListView,
UserMiniView, UserMiniView,
UserPicturesView,
UserPreferencesView, UserPreferencesView,
UserStatsView, UserStatsView,
UserToolsView, UserToolsView,
@ -144,8 +144,7 @@ urlpatterns = [
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"), path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"), path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
path( path(
"user/<int:user_id>/pictures/", "user/<int:user_id>/pictures/", UserPicturesView.as_view(), name="user_pictures"
RedirectView.as_view(pattern_name="sas:user_pictures", permanent=True),
), ),
path( path(
"user/<int:user_id>/godfathers/", "user/<int:user_id>/godfathers/",

View File

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

View File

@ -22,16 +22,28 @@
# #
# #
import types
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
)
from django.http import ( from django.http import (
HttpResponseForbidden, HttpResponseForbidden,
HttpResponseNotFound, HttpResponseNotFound,
HttpResponseServerError, HttpResponseServerError,
) )
from django.shortcuts import render from django.shortcuts import render
from django.views.generic.detail import BaseDetailView from django.utils.functional import cached_property
from django.views.generic.base import View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from core.models import User
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -53,12 +65,273 @@ def internal_servor_error(request):
return HttpResponseServerError(render(request, "core/500.jinja")) return HttpResponseServerError(render(request, "core/500.jinja"))
class DetailFormView(FormView, BaseDetailView): def can_edit_prop(obj: Any, user: User) -> bool:
"""Can the user edit the properties of the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object properties else False
Examples:
```python
if not can_edit_prop(self.object ,request.user):
raise PermissionDenied
```
"""
return obj is None or user.is_owner(obj)
def can_edit(obj: Any, user: User) -> bool:
"""Can the user edit the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to edit object else False
Examples:
```python
if not can_edit(self.object, request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_edit(obj):
return True
return can_edit_prop(obj, user)
def can_view(obj: Any, user: User) -> bool:
"""Can the user see the object.
Args:
obj: Object to test for permission
user: core.models.User to test permissions against
Returns:
True if user is authorized to see object else False
Examples:
```python
if not can_view(self.object ,request.user):
raise PermissionDenied
```
"""
if obj is None or user.can_view(obj):
return True
return can_edit(obj, user)
class GenericContentPermissionMixinBuilder(View):
"""Used to build permission mixins.
This view protect any child view that would be showing an object that is restricted based
on two properties.
Attributes:
raised_error: permission to be raised
"""
raised_error = PermissionDenied
@staticmethod
def permission_function(obj: Any, user: User) -> bool:
"""Function to test permission with."""
return False
@classmethod
def get_permission_function(cls, obj, user):
return cls.permission_function(obj, user)
def dispatch(self, request, *arg, **kwargs):
if hasattr(self, "get_object") and callable(self.get_object):
self.object = self.get_object()
if not self.get_permission_function(self.object, request.user):
raise self.raised_error
return super().dispatch(request, *arg, **kwargs)
# If we get here, it's a ListView
queryset = self.get_queryset()
l_id = [o.id for o in queryset if self.get_permission_function(o, request.user)]
if not l_id and queryset.count() != 0:
raise self.raised_error
self._get_queryset = self.get_queryset
def get_qs(self2):
return self2._get_queryset().filter(id__in=l_id)
self.get_queryset = types.MethodType(get_qs, self)
return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def dispatch(self, request, *arg, **kwargs):
res = super().dispatch(request, *arg, **kwargs)
if not request.user.is_authenticated:
raise PermissionDenied
return res
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object.
In other word, you can make a view with this view as parent,
and it will be retricted to the users that are in the
object's owner_group or that pass the `obj.can_be_viewed_by` test.
Raises:
PermissionDenied: If the user cannot see the object
"""
permission_function = can_edit_prop
class CanEditMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to edit this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_edit
class CanViewMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has permission to view this view's object.
Raises:
PermissionDenied: if the user cannot edit this view's object.
"""
permission_function = can_view
class UserIsRootMixin(GenericContentPermissionMixinBuilder):
"""Allow only root admins.
Raises:
PermissionDenied: if the user isn't root
"""
@staticmethod
def permission_function(obj: Any, user: User):
return user.is_root
class FormerSubscriberMixin(AccessMixin):
"""Check if the user was at least an old subscriber.
Raises:
PermissionDenied: if the user never subscribed.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.was_subscribed:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class SubscriberMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_subscribed:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
def get_tabs_title(self):
if hasattr(self, "tabs_title"):
return self.tabs_title
raise ImproperlyConfigured("tabs_title is required")
def get_current_tab(self):
if hasattr(self, "current_tab"):
return self.current_tab
raise ImproperlyConfigured("current_tab is required")
def get_list_of_tabs(self):
if hasattr(self, "list_of_tabs"):
return self.list_of_tabs
raise ImproperlyConfigured("list_of_tabs is required")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["tabs_title"] = self.get_tabs_title()
kwargs["current_tab"] = self.get_current_tab()
kwargs["list_of_tabs"] = self.get_list_of_tabs()
return kwargs
class QuickNotifMixin:
quick_notif_list = []
def dispatch(self, request, *arg, **kwargs):
# In some cases, the class can stay instanciated, so we need to reset the list
self.quick_notif_list = []
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self):
ret = super().get_success_url()
if hasattr(self, "quick_notif_url_arg"):
if "?" in ret:
ret += "&" + self.quick_notif_url_arg
else:
ret += "?" + self.quick_notif_url_arg
return ret
def get_context_data(self, **kwargs):
"""Add quick notifications to context."""
kwargs = super().get_context_data(**kwargs)
kwargs["quick_notifs"] = []
for n in self.quick_notif_list:
kwargs["quick_notifs"].append(settings.SITH_QUICK_NOTIF[n])
for key, val in settings.SITH_QUICK_NOTIF.items():
for gk in self.request.GET:
if key == gk:
kwargs["quick_notifs"].append(val)
return kwargs
class DetailFormView(SingleObjectMixin, FormView):
"""Class that allow both a detail view and a form view.""" """Class that allow both a detail view and a form view."""
def post(self, request, *args, **kwargs): def get_object(self):
self.object = self.get_object() """Get current group from id in url."""
return super().post(request, *args, **kwargs) return self.cached_object
@cached_property
def cached_object(self):
"""Optimisation on group retrieval."""
return super().get_object()
class AllowFragment:
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
def get_context_data(self, **kwargs):
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
return super().get_context_data(**kwargs)
# F403: those star-imports would be hellish to refactor # F403: those star-imports would be hellish to refactor

View File

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

View File

@ -28,7 +28,6 @@ from captcha.fields import CaptchaField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import ( from django.contrib.staticfiles.management.commands.collectstatic import (
staticfiles_storage, staticfiles_storage,
) )
@ -324,19 +323,6 @@ class UserGroupsForm(forms.ModelForm):
model = User model = User
fields = ["groups"] fields = ["groups"]
def save(self, *args, **kwargs) -> User:
# make the super method manage error without persisting in db
super().save(commit=False)
# Don't forget to add the non-manageable groups when setting groups,
# or the user would lose all of those when the form is submitted
self.instance.groups.set(
[
*self.cleaned_data["groups"],
*self.instance.groups.filter(is_manually_manageable=False),
]
)
return self.instance
class UserGodfathersForm(forms.Form): class UserGodfathersForm(forms.Form):
type = forms.ChoiceField( type = forms.ChoiceField(
@ -441,28 +427,3 @@ class GiftForm(forms.ModelForm):
id=user_id id=user_id
) )
self.fields["user"].widget = forms.HiddenInput() self.fields["user"].widget = forms.HiddenInput()
class PermissionGroupsForm(forms.ModelForm):
"""Manage the groups that have a specific permission."""
class Meta:
model = Permission
fields = []
groups = forms.ModelMultipleChoiceField(
Group.objects.all(),
label=_("Groups"),
widget=AutoCompleteSelectMultipleGroup,
required=False,
)
def __init__(self, instance: Permission, **kwargs):
super().__init__(instance=instance, **kwargs)
self.fields["groups"].initial = instance.group_set.all()
def save(self, commit: bool = True): # noqa FTB001
instance = super().save(commit=False)
if commit:
instance.group_set.set(self.cleaned_data["groups"])
return instance

View File

@ -16,20 +16,13 @@
"""Views to manage Groups.""" """Views to manage Groups."""
from django import forms from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.auth.models import Permission
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ImproperlyConfigured
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditMixin
from core.models import Group, User from core.models import Group, User
from core.views import DetailFormView from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.forms import PermissionGroupsForm
from core.views.widgets.select import AutoCompleteSelectMultipleUser from core.views.widgets.select import AutoCompleteSelectMultipleUser
# Forms # Forms
@ -80,14 +73,13 @@ class GroupEditView(CanEditMixin, UpdateView):
fields = ["name", "description"] fields = ["name", "description"]
class GroupCreateView(PermissionRequiredMixin, CreateView): class GroupCreateView(CanCreateMixin, CreateView):
"""Add a new Group.""" """Add a new Group."""
model = Group model = Group
queryset = Group.objects.filter(is_manually_manageable=True) queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja" template_name = "core/create.jinja"
fields = ["name", "description"] fields = ["name", "description"]
permission_required = "core.add_group"
class GroupTemplateView(CanEditMixin, DetailFormView): class GroupTemplateView(CanEditMixin, DetailFormView):
@ -135,62 +127,3 @@ class GroupDeleteView(CanEditMixin, DeleteView):
pk_url_kwarg = "group_id" pk_url_kwarg = "group_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("core:group_list") success_url = reverse_lazy("core:group_list")
class PermissionGroupsUpdateView(
PermissionRequiredMixin, SuccessMessageMixin, UpdateView
):
"""Manage the groups that have a specific permission.
Notes:
This is an `UpdateView`, but unlike typical `UpdateView`,
it doesn't accept url arguments to retrieve the object
to update.
As such, a `PermissionGroupsUpdateView` can only deal with
a single hardcoded permission.
This is not a limitation, but an on-purpose design,
mainly for security matters.
Example:
```python
class SubscriptionPermissionView(PermissionGroupsUpdateView):
permission = "subscription.add_subscription"
```
"""
permission_required = "auth.change_permission"
template_name = "core/edit.jinja"
form_class = PermissionGroupsForm
permission = None
success_message = _("Groups have been successfully updated.")
def get_object(self, *args, **kwargs):
if not self.permission:
raise ImproperlyConfigured(
f"{self.__class__.__name__} is missing the permission attribute. "
"Please fill it with either a permission string "
"or a Permission object."
)
if isinstance(self.permission, Permission):
return self.permission
if isinstance(self.permission, str):
try:
app_label, codename = self.permission.split(".")
except ValueError as e:
raise ValueError(
"Permission name should be in the form "
"app_label.permission_codename."
) from e
return get_object_or_404(
Permission, codename=codename, content_type__app_label=app_label
)
raise TypeError(
f"{self.__class__.__name__}.permission "
f"must be a string or a permission instance."
)
def get_success_url(self):
# if children classes define a success url, return it,
# else stay on the same page
return self.success_url or self.request.path

View File

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

View File

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

View File

@ -54,8 +54,14 @@ from django.views.generic.dates import MonthMixin, YearMixin
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from honeypot.decorators import check_honeypot from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User from core.models import Gift, Preferences, User
from core.views import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
QuickNotifMixin,
TabedViewMixin,
)
from core.views.forms import ( from core.views.forms import (
GiftForm, GiftForm,
LoginForm, LoginForm,
@ -64,8 +70,8 @@ from core.views.forms import (
UserGroupsForm, UserGroupsForm,
UserProfileForm, UserProfileForm,
) )
from core.views.mixins import QuickNotifMixin, TabedViewMixin
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
from counter.views.student_card import StudentCardFormView
from eboutic.models import Invoice from eboutic.models import Invoice
from subscription.models import Subscription from subscription.models import Subscription
from trombi.views import UserTrombiForm from trombi.views import UserTrombiForm
@ -200,7 +206,7 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Family"), "name": _("Family"),
}, },
{ {
"url": reverse("sas:user_pictures", kwargs={"user_id": user.id}), "url": reverse("core:user_pictures", kwargs={"user_id": user.id}),
"slug": "pictures", "slug": "pictures",
"name": _("Pictures"), "name": _("Pictures"),
}, },
@ -297,6 +303,16 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs return kwargs
class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's pictures."""
model = User
pk_url_kwarg = "user_id"
context_object_name = "profile"
template_name = "core/user_pictures.jinja"
current_tab = "pictures"
def delete_user_godfather(request, user_id, godfather_id, is_father): def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member user_is_admin = request.user.is_root or request.user.is_board_member
if user_id != request.user.id and not user_is_admin: if user_id != request.user.id and not user_is_admin:
@ -555,8 +571,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
if not hasattr(self.object, "trombi_user"): if not hasattr(self.object, "trombi_user"):
kwargs["trombi_form"] = UserTrombiForm() kwargs["trombi_form"] = UserTrombiForm()
if hasattr(self.object, "customer"): if hasattr(self.object, "customer"):
from counter.views.student_card import StudentCardFormView
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data( kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
self.object.customer self.object.customer
).render(self.request) ).render(self.request)

View File

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

View File

@ -52,8 +52,7 @@ class CustomerQuerySet(models.QuerySet):
def update_amount(self) -> int: def update_amount(self) -> int:
"""Update the amount of all customers selected by this queryset. """Update the amount of all customers selected by this queryset.
The result is given as the sum of all refills The result is given as the sum of all refills minus the sum of all purchases.
minus the sum of all purchases paid with the AE account.
Returns: Returns:
The number of updated rows. The number of updated rows.
@ -74,9 +73,7 @@ class CustomerQuerySet(models.QuerySet):
.values("res") .values("res")
) )
money_out = Subquery( money_out = Subquery(
Selling.objects.filter( Selling.objects.filter(customer=OuterRef("pk"))
customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
)
.values("customer_id") .values("customer_id")
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0)) .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
.values("res") .values("res")
@ -847,10 +844,11 @@ class Selling(models.Model):
verbose_name = _("selling") verbose_name = _("selling")
def __str__(self): def __str__(self):
return ( return "Selling: %d x %s (%f) for %s" % (
f"Selling: {self.quantity} x {self.label} " self.quantity,
f"({self.quantity * self.unit_price} €) " self.label,
f"for {self.customer.user.get_display_name()}" self.quantity * self.unit_price,
self.customer.user.get_display_name(),
) )
def save(self, *args, allow_negative=False, **kwargs): def save(self, *args, allow_negative=False, **kwargs):
@ -1055,7 +1053,7 @@ class CashRegisterSummary(models.Model):
def __getattribute__(self, name): def __getattribute__(self, name):
if name[:5] == "check": if name[:5] == "check":
checks = self.items.filter(is_check=True).order_by("value").all() checks = self.items.filter(check=True).order_by("value").all()
if name == "ten_cents": if name == "ten_cents":
return self.items.filter(value=0.1, is_check=False).first() return self.items.filter(value=0.1, is_check=False).first()
elif name == "twenty_cents": elif name == "twenty_cents":

View File

@ -98,5 +98,3 @@ class ProductFilterSchema(FilterSchema):
is_archived: bool | None = Field(None, q="archived") is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in") buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__in") product_type: set[int] | None = Field(None, q="product_type__in")
club: set[int] | None = Field(None, q="club__in")
counter: set[int] | None = Field(None, q="counters__in")

View File

@ -1,141 +1,146 @@
import { exportToHtml } from "#core:utils/globals";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index";
document.addEventListener("alpine:init", () => { exportToHtml("loadCounter", (config: CounterConfig) => {
Alpine.data("counter", (config: CounterConfig) => ({ document.addEventListener("alpine:init", () => {
basket: {} as Record<string, BasketItem>, Alpine.data("counter", () => ({
errors: [], basket: {} as Record<string, BasketItem>,
customerBalance: config.customerBalance, errors: [],
codeField: null as CounterProductSelect | null, customerBalance: config.customerBalance,
alertMessage: { codeField: undefined,
content: "", alertMessage: {
show: false, content: "",
timeout: null, show: false,
}, timeout: null,
},
init() { init() {
// Fill the basket with the initial data // Fill the basket with the initial data
for (const entry of config.formInitial) { for (const entry of config.formInitial) {
if (entry.id !== undefined && entry.quantity !== undefined) { if (entry.id !== undefined && entry.quantity !== undefined) {
this.addToBasket(entry.id, entry.quantity); this.addToBasket(entry.id, entry.quantity);
this.basket[entry.id].errors = entry.errors ?? []; this.basket[entry.id].errors = entry.errors ?? [];
}
} }
}
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.focus(); this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here // of a formset so we dynamically apply it here
this.$refs.basketManagementForm this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS") .querySelector("#id_form-TOTAL_FORMS")
.setAttribute(":value", "getBasketSize()"); .setAttribute(":value", "getBasketSize()");
}, },
removeFromBasket(id: string) { removeFromBasket(id: string) {
delete this.basket[id];
},
addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
const oldQty = item.quantity;
item.quantity += quantity;
if (item.quantity <= 0) {
delete this.basket[id]; delete this.basket[id];
return ""; },
}
this.basket[id] = item; addToBasket(id: string, quantity: number): ErrorMessage {
const item: BasketItem =
this.basket[id] || new BasketItem(config.products[id], 0);
if (this.sumBasket() > this.customerBalance) { const oldQty = item.quantity;
item.quantity = oldQty; item.quantity += quantity;
if (item.quantity === 0) {
if (item.quantity <= 0) {
delete this.basket[id]; delete this.basket[id];
return "";
} }
return gettext("Not enough money");
}
return ""; this.basket[id] = item;
},
getBasketSize() { if (this.sumBasket() > this.customerBalance) {
return Object.keys(this.basket).length; item.quantity = oldQty;
}, if (item.quantity === 0) {
delete this.basket[id];
sumBasket() { }
if (this.getBasketSize() === 0) { return gettext("Not enough money");
return 0;
}
const total = Object.values(this.basket).reduce(
(acc: number, cur: BasketItem) => acc + cur.sum(),
0,
) as number;
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
}
},
onRefillingSuccess(event: CustomEvent) {
if (event.type !== "htmx:after-request" || event.detail.failed) {
return;
}
this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
);
document.getElementById("selling-accordion").click();
this.codeField.widget.focus();
},
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
return;
}
this.$refs.basketForm.submit();
},
cancel() {
location.href = config.cancelUrl;
},
handleCode() {
const [quantity, code] = this.codeField.getSelectedProduct() as [number, string];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
if (code === "ANN") {
this.cancel();
} }
if (code === "FIN") {
this.finish(); return "";
},
getBasketSize() {
return Object.keys(this.basket).length;
},
sumBasket() {
if (this.getBasketSize() === 0) {
return 0;
} }
} else { const total = Object.values(this.basket).reduce(
this.addToBasketWithMessage(code, quantity); (acc: number, cur: BasketItem) => acc + cur.sum(),
} 0,
this.codeField.widget.clear(); ) as number;
this.codeField.widget.focus(); return total;
}, },
}));
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
}
},
onRefillingSuccess(event: CustomEvent) {
if (event.type !== "htmx:after-request" || event.detail.failed) {
return;
}
this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
);
document.getElementById("selling-accordion").click();
this.codeField.widget.focus();
},
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
return;
}
this.$refs.basketForm.submit();
},
cancel() {
location.href = config.cancelUrl;
},
handleCode() {
const [quantity, code] = this.codeField.getSelectedProduct() as [
number,
string,
];
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
if (code === "ANN") {
this.cancel();
}
if (code === "FIN") {
this.finish();
}
} else {
this.addToBasketWithMessage(code, quantity);
}
this.codeField.widget.clear();
this.codeField.widget.focus();
},
}));
});
}); });
$(() => { $(() => {

View File

@ -60,8 +60,6 @@ document.addEventListener("alpine:init", () => {
productStatus: "" as "active" | "archived" | "both", productStatus: "" as "active" | "archived" | "both",
search: "", search: "",
productTypes: [] as string[], productTypes: [] as string[],
clubs: [] as string[],
counters: [] as string[],
pageSize: defaultPageSize, pageSize: defaultPageSize,
page: defaultPage, page: defaultPage,
@ -69,27 +67,13 @@ document.addEventListener("alpine:init", () => {
const url = getCurrentUrlParams(); const url = getCurrentUrlParams();
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active"; this.productStatus = url.get("productStatus") ?? "active";
const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect; const widget = this.$refs.productTypesInput.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => { widget.on("change", (items: string[]) => {
this.productTypes = [...items]; this.productTypes = [...items];
}); });
const clubsWidget = this.$refs.clubsInput.widget as TomSelect;
clubsWidget.on("change", (items: string[]) => {
this.clubs = [...items];
});
const countersWidget = this.$refs.countersInput.widget as TomSelect;
countersWidget.on("change", (items: string[]) => {
this.counters = [...items];
});
await this.load(); await this.load();
const searchParams = [ const searchParams = ["search", "productStatus", "productTypes"];
"search",
"productStatus",
"productTypes",
"clubs",
"counters",
];
for (const param of searchParams) { for (const param of searchParams) {
this.$watch(param, () => { this.$watch(param, () => {
this.page = defaultPage; this.page = defaultPage;
@ -125,8 +109,6 @@ document.addEventListener("alpine:init", () => {
is_archived: isArchived, is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes], product_type: [...this.productTypes],
club: [...this.clubs],
counter: [...this.counters],
}, },
}; };
}, },
@ -139,17 +121,14 @@ document.addEventListener("alpine:init", () => {
const options = this.getQueryParams(); const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options); const resp = await productSearchProductsDetailed(options);
this.nbPages = Math.ceil(resp.data.count / defaultPageSize); this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>( this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
(acc: GroupedProducts, curr: ProductSchema) => { const key = curr.product_type?.name ?? gettext("Uncategorized");
const key = curr.product_type?.name ?? gettext("Uncategorized"); if (!(key in acc)) {
if (!(key in acc)) { acc[key] = [];
acc[key] = []; }
} acc[key].push(curr);
acc[key].push(curr); return acc;
return acc; }, {});
},
{},
);
this.loading = false; this.loading = false;
}, },

View File

@ -27,13 +27,7 @@
{% block content %} {% block content %}
<h4>{{ counter }}</h4> <h4>{{ counter }}</h4>
<div id="bar-ui" x-data="counter({ <div id="bar-ui" x-data="counter">
customerBalance: {{ customer.amount }},
products: products,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: '{{ cancel_url }}',
})">
<noscript> <noscript>
<p class="important">Javascript is required for the counter UI.</p> <p class="important">Javascript is required for the counter UI.</p>
</noscript> </noscript>
@ -261,5 +255,14 @@
{%- endif -%} {%- endif -%}
{%- endfor -%} {%- endfor -%}
]; ];
window.addEventListener("DOMContentLoaded", () => {
loadCounter({
customerBalance: {{ customer.amount }},
products: products,
customerId: {{ customer.pk }},
formInitial: formInitial,
cancelUrl: "{{ cancel_url }}",
});
});
</script> </script>
{% endblock script %} {% endblock script %}

View File

@ -7,7 +7,6 @@
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script> <script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/club/components/ajax-select-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script> <script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
{% endblock %} {% endblock %}
@ -23,6 +22,7 @@
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4> <h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
<form id="search-form" class="margin-bottom"> <form id="search-form" class="margin-bottom">
<div class="row gap-4x"> <div class="row gap-4x">
<fieldset> <fieldset>
<label for="search-input">{% trans %}Product name{% endtrans %}</label> <label for="search-input">{% trans %}Product name{% endtrans %}</label>
<input <input
@ -48,34 +48,16 @@
</div> </div>
</fieldset> </fieldset>
</div> </div>
<div class="row gap-4x"> <fieldset>
<fieldset class="grow"> <label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
<label for="type-search-input">{% trans %}Product type{% endtrans %}</label> <product-type-ajax-select
<product-type-ajax-select id="type-search-input"
id="type-search-input" name="product-type"
name="product-type" x-ref="productTypesInput"
x-ref="productTypesInput" multiple
multiple >
></product-type-ajax-select> </product-type-ajax-select>
</fieldset> </fieldset>
<fieldset class="grow">
<label for="club-search-input">{% trans %}Clubs{% endtrans %}</label>
<club-ajax-select
id="club-search-input"
name="club"
x-ref="clubsInput"
multiple></club-ajax-select>
</fieldset>
<fieldset class="grow">
<label for="counter-search-input">{% trans %}Counters{% endtrans %}</label>
<counter-ajax-select
id="counter-search-input"
name="counter"
x-ref="countersInput"
multiple
></counter-ajax-select>
</fieldset>
</div>
</form> </form>
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>

View File

@ -681,42 +681,6 @@ class TestCounterClick(TestFullClickBase):
-3 - settings.SITH_ECOCUP_LIMIT -3 - settings.SITH_ECOCUP_LIMIT
) )
def test_recordings_when_negative(self):
self.refill_user(
self.customer,
self.cons.selling_price * 3 + Decimal(self.beer.selling_price),
)
self.customer.customer.recorded_products = settings.SITH_ECOCUP_LIMIT * -10
self.customer.customer.save()
self.login_in_bar(self.barmen)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.dcons.id, 1)],
).status_code
== 200
)
assert self.updated_amount(
self.customer
) == self.cons.selling_price * 3 + Decimal(self.beer.selling_price)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.cons.id, 3)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == Decimal(self.beer.selling_price)
assert (
self.submit_basket(
self.customer,
[BasketItem(self.beer.id, 1)],
).status_code
== 302
)
assert self.updated_amount(self.customer) == 0
class TestCounterStats(TestCase): class TestCounterStats(TestCase):
@classmethod @classmethod
@ -973,23 +937,13 @@ class TestClubCounterClickAccess(TestCase):
assert res.status_code == 403 assert res.status_code == 403
def test_board_member(self): def test_board_member(self):
"""By default, board members should be able to click on office counters"""
baker.make(Membership, club=self.counter.club, user=self.user, role=3) baker.make(Membership, club=self.counter.club, user=self.user, role=3)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
def test_barman(self): def test_barman(self):
"""Sellers should be able to click on office counters"""
self.counter.sellers.add(self.user) self.counter.sellers.add(self.user)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 403
def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well."""
self.counter.sellers.add(self.user)
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200

View File

@ -442,7 +442,6 @@ def test_update_balance():
_quantity=len(customers), _quantity=len(customers),
unit_price=10, unit_price=10,
quantity=1, quantity=1,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
*sale_recipe.prepare( *sale_recipe.prepare(
@ -450,26 +449,10 @@ def test_update_balance():
_quantity=3, _quantity=3,
unit_price=5, unit_price=5,
quantity=2, quantity=2,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
sale_recipe.prepare( sale_recipe.prepare(
customer=customers[4], customer=customers[4], quantity=1, unit_price=50, _save_related=True
quantity=1,
unit_price=50,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
*sale_recipe.prepare(
# all customers also bought products without using their AE account.
# All purchases made with another mean than the AE account should
# be ignored when updating the account balance.
customer=iter(customers),
_quantity=len(customers),
unit_price=50,
quantity=1,
payment_method="CARD",
_save_related=True,
), ),
] ]
Selling.objects.bulk_create(sales) Selling.objects.bulk_create(sales)

View File

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

View File

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

View File

@ -31,9 +31,9 @@ from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.utils import FormFragmentTemplateData from core.utils import FormFragmentTemplateData
from core.views import CanViewMixin
from counter.forms import RefillForm from counter.forms import RefillForm
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer, Product, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@ -126,11 +126,6 @@ class BaseBasketForm(BaseFormSet):
if form.product.is_unrecord_product: if form.product.is_unrecord_product:
self.total_recordings += form.cleaned_data["quantity"] self.total_recordings += form.cleaned_data["quantity"]
# We don't want to block an user that have negative recordings
# if he isn't recording anything or reducing it's recording count
if self.total_recordings <= 0:
return
if not customer.can_record_more(self.total_recordings): if not customer.can_record_more(self.total_recordings):
raise ValidationError(_("This user have reached his recording limit")) raise ValidationError(_("This user have reached his recording limit"))
@ -147,16 +142,15 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
""" """
model = Counter model = Counter
queryset = ( queryset = Counter.objects.annotate_is_open()
Counter.objects.exclude(type="EBOUTIC")
.annotate_is_open()
.select_related("club")
)
form_class = BasketForm form_class = BasketForm
template_name = "counter/counter_click.jinja" template_name = "counter/counter_click.jinja"
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
current_tab = "counter" current_tab = "counter"
def get_queryset(self):
return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
@ -174,15 +168,9 @@ class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
if obj.type == "OFFICE" and ( if obj.type == "OFFICE" and (
request.user.is_anonymous obj.sellers.filter(pk=request.user.pk).exists()
or not ( or not obj.club.has_rights_in_club(request.user)
obj.sellers.contains(request.user)
or obj.club.has_rights_in_club(request.user)
)
): ):
# To be able to click on an office counter,
# a user must either be in the board of the club that own the counter
# or a seller of this counter.
raise PermissionDenied raise PermissionDenied
if obj.type == "BAR" and ( if obj.type == "BAR" and (

View File

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

View File

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

View File

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

View File

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

36
docker-compose.yml Normal file
View File

@ -0,0 +1,36 @@
services:
db:
image: postgres:16.6
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
ports:
- "5431:5432"
environment:
POSTGRES_USER: sith
POSTGRES_PASSWORD: sith
POSTGRES_DB: sith
redis:
image: redis:latest
restart: unless-stopped
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
ports:
- "6378:6379"
command: redis-server
volumes:
- redis_data:/var/lib/redis/data/
volumes:
postgres_data:
driver: local
redis_data:
driver: local

View File

@ -8,7 +8,7 @@ Cette variable est composée d'un lien complet vers votre projet sentry.
## Récupérer les statiques ## Récupérer les statiques
Nous utilisons du SCSS dans le projet. Nous utilisons du SCSS dans le projet.
En environnement de développement (`SITH_DEBUG=true`), En environnement de développement (`DEBUG=true`),
le SCSS est compilé à chaque fois que le fichier est demandé. le SCSS est compilé à chaque fois que le fichier est demandé.
Pour la production, le projet considère Pour la production, le projet considère
que chacun des fichiers est déjà compilé. que chacun des fichiers est déjà compilé.

View File

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

View File

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

View File

@ -157,9 +157,7 @@ il est automatiquement ajouté au groupe des membres
du club. du club.
Lorsqu'il quitte le club, il est retiré du groupe. Lorsqu'il quitte le club, il est retiré du groupe.
## Les groupes utilisés ## Les principaux groupes utilisés
### Groupes principaux
Les groupes les plus notables gérables par les administrateurs du site sont : Les groupes les plus notables gérables par les administrateurs du site sont :
@ -170,96 +168,15 @@ Les groupes les plus notables gérables par les administrateurs du site sont :
- `SAS admin` : les administrateurs du SAS - `SAS admin` : les administrateurs du SAS
- `Forum admin` : les administrateurs du forum - `Forum admin` : les administrateurs du forum
- `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs) - `Pedagogy admin` : les administrateurs de la pédagogie (guide des UVs)
En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site.
Un utilisateur est automatiquement ajouté à ce group
lors de la création de son compte.
- `Subscribers` : tous les cotisants du site.
Les utilisateurs ne sont pas réellement ajoutés ce groupe ;
cependant, les utilisateurs cotisants sont implicitement
considérés comme membres du groupe lors de l'appel
à la méthode `User.has_perm`.
- `Old subscribers` : tous les anciens cotisants.
Un utilisateur est automatiquement ajouté à ce groupe
lors de sa première cotisation
!!!note "Utilisation du groupe Public"
Le groupe Public est un groupe particulier.
Tout le monde faisant partie de ce groupe
(même les utilisateurs non-connectés en sont implicitement
considérés comme membres),
il ne doit pas être utilisé pour résoudre les
permissions d'une vue.
En revanche, il est utile pour attribuer une ressource
à tout le monde.
Par exemple, un produit avec le groupe de vente Public
est considéré comme achetable par tous utilisateurs.
S'il n'avait eu aucun group de vente, il n'aurait
été accessible à personne.
### Groupes de club
Chaque club est associé à deux groupes :
le groupe des membres et le groupe du bureau.
Lorsqu'un utilisateur rejoint un club, il est automatiquement
ajouté au groupe des membres.
S'il rejoint le club en tant que membre du bureau,
il est également ajouté au groupe du bureau.
Lorsqu'un utilisateur quitte le club, il est automatiquement
retiré des groupes liés au club.
S'il quitte le bureau, mais reste dans le club,
il est retiré du groupe du bureau, mais reste dans le groupe des membres.
### Groupes de ban
Les groupes de ban sont une catégorie de groupes à part,
qui ne sont pas stockés dans la même table
et qui ne sont pas gérés sur la même interface
que les autres groupes.
Les groupes de ban existants sont les suivants :
- `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs) - `Banned from buying alcohol` : les utilisateurs interdits de vente d'alcool (non mineurs)
- `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs - `Banned from counters` : les utilisateurs interdits d'utilisation des comptoirs
- `Banned to subscribe` : les utilisateurs interdits de cotisation - `Banned to subscribe` : les utilisateurs interdits de cotisation
## Groupes liés à une permission
Certaines actions sur le site demandent une permission en particulier, En plus de ces groupes, on peut noter :
que l'on veut donner ou retirer n'importe quand.
Prenons par exemple les cotisations : lors de l'intégration, - `Public` : tous les utilisateurs du site
on veut permettre aux membres du bureau de l'Integ - `Subscribers` : tous les cotisants du site
de créer des cotisations, et pareil pour les membres du bureau - `Old subscribers` : tous les anciens cotisants
de la Welcome Week pendant cette dernière.
Dans ces cas-là, il est pertinent de mettre à disposition
des administrateurs du site une page leur permettant
de gérer quels groupes ont une permission donnée.
Pour ce faire, il existe
[PermissionGroupsUpdateView][core.views.PermissionGroupsUpdateView].
Pour l'utiliser, il suffit de créer une vue qui en hérite
et de lui dire quelle est la permission dont on veut gérer
les groupes :
```python
from core.views.group import PermissionGroupsUpdateView
class SubscriptionPermissionView(PermissionGroupsUpdateView):
permission = "subscription.add_subscription"
```
Configurez l'url de la vue, et c'est tout !
La page ainsi générée contiendra un formulaire
avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root).

View File

@ -77,58 +77,6 @@ uv sync --group prod
C'est parce que ces dépendances compilent certains modules C'est parce que ces dépendances compilent certains modules
à l'installation. à l'installation.
## Désactiver Honcho
Honcho est utilisé en développement pour simplifier la gestion
des services externes (redis, vite et autres futures).
En mode production, il est nécessaire de le désactiver puisque normalement
tous ces services sont déjà configurés.
Pour désactiver Honcho il suffit de ne sélectionner aucun `PROCFILE_` dans la config.
```dotenv
PROCFILE_STATIC=
PROCFILE_SERVICE=
```
!!! note
Si `PROCFILE_STATIC` est désactivé, la recompilation automatique
des fichiers statiques ne se fait plus.
Si vous en avez besoin et que vous travaillez sans `PROCFILE_STATIC`,
vous devez ouvrir une autre fenêtre de votre terminal
et lancer la commande `npm run serve`
## Configurer Redis en service externe
Redis est installé comme dépendance mais pas lancé par défaut.
En mode développement, le sith se charge de le démarrer mais
pas en production !
Il faut donc lancer le service comme ceci:
```bash
sudo systemctl start redis
sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
```
Puis modifiez votre `.env` pour y configurer le bon port redis.
Le port du fichier d'exemple est un port non standard pour éviter
les conflits avec les instances de redis déjà en fonctionnement.
```dotenv
REDIS_PORT=6379
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
```
Si on souhaite configurer redis pour communiquer via un socket :
```dovenv
CACHE_URL=redis:///path/to/redis-server.sock
```
## Configurer PostgreSQL ## Configurer PostgreSQL
PostgreSQL est utilisé comme base de données. PostgreSQL est utilisé comme base de données.
@ -264,7 +212,7 @@ Puis lancez ou relancez nginx :
sudo systemctl restart nginx sudo systemctl restart nginx
``` ```
Dans votre `.env`, remplacez `SITH_DEBUG=true` par `SITH_DEBUG=false`. Dans votre `.env`, remplacez `DEBUG=true` par `DEBUG=false`.
Enfin, démarrez le serveur Django : Enfin, démarrez le serveur Django :
@ -276,7 +224,7 @@ uv run ./manage.py runserver 8001
Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur. Et c'est bon, votre reverse-proxy est prêt à tourner devant votre serveur.
Nginx écoutera sur le port 8000. Nginx écoutera sur le port 8000.
Toutes les requêtes vers des fichiers statiques et les medias publiques Toutes les requêtes vers des fichiers statiques et les medias publiques
seront servies directement par nginx. seront seront servies directement par nginx.
Toutes les autres requêtes seront transmises au serveur django. Toutes les autres requêtes seront transmises au serveur django.
@ -290,64 +238,3 @@ un cron pour la mettre à jour au moins une fois par jour.
```bash ```bash
python manage.py update_spam_database python manage.py update_spam_database
``` ```
## Personnaliser l'environnement
Le site utilise beaucoup de variables configurables via l'environnement.
Cependant, pour des raisons de maintenabilité et de simplicité
pour les nouveaux développeurs, nous n'avons mis dans le fichier
`.env.example` que celles qui peuvent nécessiter d'être fréquemment modifiées
(par exemple, l'url de connexion à la db, ou l'activation du mode debug).
Cependant, il en existe beaucoup d'autres, que vous pouvez trouver
dans le `settings.py` en recherchant `env.`
(avec `grep` ou avec un ++ctrl+f++ dans votre éditeur).
Si le besoin de les modifier se présente, c'est chose possible.
Il suffit de rajouter la paire clef-valeur correspondante dans le `.env`.
!!!tip
Si vous utilisez nushell,
vous pouvez automatiser le processus avec
avec le script suivant, qui va parser le `settings.py`
pour récupérer toutes les variables d'environnement qui ne sont pas
définies dans le .env puis va les rajouter :
```nu
# si le fichier .env n'existe pas, on le crée
if not (".env" | path exists) {
cp .env.example .env
}
# puis on récupère les variables d'environnement déjà existantes
let existing = open .env
# on récupère toutes les variables d'environnement utilisées
# dans le settings.py qui ne sont pas encore définies dans le .env,
# on les convertit dans un format .env,
# puis on les ajoute à la fin du .env
let regex = '(env\.)(?<method>\w+)\(\s*"(?<env_name>\w+)"(\s*(, default=)(?<value>.+))?\s*\)';
let content = open sith/settings.py;
let vars = $content
| parse --regex $regex
| filter { |i| $i.env_name not-in $existing }
| each { |i|
let parsed_value = match [$i.method, $i.value] {
["str", "None"] => ""
["bool", $val] => ($val | str downcase)
["list", $val] => ($val | str trim -c '[' | str trim -c ']')
["path", $val] => ($val | str replace 'BASE_DIR / "' $'"(pwd)/')
[_, $val] => $val
}
$"($i.env_name)=($parsed_value)"
}
if ($vars | is-not-empty) {
# on ajoute les nouvelles valeurs,
# en mettant une ligne vide de séparation avec les anciennes
["", ...$vars] | save --append .env
}
print $"($vars | length) values added to .env"
```

View File

@ -100,6 +100,23 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
Python ne fait pas parti des dépendances puisqu'il est automatiquement Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. installé par uv.
Parmi les dépendances installées se trouve redis (que nous utilisons comme cache).
Redis est un service qui doit être activé pour être utilisé.
Pour cela, effectuez les commandes :
```bash
sudo systemctl start redis
sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
```
Parmi les dépendances installées se trouve redis (que nous utilisons comme cache).
Redis est un service qui doit être activé pour être utilisé.
Pour cela, effectuez les commandes :
```bash
sudo systemctl start redis
sudo systemctl enable redis # si vous voulez que redis démarre automatiquement au boot
```
## Finaliser l'installation ## Finaliser l'installation
@ -171,11 +188,6 @@ uv run ./manage.py runserver
[http://localhost:8000](http://localhost:8000) [http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/). ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
!!!note
Le serveur de développement se charge de lancer redis
et les autres services nécessaires au bon fonctionnement du site.
!!!tip !!!tip
Vous trouverez également, à l'adresse Vous trouverez également, à l'adresse

View File

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

View File

@ -66,24 +66,20 @@ sith/
│ └── ... │ └── ...
├── staticfiles/ (23) ├── staticfiles/ (23)
│ └── ... │ └── ...
├── processes/ (24)
│ └── ...
├── .coveragerc (25) ├── .coveragerc (24)
├── .envrc (26) ├── .envrc (25)
├── .gitattributes ├── .gitattributes
├── .gitignore ├── .gitignore
├── .mailmap ├── .mailmap
├── .env (27) ├── .env (26)
├── .env.example (28) ├── .env.example (27)
├── manage.py (29) ├── manage.py (28)
├── mkdocs.yml (30) ├── mkdocs.yml (29)
├── uv.lock ├── uv.lock
├── pyproject.toml (31) ├── pyproject.toml (30)
├── .venv/ (32) ├── .venv/ (31)
├── .python-version (33) ├── .python-version (32)
├── Procfile.static (34)
├── Procfile.service (35)
└── README.md └── README.md
``` ```
</div> </div>
@ -125,27 +121,22 @@ sith/
23. Gestion des statics du site. Override le système de statics de Django. 23. Gestion des statics du site. Override le système de statics de Django.
Ajoute l'intégration du scss et du bundler js Ajoute l'intégration du scss et du bundler js
de manière transparente pour l'utilisateur. de manière transparente pour l'utilisateur.
24. Module de gestion des services externes. 24. Fichier de configuration de coverage.
Offre une API simple pour utiliser les fichiers `Procfile.*`. 25. Fichier de configuration de direnv.
25. Fichier de configuration de coverage. 26. Contient les variables d'environnement, qui sont susceptibles
26. Fichier de configuration de direnv.
27. Contient les variables d'environnement, qui sont susceptibles
de varier d'une machine à l'autre. de varier d'une machine à l'autre.
28. Contient des valeurs par défaut pour le `.env` 27. Contient des valeurs par défaut pour le `.env`
pouvant convenir à un environnment de développement local pouvant convenir à un environnment de développement local
29. Fichier généré automatiquement par Django. C'est lui 28. Fichier généré automatiquement par Django. C'est lui
qui permet d'appeler des commandes de gestion du projet qui permet d'appeler des commandes de gestion du projet
avec la syntaxe `python ./manage.py <nom de la commande>` avec la syntaxe `python ./manage.py <nom de la commande>`
30. Le fichier de configuration de la documentation, 29. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières. avec ses plugins et sa table des matières.
31. Le fichier où sont déclarés les dépendances et la configuration 30. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles. de certaines d'entre elles.
32. Dossier d'environnement virtuel généré par uv 31. Dossier d'environnement virtuel généré par uv
33. Fichier qui contrôle quelle version de python utiliser pour le projet 32. Fichier qui contrôle quelle version de python utiliser pour le projet
34. Fichier qui contrôle les commandes à lancer pour gérer la compilation
automatique des static et autres services nécessaires à la command runserver.
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
du Sith tel que redis.
## L'application principale ## L'application principale
@ -229,4 +220,4 @@ comme suit :
L'organisation peut éventuellement être un peu différente L'organisation peut éventuellement être un peu différente
pour certaines applications, mais le principe pour certaines applications, mais le principe
général est le même. général est le même.

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More