1 Commits

Author SHA1 Message Date
dependabot[bot]
3e258f5940 [UPDATE] Update redis[hiredis] requirement from <7,>=5.3.0 to >=5.3.0,<8
Updates the requirements on [redis[hiredis]](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v5.3.0...v7.0.0)

---
updated-dependencies:
- dependency-name: redis[hiredis]
  dependency-version: 7.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 18:17:03 +00:00
92 changed files with 1482 additions and 1936 deletions

View File

@@ -6,8 +6,6 @@ from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:

View File

@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher):
"""
An API key hasher using the sha512 algorithm.
An API key hasher using the sha256 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing

View File

@@ -1,48 +0,0 @@
import pytest
from django.test import Client
from django.urls import path
from model_bakery import baker
from ninja import NinjaAPI
from ninja.security import SessionAuth
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
api = NinjaAPI()
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
def post_method(*args, **kwargs) -> None:
"""Dummy POST route authenticated by either api key or session cookie."""
pass
urlpatterns = [path("", api.urls)]
@pytest.mark.django_db
@pytest.mark.urls(__name__)
@pytest.mark.parametrize("user_logged_in", [False, True])
def test_csrf_token(user_logged_in):
"""Test that CSRF check happens only when no api key is used."""
client = Client(enforce_csrf_checks=True)
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
if user_logged_in:
client.force_login(api_client.owner)
response = client.post("")
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"
# if using a valid API key, CSRF check should not occur
response = client.post("", headers={"X-APIKey": key})
assert response.status_code == 200
# if using a wrong API key, ApiKeyAuth should fail,
# leading to a fallback into SessionAuth and a CSRF check
response = client.post("", headers={"X-APIKey": generate_key()[0]})
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"

View File

@@ -1,4 +1,3 @@
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI(
@@ -6,6 +5,6 @@ api = NinjaExtraAPI(
description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0",
urls_namespace="api",
auth=[SessionAuth()],
csrf=True,
)
api.auto_discover_controllers()

View File

@@ -16,7 +16,7 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@@ -27,7 +27,7 @@ class ClubController(ControllerBase):
@route.get(
"/{int:club_id}",
response=ClubSchema,
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[HasPerm("club.view_club")],
url_name="fetch_club",
)

View File

@@ -37,7 +37,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubEditForm(forms.ModelForm):
@@ -192,18 +191,6 @@ class SellingsForm(forms.Form):
required=False,
)
def to_filter_schema(self) -> SaleFilterSchema:
products = (
*self.cleaned_data["products"],
*self.cleaned_data["archived_products"],
)
return SaleFilterSchema(
after=self.cleaned_data["begin_date"],
before=self.cleaned_data["end_date"],
counters={c.id for c in self.cleaned_data["counters"]} or None,
products={p.id for p in products} or None,
)
class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField(

View File

@@ -9,18 +9,6 @@
{{ club.short_description }}
{%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ club.name }}" />
<meta property="og:description" content="{{ club.short_description }}" />
{% if club.logo %}
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
{% else %}
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endif %}
{% endblock %}
{% block content %}
<div id="club_detail">
{% if club.logo %}
@@ -29,7 +17,7 @@
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
<h3>{% trans %}Club{% endtrans %}</h3>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %}
{% from 'core/page/macros.jinja' import page_history %}
{% from 'core/macros_pages.jinja' import page_history %}
{% block content %}
{{ page_history(club.page) }}
{% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{% endblock %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block content %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
{% endblock %}

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import Client, TestCase
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
@@ -532,35 +532,6 @@ class TestMembership(TestClub):
assert new_board == initial_board
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
membership.refresh_from_db()
assert membership.end_date == localdate()
@pytest.mark.django_db
def test_membership_delete(client: Client):
user = baker.make(User, is_superuser=True)
membership = baker.make(Membership)
client.force_login(user)
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from pytest_django.asserts import assertHTMLEqual
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from club.models import Club
from core.markdown import markdown
from core.models import PageRev, User
@@ -17,6 +16,7 @@ def test_page_display_on_club_main_page(client: Client):
club = baker.make(Club)
content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content)
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
@@ -30,42 +30,10 @@ def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty"""
club = baker.make(Club)
club.page.revisions.all().delete()
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == []
@pytest.mark.django_db
def test_page_revision(client: Client):
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
client.force_login(baker.make(User))
url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
res = client.get(url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
@pytest.mark.django_db
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": content})
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
assert club.page.revisions.last().content == content

View File

@@ -1,6 +1,3 @@
import csv
import itertools
import pytest
from django.test import Client
from django.urls import reverse
@@ -10,20 +7,16 @@ from club.forms import SellingsForm
from club.models import Club
from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling
from counter.models import Counter, Customer
@pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client):
"""Basic crashtest on club sales view."""
club = baker.make(Club)
product = baker.make(Product, club=club)
admin = baker.make(User, is_superuser=True)
client.force_login(admin)
url = reverse("club:club_sellings", kwargs={"club_id": club.id})
assert client.get(url).status_code == 200
assert client.post(url).status_code == 200
assert client.post(url, data={"products": [product.id]}).status_code == 200
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
assert response.status_code == 200
@pytest.mark.django_db
@@ -43,62 +36,3 @@ def test_sales_form_counter_filter():
form = SellingsForm(club)
form_counters = list(form.fields["counters"].queryset)
assert form_counters == [counters[1], counters[2], counters[0]]
@pytest.mark.django_db
def test_club_sales_csv(client: Client):
client.force_login(baker.make(User, is_superuser=True))
club = baker.make(Club)
counter = baker.make(Counter, club=club)
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
sales: list[Selling] = sale_recipe.make(
club=club,
counter=counter,
quantity=2,
unit_price=1.5,
product=iter([product, product, None]),
customer=itertools.cycle(customers),
_quantity=3,
)
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
response = client.post(url, data={"counters": [counter.id]})
assert response.status_code == 200
reader = csv.reader(s.decode() for s in response.streaming_content)
data = list(reader)
sale_rows = [
[
str(s.date),
str(counter),
str(s.seller),
s.customer.user.get_display_name(),
s.label,
"2",
"1.50",
"3.00",
"Compte utilisateur",
]
for s in sales[::-1]
]
sale_rows[2].extend(["0.50", "1.00"])
sale_rows[1].extend(["0.50", "1.00"])
sale_rows[0].extend(["", ""])
assert data == [
["Quantité", "6"],
["Total", "9"],
["Bénéfice", "1"],
[
"Date",
"Comptoir",
"Barman",
"Client",
"Étiquette",
"Quantité",
"Prix unitaire",
"Total",
"Méthode de paiement",
"Prix d'achat",
"Bénéfice",
],
*sale_rows,
]

View File

@@ -22,28 +22,25 @@
#
#
from __future__ import annotations
import csv
import itertools
from typing import TYPE_CHECKING, Any
from typing import Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.http import Http404, StreamingHttpResponse
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
@@ -64,14 +61,11 @@ from com.views import (
PosterListBaseView,
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self):
@@ -81,8 +75,6 @@ class ClubTabsMixin(TabedViewMixin):
self.object = self.object.page.club
elif isinstance(self.object, Poster):
self.object = self.object.club
elif hasattr(self, "club"):
self.object = self.club
return self.object.get_display_name()
def get_list_of_tabs(self):
@@ -210,7 +202,7 @@ class ClubView(ClubTabsMixin, DetailView):
return kwargs
class ClubRevView(LoginRequiredMixin, ClubView):
class ClubRevView(ClubView):
"""Display a specific page revision."""
def dispatch(self, request, *args, **kwargs):
@@ -224,26 +216,26 @@ class ClubRevView(LoginRequiredMixin, ClubView):
return kwargs
class ClubPageEditView(ClubTabsMixin, BasePageEditView):
class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
template_name = "club/pagerev_edit.jinja"
current_tab = "page_edit"
@cached_property
def club(self):
return get_object_or_404(Club, pk=self.kwargs["club_id"])
def dispatch(self, request, *args, **kwargs):
self.club = get_object_or_404(Club, pk=kwargs["club_id"])
if not self.club.page:
raise Http404
return super().dispatch(request, *args, **kwargs)
@cached_property
def page(self) -> Page:
page = self.club.page
page.set_lock(self.request.user)
return page
def get_object(self):
self.page = self.club.page
return self._get_revision()
def get_success_url(self, **kwargs):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification history of the page."""
"""Modification hostory of the page."""
model = Club
pk_url_kwarg = "club_id"
@@ -407,14 +399,33 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
kwargs = super().get_context_data(**kwargs)
kwargs["result"] = Selling.objects.none()
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0
kwargs["total_quantity"] = 0
kwargs["benefit"] = 0
form: SellingsForm = self.get_form()
if form.is_valid() and any(v for v in form.cleaned_data.values()):
filters = form.to_filter_schema()
qs = filters.filter(Selling.objects.filter(club=self.object))
form = self.get_form()
if form.is_valid():
qs = Selling.objects.filter(club=self.object)
if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
qs = qs.filter(date__lte=form.cleaned_data["end_date"])
if form.cleaned_data["counters"]:
qs = qs.filter(counter__in=form.cleaned_data["counters"])
selected_products = []
if form.cleaned_data["products"]:
selected_products.extend(form.cleaned_data["products"])
if form.cleaned_data["archived_products"]:
selected_products.extend(form.cleaned_data["archived_products"])
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"]
@@ -461,15 +472,15 @@ class ClubSellingCSVView(ClubSellingView):
*row,
selling.label,
selling.quantity,
selling.unit_price,
selling.quantity * selling.unit_price,
selling.get_payment_method_display(),
]
if selling.product:
row.append(selling.product.selling_price)
row.append(selling.product.purchase_price)
row.append(selling.unit_price - selling.product.purchase_price)
row.append(selling.product.selling_price - selling.product.purchase_price)
else:
row = [*row, "", ""]
row = [*row, "", "", ""]
return row
def get(self, request, *args, **kwargs):
@@ -490,9 +501,9 @@ class ClubSellingCSVView(ClubSellingView):
gettext("Customer"),
gettext("Label"),
gettext("Quantity"),
gettext("Unit price"),
gettext("Total"),
gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"),
gettext("Benefit"),
],
@@ -545,17 +556,33 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old."""
class MembershipSetOldView(CanEditMixin, DetailView):
"""Set a membership as beeing old."""
model = Membership
pk_url_kwarg = "membership_id"
def post(self, *_args, **_kwargs):
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.end_date = timezone.now()
self.object.save()
return redirect("core:user_clubs", user_id=self.object.user_id)
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -567,7 +594,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership"
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})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):

View File

@@ -5,6 +5,7 @@ from django.utils.cache import add_never_cache_headers
from ninja import Query
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 api.permissions import HasPerm
@@ -16,13 +17,17 @@ from core.views.files import send_raw_file
@api_controller("/calendar")
class CalendarController(ControllerBase):
@route.get("/internal.ics", auth=None, url_name="calendar_internal")
@route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response)
return response
@route.get("/unpublished.ics", url_name="calendar_unpublished")
@route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self):
response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user),
@@ -69,7 +74,6 @@ class NewsController(ControllerBase):
@route.get(
"/date",
auth=None,
url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema],
)

View File

@@ -1,20 +1,15 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, link_news_logo %}
{% 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 %}
{% trans %}News{% endtrans %} - {{ object.title }}
{% trans %}News{% endtrans %} -
{{ object.title }}
{% endblock %}
{% block description %}{{ news.summary }}{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
{% block head %}
{{ super() }}
{{ gen_news_metatags(news) }}
{% endblock %}
@@ -49,14 +44,8 @@
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
<div>{{ news.content|markdown }}</div>
<a
rel="nofollow"
target="#"
class="share_button facebook"
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
>
{% trans %}Share on Facebook{% endtrans %}
</a>
{{ facebook_share(news) }}
{{ tweet(news) }}
<div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %}

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
@@ -17,6 +18,16 @@ from core.markdown import markdown
from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

@@ -240,11 +240,10 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"):
return []
return itertools.groupby(
User.objects.viewable_by(self.request.user)
.filter(
User.objects.filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
is_viewable=True,
is_subscriber_viewable=True,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
@@ -701,7 +700,7 @@ class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
parsed = urlparse(referer)
if parsed.netloc == settings.SITH_URL:
return redirect(parsed.path)
return redirect("com:poster_list")
return redirect(reverse("com:poster_list"))
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):

View File

@@ -74,19 +74,9 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page)

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Any, Literal
from annotated_types import Ge, Le, MinLen
import annotated_types
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
@@ -28,7 +28,6 @@ from core.schemas import (
UserSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown")
@@ -73,9 +72,9 @@ class MailingListController(ControllerBase):
@api_controller("/user")
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
@@ -86,18 +85,13 @@ class UserController(ControllerBase):
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
# logged in barmen aren't authenticated stricto sensu, so no auth here
auth=None,
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
qs = User.objects
# the logged in barmen can see all users (even the hidden one),
# because they have a temporary administrative function during
# which they may have to deal with hidden users
if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True))
)
@api_controller("/file")
@@ -105,11 +99,11 @@ class SithFileController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SithFileSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]):
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -118,15 +112,15 @@ class GroupController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[GroupSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, Ge(0), Le(10)]
DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4

View File

@@ -24,6 +24,7 @@
from __future__ import annotations
import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
@@ -146,6 +147,45 @@ class GenericContentPermissionMixinBuilder(View):
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):
if not request.user.is_authenticated:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
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.

View File

@@ -0,0 +1,40 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# 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 django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively check the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._check_fs()

View File

@@ -150,8 +150,7 @@ class Command(BaseCommand):
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)
skia = User.objects.create_user(

View File

@@ -0,0 +1,41 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# 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 django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively repair the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._repair_fs()

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]

View File

@@ -23,13 +23,14 @@
#
from __future__ import annotations
import difflib
import logging
import os
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Final, Self
from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4
from django.conf import settings
@@ -55,8 +56,6 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image, ImageOps
from core.utils import get_last_promo
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt
@@ -89,14 +88,57 @@ class Group(AuthGroup):
def validate_promo(value: int) -> None:
last_promo = get_last_promo()
if not 0 < value <= last_promo:
start_year = settings.SITH_SCHOOL_START_YEAR
delta = (localdate() + timedelta(days=180)).year - start_year
if value < 0 or delta < value:
raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
params={"value": value, "end": last_promo},
params={"value": value, "end": delta},
)
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
# Using None as a cache value is a little bit tricky,
# so we use a special string to represent None
return None
elif group is not None:
return group
# if this point is reached, the group is not in cache
if pk is not None:
group = Group.objects.filter(pk=pk).first()
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
@@ -138,15 +180,6 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
)
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -282,24 +315,13 @@ class User(AbstractUser):
parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default=""
)
is_viewable = models.BooleanField(
_("Profile visible by subscribers"),
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
is_subscriber_viewable = models.BooleanField(
_("is subscriber viewable"), default=True
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self):
return self.get_display_name()
@@ -360,18 +382,19 @@ class User(AbstractUser):
Returns:
True if the user is the group, else False
"""
if not pk and not name:
if pk is not None:
group: Optional[Group] = get_group(pk=pk)
elif name is not None:
group: Optional[Group] = get_group(name=name)
else:
raise ValueError("You must either provide the id or the name of the group")
group_id: int | None = (
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
)
if group_id is None:
if group is None:
return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
return group in self.cached_groups
@cached_property
def cached_groups(self) -> list[Group]:
@@ -431,6 +454,14 @@ class User(AbstractUser):
else:
raise ValidationError(_("A user with that username already exists"))
def get_profile(self):
return {
"last_name": self.last_name,
"first_name": self.first_name,
"nick_name": self.nick_name,
"date_of_birth": self.date_of_birth,
}
def get_short_name(self):
"""Returns the short name for the user."""
if self.nick_name:
@@ -573,12 +604,8 @@ class User(AbstractUser):
def can_be_edited_by(self, user):
return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool:
return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def can_be_viewed_by(self, user):
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
def get_mini_item(self):
return """
@@ -662,8 +689,8 @@ class AnonymousUser(AuthAnonymousUser):
if pk is not None:
return pk == allowed_id
elif name is not None:
group = Group.objects.get(id=allowed_id)
return group.name == name
group = get_group(name=name)
return group is not None and group.id == allowed_id
else:
raise ValueError("You must either provide the id or the name of the group")
@@ -989,6 +1016,63 @@ class SithFile(models.Model):
self.clean()
self.save()
def _repair_fs(self):
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
if self.is_folder:
for c in self.children.all():
c._repair_fs()
return
elif not self._check_path_consistence():
# First get future parent path and the old file name
# Prepend "." so that we match all relative handling of Django's
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
settings.MEDIA_ROOT + old_path,
settings.MEDIA_ROOT + new_path,
)
# Empty directories may remain, but that's not really a
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
return True
def _check_fs(self):
if self.is_folder:
for c in self.children.all():
c._check_fs()
return
else:
self._check_path_consistence()
@property
def is_file(self):
return not self.is_folder
@@ -1345,9 +1429,6 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content .
"""
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True)
@@ -1389,32 +1470,6 @@ class PageRev(models.Model):
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
The result is a float in [0; 1], 0 meaning the contents are entirely different,
and 1 they are strictly the same.
"""
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
def should_merge(self, other: Self) -> bool:
"""Return True if `other` should be merged into `self`, else False.
It's considered the other revision should be merged into this one if :
- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%
"""
return (
not self._state.adding # cannot merge if the original rev doesn't exist
and self.author == other.author
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
and (not other._state.adding or other.revision == self.revision + 1)
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
)
def get_notification_types():
return settings.SITH_NOTIFICATIONS

View File

@@ -1,9 +1,8 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin([sort, limitedChoices]);
Alpine.plugin(sort);
Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine;

View File

@@ -1,69 +0,0 @@
import type { Alpine as AlpineType } from "alpinejs";
export function limitedChoices(Alpine: AlpineType) {
/**
* Directive to limit the number of elements
* that can be selected in a group of checkboxes.
*
* When the max numbers of selectable elements is reached,
* new elements will still be inserted, but oldest ones will be deselected.
* For example, if checkboxes A, B and C have been selected and the max
* number of selections is 3, then selecting D will result in having
* B, C and D selected.
*
* # Example in template
* ```html
* <div x-data="{nbMax: 2}", x-limited-choices="nbMax">
* <button @click="nbMax += 1">Click me to increase the limit</button>
* <input type="checkbox" value="A" name="foo">
* <input type="checkbox" value="B" name="foo">
* <input type="checkbox" value="C" name="foo">
* <input type="checkbox" value="D" name="foo">
* </div>
* ```
*/
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
);
const checked = [] as HTMLInputElement[];
const manageDequeue = () => {
if (checked.length <= maxChoices) {
// There isn't too many checkboxes selected. Nothing to do
return;
}
const popped = checked.splice(0, checked.length - maxChoices);
for (const p of popped) {
p.checked = false;
}
};
for (const input of inputs) {
input.addEventListener("change", (_e) => {
if (input.checked) {
checked.push(input);
} else {
checked.splice(checked.indexOf(input), 1);
}
manageDequeue();
});
}
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed
manageDequeue();
}
});
});
},
);
}

View File

@@ -21,8 +21,6 @@ $secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);

View File

@@ -745,32 +745,4 @@ form {
background-repeat: no-repeat;
background-size: var(--nf-input-size);
}
&.no-margin {
margin:0;
}
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
margin: auto;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}

View File

@@ -5,6 +5,9 @@ $text-color: white;
$background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header {
box-sizing: border-box;
background-color: $deepblue;
@@ -248,15 +251,12 @@ $background-color-hovered: #283747;
justify-content: flex-start;
}
a {
color: $text-color;
}
a,
button {
font-size: 100%;
margin: 0;
text-align: right;
color: $text-color;
margin-top: auto;
&:hover {
@@ -268,6 +268,19 @@ $background-color-hovered: #283747;
margin: 0;
display: inline;
}
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
}
}

View File

@@ -519,6 +519,7 @@ th {
td {
margin: 5px;
border-collapse: collapse;
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -7,13 +7,10 @@
.profile {
&-visible {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures {
@@ -119,19 +116,23 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
gap: 10px;
justify-content: center;
}
&-field {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
width: 100%;
max-width: 330px;
min-width: 300px;
@media (max-width: 750px) {
gap: 4px;
max-width: 100%;
}
@@ -144,6 +145,22 @@
}
}
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea {
height: 7rem;
}

View File

@@ -4,22 +4,12 @@
{% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta
name="description"
content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<meta name="description" content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@@ -61,9 +61,7 @@
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %}
<button type="submit" class="link-like link-red">
{% trans %}Logout{% endtrans %}
</button>
<button type="submit">{% trans %}Logout{% endtrans %}</button>
</form>
</div>
</div>

View File

@@ -21,6 +21,20 @@
{% else %}
<h2>{% trans %}Save{% endtrans %}</h2>
{% 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">
{% csrf_token %}
{{ form.as_p() }}

View File

@@ -13,11 +13,30 @@
{%- endmacro %}
{% macro link_news_logo(news) -%}
{%- if news.club.logo -%}
{% if news.club.logo -%}
{{ news.club.logo.url }}
{%- else -%}
{% else -%}
{{ static("com/img/news.png") }}
{%- endif -%}
{% endif %}
{%- endmacro %}
{% macro gen_news_metatags(news) -%}
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
{%- endmacro %}
{% macro facebook_share(news) -%}
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
{%- endmacro %}
{% macro tweet(news) -%}
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %}
{% macro user_mini_profile(user) %}

View File

@@ -17,3 +17,12 @@
{%- endfor -%}
</ul>
{% endmacro %}
{% macro page_edit_form(page, form, url, token) %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url }}" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ token }}">
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endmacro %}

View File

@@ -0,0 +1,52 @@
{% extends "core/base.jinja" %}
{% block title %}
{% if page %}
{{ page.get_display_name() }}
{% elif page_list %}
{% trans %}Page list{% endtrans %}
{% elif new_page %}
{% trans %}Create page{% endtrans %}
{% else %}
{% trans %}Not found{% endtrans %}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page %}
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
{% endif %}
</div>
</div>
<hr>
{% if page %}
{% block page %}
{% endblock %}
{% else %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p><a href="{{ url('core:page_new') }}?page={{ request.resolver_match.kwargs['page_name'] }}">
{% trans %}Create it?{% endtrans %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{{ page.get_display_name() }}
{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
</div>
</div>
<hr>
{% block page %}
{% endblock %}
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
{% if revision and revision.id != last_revision.id %}
<h4>
{% trans trimmed rev_id=revision.revision %}
This may not be the last update, you are seeing revision {{ rev_id }}!
{% endtrans %}
</h4>
{% endif %}
{% set current_revision = revision or last_revision %}
<h3>{{ current_revision.title }}</h3>
<div class="page_content">{{ current_revision.content|markdown }}</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('core:page_edit', page_name=page.get_full_name()) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p>
{# This template is rendered when a PageNotFound error is raised,
so the `exception` context variable should always have a page_name attribute #}
<a href="{{ url('core:page_new') }}?page={{ exception.page_name }}">
{% trans %}Create it?{% endtrans %}
</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "core/page.jinja" %}
{% block page %}
{% if rev %}
<h4>{% trans rev_id=rev.revision %}This may not be the last update, you are seeing revision {{ rev_id }}!{% endtrans %}</h4>
<h3>{{ rev.title }}</h3>
<div class="page_content">{{ rev.content|markdown }}</div>
{% else %}
{% if page.revisions.last() %}
<h3>{{ page.revisions.last().title }}</h3>
<div class="page_content">{{ page.revisions.last().content|markdown }}</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "core/page/base.jinja" %}
{% extends "core/page.jinja" %}
{% from "core/page/macros.jinja" import page_history %}
{% from "core/macros_pages.jinja" import page_history %}
{% block page %}
<h3>{% trans %}Page history{% endtrans %}</h3>

View File

@@ -1,13 +1,18 @@
{% extends "core/page/base.jinja" %}
{% extends "core/page.jinja" %}
{% block page %}
{% block content %}
{% if page %}
{{ super() }}
{% endif %}
<h2>{% trans %}Page properties{% endtrans %}</h2>
<form action="" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% if page %}
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "core/page.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block page %}
{{ page_edit_form(page, form, url('core:page_edit', page_name=page.get_full_name()), csrf_token) }}
{% endblock %}

View File

@@ -17,9 +17,7 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td>
<td></td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
<td></td>
</tr>
</thead>
<tbody>
@@ -30,16 +28,7 @@
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %}
<td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
<td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
{% endif %}
{% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -59,9 +48,7 @@
<td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr>
</thead>
<tbody>

View File

@@ -116,12 +116,12 @@
{# All fields #}
<div class="profile-fields">
{%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
{%- continue -%}
{%- endif -%}
<div class="profile-field">
{{ field.label_tag() }}
<div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content">
{{ field }}
{%- if field.errors -%}
@@ -136,7 +136,7 @@
<div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field">
{{ field.label_tag() }}
<div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content">
{{ field }}
{%- if field.errors -%}
@@ -149,13 +149,8 @@
{# Checkboxes #}
<div class="profile-visible">
<div class="row">
{{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
{{ form.is_subscriber_viewable }}
{{ form.is_subscriber_viewable.label }}
</div>
<div class="final-actions">

View File

@@ -23,7 +23,6 @@ from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
@@ -36,8 +35,8 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club, Membership
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment
from counter.models import Customer
from sith import settings
@@ -319,8 +318,9 @@ class TestPageHandling(TestCase):
def test_access_page_not_found(self):
"""Should not display a page correctly."""
response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"}))
assert response.status_code == 404
assert '<a href="/page/create/?page=swagg">' in response.text
assert response.status_code == 200
html = response.text
self.assertIn('<a href="/page/create/?page=swagg">', html)
def test_create_page_markdown_safe(self):
"""Should format the markdown and escape html correctly."""
@@ -421,16 +421,18 @@ class TestUserIsInGroup(TestCase):
# clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is in the group
with self.assertNumQueries(1):
with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is not in the group
with self.assertNumQueries(1):
with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id)
@@ -523,21 +525,6 @@ class TestDateUtils(TestCase):
assert get_start_of_semester() == autumn_2023
@pytest.mark.parametrize(
("current_date", "promo"),
[("2020-10-01", 22), ("2025-03-01", 26), ("2000-11-11", 2)],
)
def test_get_last_promo(current_date: str, promo: int):
with freezegun.freeze_time(current_date):
assert get_last_promo() == promo
@pytest.mark.parametrize("promo", [0, 24])
def test_promo_validator(promo: int):
with freezegun.freeze_time("2021-10-01"), pytest.raises(ValidationError):
validate_promo(promo)
def test_allow_fragment_mixin():
class TestAllowFragmentView(AllowFragment, ContextMixin, View):
def get(self, *args, **kwargs):

View File

@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id])
)
assert response.status_code == 401
assert response.status_code == 403
self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get(
@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403
def test_fetch_family_hidden_user(self):
self.main_user.is_viewable = False
self.main_user.is_subscriber_viewable = False
self.main_user.save()
for user_to_login, error_code in [
(self.main_user, 200),

View File

@@ -269,7 +269,7 @@ def test_apply_rights_recursively():
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
401,
403,
),
(
lambda: baker.make(User),

View File

@@ -1,122 +1,32 @@
from datetime import timedelta
import freezegun
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
from core.models import AnonymousUser, Page, User
from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db
class TestEditPage:
def test_edit_page(self, client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
page.edit_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(
res, reverse("core:page", kwargs={"page_name": page._full_name})
)
revision = page.revisions.last()
assert revision.content == "Hello World"
def test_pagerev_reused(self, client):
"""Test that the previous revision is edited, if same author and small time diff"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(
PageRev, author=user, page=page, date=now(), content="Hello World"
)
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
client.post(url, data={"content": "Hello World!"})
assert page.revisions.count() == 1
assert page.revisions.last() == first_rev
first_rev.refresh_from_db()
assert first_rev.author == user
assert first_rev.content == "Hello World!"
def test_pagerev_not_reused(self, client):
"""Test that a new revision is created if too much time
passed since the last one.
"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(PageRev, author=user, page=page, date=now())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
with freezegun.freeze_time(now() + timedelta(minutes=30)):
client.post(url, data={"content": "Hello World"})
assert page.revisions.count() == 2
assert page.revisions.last() != first_rev
@pytest.mark.django_db
def test_page_revision(client: Client):
"""Test the GET to request to a specific revision page."""
def test_edit_page(client: Client):
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(settings.SITH_GROUP_SUBSCRIBERS_ID)
revisions = baker.make(
PageRev, page=page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
client.force_login(subscriber_user.make())
url = reverse(
"core:page_rev",
kwargs={"page_name": page._full_name, "rev": revisions[1].id},
)
page.view_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
@pytest.mark.django_db
def test_page_club_redirection(client: Client):
club = baker.make(Club)
url = reverse("core:page", kwargs={"page_name": club.page._full_name})
res = client.get(url)
redirection_url = reverse("club:club_view", kwargs={"club_id": club.id})
assertRedirects(res, redirection_url)
@pytest.mark.django_db
def test_page_revision_club_redirection(client: Client):
client.force_login(subscriber_user.make())
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
url = reverse(
"core:page_rev",
kwargs={"page_name": club.page._full_name, "rev": revisions[1].id},
)
res = client.get(url)
redirection_url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
assertRedirects(res, redirection_url)
res = client.post(url, data={"content": "Hello World"})
assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
revision = page.revisions.last()
assert revision.content == "Hello World"
@pytest.mark.django_db
@@ -125,9 +35,9 @@ def test_viewable_by():
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, settings.SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID, settings.SITH_GROUP_OLD_SUBSCRIBERS_ID],
[settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID],
[SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[],
]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
@@ -146,11 +56,3 @@ def test_viewable_by():
)
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages}
@pytest.mark.django_db
def test_page_list_view(client: Client):
baker.make(Page, _quantity=10, _bulk_create=True)
client.force_login(subscriber_user.make())
res = client.get(reverse("core:page_list"))
assert res.status_code == 200

View File

@@ -1,10 +1,8 @@
from datetime import timedelta
from unittest import mock
import pytest
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
@@ -20,11 +18,10 @@ from core.baker_recipes import (
subscriber_user,
very_old_subscriber_user,
)
from core.models import AnonymousUser, Group, User
from core.models import Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
@@ -62,9 +59,7 @@ class TestSearchUsersAPI(TestSearchUsers):
"""Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make())
response = self.client.get(
reverse("api:search_users", query={"search": "First"})
)
response = self.client.get(reverse("api:search_users") + "?search=First")
assert response.status_code == 200
assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list
@@ -73,7 +68,7 @@ class TestSearchUsersAPI(TestSearchUsers):
]
def test_search_case_insensitive(self):
"""Test that the search is case-insensitive."""
"""Test that the search is case insensitive."""
self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]]
@@ -86,19 +81,14 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self):
"""Test that the search can be done on the nickname."""
# hidden users should not be in the final result,
# even when the nickname matches
self.users[10].is_viewable = False
self.users[10].save()
"""Test that the search can be done on the nick name."""
self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
response = self.client.get(reverse("api:search_users") + "?search=Nick1")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
@@ -110,25 +100,10 @@ class TestSearchUsersAPI(TestSearchUsers):
self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users", query={"search": "bél"}))
response = self.client.get(reverse("api:search_users") + "?search=bél")
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id]
@mock.create_autospec(is_logged_in_counter, return_value=True)
def test_search_as_barman(self):
# barmen should also see hidden users
self.users[10].is_viewable = False
self.users[10].save()
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`)."""
@@ -393,38 +368,3 @@ class TestRedirectMe:
def test_promo_has_logo(promo):
user = baker.make(User, promo=promo)
assert user.promo_has_logo()
@pytest.mark.django_db
class TestUserQuerySetViewableBy:
@pytest.fixture
def users(self) -> list[User]:
return [
baker.make(User),
subscriber_user.make(),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users)
@pytest.mark.parametrize(
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()

View File

@@ -112,16 +112,6 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:]
def get_last_promo() -> int:
"""Get the latest promo at the time the function is called.
For example, if called in october 2022 return 24,
if called in march 2026 return 27, etc.
"""
start_year = settings.SITH_SCHOOL_START_YEAR
return (localdate() + timedelta(days=180)).year - start_year
def is_image(file: UploadedFile):
try:
im = PIL.Image.open(file.file)
@@ -196,7 +186,7 @@ def exif_auto_rotate(image):
def get_client_ip(request: HttpRequest) -> str | None:
headers = (
"X_FORWARDED_FOR", # Common header for proxies
"X_FORWARDED_FOR", # Common header for proixes
"FORWARDED", # Standard header defined by RFC 7239.
"REMOTE_ADDR", # Default IP Address (direct connection)
)

View File

@@ -21,10 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.http import (
Http404,
HttpRequest,
HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError,
)
from django.shortcuts import render
@@ -33,20 +33,17 @@ from django.views.generic.edit import FormView
from sentry_sdk import last_event_id
from core.views.forms import LoginForm
from core.views.page import PageNotFound
def forbidden(request: HttpRequest, exception):
def forbidden(request, exception):
context = {"next": request.path, "form": LoginForm()}
return HttpResponseForbidden(render(request, "core/403.jinja", context=context))
def not_found(request: HttpRequest, exception: Http404):
if isinstance(exception, PageNotFound):
template_name = "core/page/not_found.jinja"
else:
template_name = "core/404.jinja"
return render(request, template_name, context={"exception": exception}, status=404)
def not_found(request, exception):
return HttpResponseNotFound(
render(request, "core/404.jinja", context={"exception": exception})
)
def internal_servor_error(request):

View File

@@ -21,7 +21,6 @@
#
#
import re
from copy import copy
from datetime import date, datetime
from io import BytesIO
@@ -43,12 +42,13 @@ from django.forms import (
Widget,
)
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, SithFile, User
from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
@@ -56,7 +56,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
# Widgets
@@ -87,6 +86,30 @@ class NFCTextInput(TextInput):
return context
class SelectUser(TextInput):
def render(self, name, value, attrs=None, renderer=None):
if attrs:
attrs["class"] = "select_user"
else:
attrs = {"class": "select_user"}
output = (
'%(content)s<div name="%(name)s" class="choose_user_widget" title="%(title)s"></div>'
% {
"content": super().render(name, value, attrs, renderer),
"title": _("Choose user"),
"name": name,
}
)
output += (
'<span name="'
+ name
+ '" class="choose_user_button">'
+ gettext("Choose user")
+ "</span>"
)
return output
# Fields
@@ -179,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school",
"promo",
"forum_signature",
"is_viewable",
"is_subscriber_viewable",
]
widgets = {
"date_of_birth": SelectDate,
@@ -188,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea,
}
def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile
@@ -381,42 +404,6 @@ class PageForm(forms.ModelForm):
)
class PageRevisionForm(forms.ModelForm):
"""Form to add a new revision to a page.
Notes:
Saving this form won't always result in a new revision.
If the previous revision on the same page was made :
- less than 20 minutes ago
- by the same author
- with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created.
"""
class Meta:
model = PageRev
fields = ["title", "content"]
widgets = {"content": MarkdownInput}
def __init__(
self, *args, author: User, page: Page, instance: PageRev | None = None, **kwargs
):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
self.page = page
self.initial_obj: PageRev = copy(self.instance)
def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance
if not self.initial_obj.should_merge(self.instance):
revision.author = self.author
revision.page = self.page
revision.id = None # if id is None, Django will create a new record
return super().save(commit=commit)
class GiftForm(forms.ModelForm):
class Meta:
model = Gift

View File

@@ -13,39 +13,39 @@
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditPropMixin, CanViewMixin
from core.models import Page, PageRev
from core.views.forms import PageForm, PagePropForm, PageRevisionForm
from core.auth.mixins import (
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev
from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput
class PageNotFound(Http404):
"""Http404 Exception, but specifically for when the not found object is a Page."""
def __init__(self, page_name: str):
self.page_name = page_name
def get_page_or_404(full_name: str) -> Page:
"""Like Django's get_object_or_404, but for Page, and with a custom 404 exception."""
page = Page.objects.filter(_full_name=full_name).first()
if not page:
raise PageNotFound(full_name)
return page
class CanEditPagePropMixin(CanEditPropMixin):
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object.is_club_page:
raise Http404
return res
class PageListView(ListView):
model = Page
template_name = "core/page/list.jinja"
template_name = "core/page_list.jinja"
def get_queryset(self):
return (
@@ -64,57 +64,80 @@ class PageListView(ListView):
)
class BasePageDetailView(CanViewMixin, DetailView):
class PageView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_detail.jinja"
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object and self.object.need_club_redirection:
return redirect("club:club_view", club_id=self.object.club.id)
return res
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "page" not in context:
context["new_page"] = self.kwargs["page_name"]
return context
class PageHistView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_hist.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
_cached_object: Page | None = None
def dispatch(self, request, *args, **kwargs):
page = self.get_object()
if page.need_club_redirection:
return redirect("club:club_view", club_id=page.club.id)
return redirect("club:club_hist", club_id=page.club.id)
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
if not self._cached_object:
full_name = self.kwargs.get(self.slug_url_kwarg)
self._cached_object = get_page_or_404(full_name)
self._cached_object = super().get_object()
return self._cached_object
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"last_revision": self.object.revisions.last()
}
class PageView(BasePageDetailView):
template_name = "core/page/detail.jinja"
class PageHistView(BasePageDetailView):
template_name = "core/page/history.jinja"
class PageRevView(BasePageDetailView):
template_name = "core/page/detail.jinja"
class PageRevView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_detail.jinja"
def dispatch(self, request, *args, **kwargs):
page = self.get_object()
if page.need_club_redirection:
res = super().dispatch(request, *args, **kwargs)
self.object = self.get_object()
if self.object is None:
return redirect("core:page_create", page_name=self.kwargs["page_name"])
if self.object.need_club_redirection:
return redirect(
"club:club_view_rev", club_id=page.club.id, rev_id=kwargs["rev"]
"club:club_view_rev", club_id=self.object.club.id, rev_id=kwargs["rev"]
)
self.revision = get_object_or_404(page.revisions, id=self.kwargs["rev"])
return super().dispatch(request, *args, **kwargs)
return res
def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"revision": self.revision}
context = super().get_context_data(**kwargs)
if not self.page:
return context | {"new_page": self.kwargs["page_name"]}
context["page"] = self.page
context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
return context
class PageCreateView(PermissionRequiredMixin, CreateView):
model = Page
form_class = PageForm
template_name = "core/create.jinja"
template_name = "core/page_prop.jinja"
permission_required = "core.add_page"
def get_initial(self):
@@ -129,67 +152,88 @@ class PageCreateView(PermissionRequiredMixin, CreateView):
init["name"] = page_name[-1]
return init
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["new_page"] = True
return context
def form_valid(self, form):
form.instance.set_lock(self.request.user)
ret = super().form_valid(form)
return ret
class CanEditPagePropMixin(CanEditPropMixin):
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object.is_club_page:
raise Http404
return res
class PagePropView(CanEditPagePropMixin, UpdateView):
model = Page
form_class = PagePropForm
template_name = "core/page/prop.jinja"
template_name = "core/page_prop.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
def get_object(self, queryset=None):
self.page = get_page_or_404(full_name=self.kwargs["page_name"])
self.page.set_lock_recursive(self.request.user)
self.page = super().get_object()
try:
self.page.set_lock_recursive(self.request.user)
except LockError as e:
raise e
return self.page
class BasePageEditView(UserPassesTestMixin, UpdateView):
class PageEditViewBase(CanEditMixin, UpdateView):
model = PageRev
form_class = PageRevisionForm
template_name = "core/page/edit.jinja"
def test_func(self):
return self.request.user.can_edit(self.page)
@cached_property
def page(self) -> Page:
page = get_page_or_404(full_name=self.kwargs["page_name"])
page.set_lock(self.request.user)
return page
form_class = modelform_factory(
model=PageRev, fields=["title", "content"], widgets={"content": MarkdownInput}
)
template_name = "core/pagerev_edit.jinja"
def get_object(self, *args, **kwargs):
return self.page.revisions.last()
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision()
def _get_revision(self):
if self.page is not None:
# First edit
if self.page.revisions.all() is None:
rev = PageRev(author=self.request.user)
rev.save()
self.page.revisions.add(rev)
try:
self.page.set_lock(self.request.user)
except LockError as e:
raise e
return self.page.revisions.last()
return None
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"page": self.page}
context = super().get_context_data(**kwargs)
if self.page is not None:
context["page"] = self.page
else:
context["new_page"] = self.kwargs["page_name"]
return context
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"author": self.request.user,
"page": self.page,
}
def form_valid(self, form):
# TODO : factor that, but first make some tests
rev = form.instance
new_rev = PageRev(title=rev.title, content=rev.content)
new_rev.author = self.request.user
new_rev.page = self.page
form.instance = new_rev
return super().form_valid(form)
class PageEditView(BasePageEditView):
class PageEditView(PageEditViewBase):
def dispatch(self, request, *args, **kwargs):
if self.page.need_club_redirection:
return redirect("club:club_edit_page", club_id=self.page.club.id)
return super().dispatch(request, *args, **kwargs)
res = super().dispatch(request, *args, **kwargs)
if self.object and self.object.page.need_club_redirection:
return redirect("club:club_edit_page", club_id=self.object.page.club.id)
return res
class PageDeleteView(CanEditPagePropMixin, DeleteView):
model = Page
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "page_id"
success_url = reverse_lazy("core:page_list")
def get_success_url(self, **kwargs):
return reverse_lazy("core:page_list")

View File

@@ -103,7 +103,9 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password."""
if not request.user.is_root:
raise PermissionDenied
user = get_object_or_404(User, id=user_id)
user = User.objects.filter(id=user_id).first()
if not user:
raise Http404("User not found")
if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid():

View File

@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleProductSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
def fetch_all(self):
return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move", url_name="reorder_product_type")
@route.patch("/{type_id}/move")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type.

View File

@@ -235,19 +235,6 @@ class ScheduledProductActionForm(forms.ModelForm):
)
return super().clean()
def set_product(self, product: Product):
"""Set the product to which this form's instance is linked.
When this form is linked to a ProductForm in the case of a product's creation,
the product doesn't exist yet, so saving this form as is will result
in having `{"product_id": null}` in the action kwargs.
For the creation to be useful, it may be needed to inject the newly created
product into this form, before saving the latter.
"""
self.product = product
kwargs = json.loads(self.instance.kwargs) | {"product_id": self.product.id}
self.instance.kwargs = json.dumps(kwargs)
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
@@ -334,19 +321,11 @@ class ProductForm(forms.ModelForm):
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
self.action_formset.save()
return product
return ret
class ReturnableProductForm(forms.ModelForm):
@@ -390,6 +369,7 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Annotated, Self
from annotated_types import MinLen
@@ -101,10 +100,3 @@ class ProductFilterSchema(FilterSchema):
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")
class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt")
after: datetime | None = Field(None, q="date__gt")
counters: set[int] | None = Field(None, q="counter__in")
products: set[int] | None = Field(None, q="product__in")

View File

@@ -11,12 +11,8 @@ from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import (
ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
@pytest.mark.django_db
@@ -38,39 +34,6 @@ def test_edit_product(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
def test_create_actions_alongside_product():
"""The form should work when the product and the actions are created alongside."""
# non-persisted instance
product: Product = product_recipe.prepare(_save_related=True)
trigger_at = now() + timedelta(minutes=10)
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
product = form.save()
action = ScheduledProductAction.objects.last()
assert action.clocked.clocked_time == trigger_at
assert action.enabled is True
assert action.one_off is True
assert action.task == "counter.tasks.archive_product"
assert action.kwargs == json.dumps({"product_id": product.id})
@pytest.mark.django_db
class TestProductActionForm:
def test_single_form_archive(self):

View File

@@ -3,9 +3,11 @@ from django.conf import settings
from django.test import Client
from django.urls import reverse
from model_bakery import baker, seq
from ninja_extra.testing import TestClient
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.api import ProductTypeController
from counter.models import ProductType
@@ -17,43 +19,24 @@ def product_types(db) -> list[ProductType]:
return baker.make(ProductType, _quantity=5, order=seq(0))
@pytest.fixture()
def counter_admin_client(db, client: Client) -> Client:
client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
)
return client
@pytest.mark.django_db
def test_fetch_product_types(
counter_admin_client: Client, product_types: list[ProductType]
):
def test_fetch_product_types(product_types: list[ProductType]):
"""Test that the API returns the right products in the right order"""
response = counter_admin_client.get(reverse("api:fetch_product_types"))
client = TestClient(ProductTypeController)
response = client.get("")
assert response.status_code == 200
assert [i["id"] for i in response.json()] == [t.id for t in product_types]
@pytest.mark.django_db
def test_move_below_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
def test_move_below_product_type(product_types: list[ProductType]):
"""Test that moving a product below another works"""
response = counter_admin_client.patch(
reverse(
"api:reorder_product_type",
kwargs={"type_id": product_types[-1].id},
query={"below": product_types[0].id},
),
client = TestClient(ProductTypeController)
response = client.patch(
f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
)
assert response.status_code == 200
new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
new_order = [i["id"] for i in client.get("").json()]
assert new_order == [
product_types[0].id,
product_types[-1].id,
@@ -62,22 +45,14 @@ def test_move_below_product_type(
@pytest.mark.django_db
def test_move_above_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
def test_move_above_product_type(product_types: list[ProductType]):
"""Test that moving a product above another works"""
response = counter_admin_client.patch(
reverse(
"api:reorder_product_type",
kwargs={"type_id": product_types[1].id},
query={"above": product_types[0].id},
),
client = TestClient(ProductTypeController)
response = client.patch(
f"/{product_types[1].id}/move", query={"above": product_types[0].id}
)
assert response.status_code == 200
new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
new_order = [i["id"] for i in client.get("").json()]
assert new_order == [
product_types[1].id,
product_types[0].id,

View File

@@ -182,19 +182,29 @@ ainsi même que de l'héritage de templates.
si on souhaite faire des modifications côté client,
il faut utiliser du Javascript, rien ne change à ce niveau-là.
### Typescript
### jQuery
[Site officiel](https://www.typescriptlang.org/)
[Site officiel](https://jquery.com/)
Pour rendre le site interactif, nous n'utilisons
pas directement Javascript, mais Typescript.
Il s'agit d'un langage construit par-dessus Javascript,
en ajoutant un typage statique et des éléments de sucre syntaxique.
Grâce au système de type, le code est plus lisible,
à la fois par les humains et par l'IDE, et plus fiable.
jQuery est une bibliothèque JavaScript
libre et multiplateforme créée pour faciliter
l'écriture de scripts côté client
dans le code HTML des pages web.
La première version est lancée en janvier 2006 par John Resig.
Il faut parfois se battre un peu contre le système de types de Typescript,
mais globalement Typescript est une alternative largement préférable à Javascript.
C'est une vieille technologie et certains
feront remarquer à juste titre que le Javascript
moderne permet d'utiliser assez simplement
la majorité de ce que fournit jQuery
sans rien avoir à installer.
Cependant, de nombreuses dépendances du projet
utilisent encore jQuery qui est toujours
très implanté aujourd'hui.
Le sucre syntaxique qu'offre cette librairie
reste très agréable à utiliser et économise
parfois beaucoup de temps.
Ça fonctionne et ça fonctionne très bien.
C'est maintenu et pratique.
### AlpineJS
@@ -260,6 +270,17 @@ sur tous les navigateurs contrairement
à un simple icône unicode qui s'affiche
lui différemment selon la plate-forme.
!!!note
C'est une dépendance capricieuse qui évolue très vite
et qu'il faut très souvent mettre à jour.
!!!warning
Il a été décidé de **ne pas utiliser**
de CDN puisque le site ralentissait régulièrement.
Il est préférable de fournir cette dépendance avec le site.
## Workflow
### Git

View File

@@ -1,4 +1,4 @@
L'ORM de Django est puissant, très puissant, non pas parce qu'il
L'ORM de Django est puissant, très puissant, non par parce qu'il
est performant (après tout, ce n'est qu'une interface, le gros du boulot,
c'est la db qui le fait), mais parce qu'il permet d'écrire
de manière relativement simple un grand panel de requêtes.

View File

@@ -51,7 +51,7 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en
Le bundler ne génère que des modules javascript.
Ajouter `type="module"` n'est pas optionnel !
### Les imports au sein des fichiers javascript bundlés
### Les imports au sein des fichiers des fichiers javascript bundlés
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.

View File

@@ -36,4 +36,11 @@ SITH_SUBSCRIPTIONS = {
}
```
Après ça, n'oubliez pas de gérer les traductions (cf. [ici](./translation.md))
Une fois ceci fait, il faut créer une nouvelle migration :
```bash
python ./manage.py makemigrations subscription
python ./manage.py migrate
```
N'oubliez pas non plus les traductions (cf. [ici](./translation.md))

View File

@@ -17,6 +17,7 @@
- can_edit_prop
- can_edit
- can_view
- CanCreateMixin
- CanEditMixin
- CanViewMixin
- CanEditPropMixin

View File

@@ -1,3 +1,4 @@
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
Ce sont des librairies relativement simples et qui présentent
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
@@ -48,9 +49,8 @@ Notre API offre deux moyens d'authentification :
- par clef d'API
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
Cette dernière est donc activée par défaut.
Pour changer la méthode d'authentification,
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
utilisez l'attribut `auth` et les classes `SessionAuth` et
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
@@ -60,17 +60,13 @@ utilisez l'attribut `auth` et les classes `SessionAuth` et
@api_controller("/foo")
class FooController(ControllerBase):
# Cette route sera accessible uniquement avec l'authentification
# par clef d'API
@route.get("", auth=[ApiKeyAuth()])
# par cookie de session
@route.get("", auth=[SessionAuth()])
def fetch_foo(self, club_id: int): ...
# Celle-ci sera accessible avec les deux méthodes d'authentification
@route.get("/bar", auth=[ApiKeyAuth(), SessionAuth()])
# Et celle-ci sera accessible peut importe la méthode d'authentification
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def fetch_bar(self, club_id: int): ...
# Et celle-ci sera accessible aussi aux utilisateurs non-connectés
@route.get("/public", auth=None)
def fetch_public(self, club_id: int): ...
```
### Permissions
@@ -83,7 +79,9 @@ par-dessus `django-ninja`, le système de permissions de django
et notre propre système.
Cette dernière est documentée [ici](../perms.md).
### Incompatibilité avec certaines permissions
### Limites des clefs d'API
#### Incompatibilité avec certaines permissions
Le système des clefs d'API est apparu très tard dans l'histoire du site
(en P25, 10 ans après le début du développement).
@@ -114,33 +112,10 @@ Les principaux points de friction sont :
- `IsLoggedInCounter`, qui utilise encore un autre système
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
### CSRF
#### Incompatibilité avec les tokens csrf
!!!info "A propos du csrf"
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un vecteur d'attaque sur le web consistant
à soumettre des données au serveur à l'insu
de l'utilisateur, en profitant de sa session.
C'est une attaque qui peut se produire lorsque l'utilisateur
est authentifié par cookie de session.
En effet, les cookies sont joints automatiquement à
toutes les requêtes ;
en l'absence de protection contre le CSRF,
un attaquant parvenant à insérer un formulaire
dans la page de l'utilisateur serait en mesure
de faire presque n'importe quoi en son nom,
et ce sans même que l'utilisateur ni les administrateurs
ne s'en rendent compte avant qu'il ne soit largement trop tard !
Sur le CSRF et les moyens de s'en prémunir, voir :
- [https://owasp.org/www-community/attacks/csrf]()
- [https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints]()
- [https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html]()
Le CSRF, c'est dangereux.
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un des multiples facteurs d'attaque sur le web.
Heureusement, Django vient encore une fois à notre aide,
avec des mécanismes intégrés pour s'en protéger.
Ceux-ci incluent notamment un système de
@@ -148,39 +123,16 @@ Ceux-ci incluent notamment un système de
à fournir dans les requêtes POST/PUT/PATCH.
Ceux-ci sont bien adaptés au cycle requêtes/réponses
typiques de l'expérience utilisateur sur un navigateur,
typique de l'expérience utilisateur sur un navigateur,
où les requêtes POST sont toujours effectuées après une requête
GET au cours de laquelle on a pu récupérer un token csrf.
Cependant, ils sont également gênants et moins utiles
dans le cadre d'une API REST, étant donné
que l'authentification cesse d'être implicite :
la clef d'API doit être explicitement jointe aux headers,
pour chaque requête.
Cependant, le flux des requêtes sur une API est bien différent ;
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
par un client externe n'aient pas de token CSRF et se retrouvent
donc bloquées.
Pour ces raisons, la vérification CSRF ne prend place
que pour la vérification de l'authentification
par cookie de session.
!!!warning "L'ordre est important"
Si vous écrivez le code suivant, l'authentification par clef d'API
ne marchera plus :
```python
@api_controller("/foo")
class FooController(ControllerBase):
@route.post("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def post_bar(self, club_id: int): ...
```
En effet, la vérification du cookie de session intègrera
toujours la vérification CSRF.
Or, un échec de cette dernière est traduit par django en un code HTTP 403
au lieu d'un HTTP 401.
L'authentification se retrouve alors court-circuitée,
faisant que la vérification de la clef d'API ne sera jamais appelée.
`SessionAuth` doit donc être déclaré **après** `ApiKeyAuth`.
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
par un client externe ne marche pas.
## Créer un client et une clef d'API
@@ -219,3 +171,5 @@ qui en a besoin.
Dites-lui bien de garder cette clef en lieu sûr !
Si la clef est perdue, il n'y a pas moyen de la récupérer,
vous devrez en recréer une.

View File

@@ -157,18 +157,16 @@ que sont VsCode et Sublime Text.
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
supplémentaire à effectuer.
Pour utiliser Biome, placez-vous à la racine du projet et lancez la commande suivante:
Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante:
```bash
npx @biomejs/biome check # Pour checker le code avec le linter et le formater
npx @biomejs/biome check --write # Pour appliquer les changements
npx @biomejs/biome check --write # Pour appliquer les changemnts
```
Biome va alors faire son travail sur l'ensemble du projet puis vous dire
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
ou bien s'il y a des erreurs à réparer
(si vous avez fait `npx @biomejs/biome lint`)
ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
Appeler Biome en ligne de commandes avant de pousser votre code sur Github
est une technique qui marche très bien.

View File

@@ -30,7 +30,7 @@ opérations, telles que la validation de formulaire.
En effet, valider un formulaire demande beaucoup
de travail de nettoyage des données et d'affichage
des messages d'erreur appropriés.
Or, tout ce travail existe déjà dans Django.
Or, tout ce travail existe déjà dans django.
On veut donc, dans ces cas-là, ne pas demander
toute une page HTML au serveur, mais uniquement
@@ -84,7 +84,7 @@ Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons
une `UpdateView` très simple, contenant
une `EditView` très simple, contenant
uniquement un formulaire.
On peut écrire la vue et le template de la manière
suivante :
@@ -94,10 +94,8 @@ suivante :
```python
from django.views.generic import UpdateView
from core.views import AllowFragment
class FooUpdateView(AllowFragment, UpdateView):
class FooUpdateView(UpdateView):
model = Foo
fields = ["foo", "bar"]
pk_url_kwarg = "foo_id"
@@ -134,7 +132,7 @@ Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template
qui peut changer selon les situations, on a plusieurs
qui peut changer les situations, on a plusieurs
templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un,
@@ -240,10 +238,10 @@ qui se comportera alors comme une vue normale.
#### La méthode `as_fragment`
Il est à noter que l'instanciation d'un fragment
Il est à noter que l'instantiation d'un fragment
se fait en deux étapes :
- on commence par instancier la vue en tant que renderer.
- on commence par instantier la vue en tant que renderer.
- on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`.

View File

@@ -76,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```bash
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
sudo pacman -S uv gcc git gettext pkgconf npm valkey
sudo pacman -S uv gcc git gettext pkgconf npm redis
```
=== "macOS"

View File

@@ -212,7 +212,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
obj = self.get_object()
obj.is_moderated = True
obj.save()
return redirect("com:news_list")
return redirect(reverse("com:news_list"))
```
=== "Function-based view"
@@ -233,7 +233,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
news = get_object_or_404(News, id=news_id)
news.is_moderated = True
news.save()
return redirect("com:news_list")
return redirect(reverse("com:news_list"))
```
## Accès à des éléments en particulier
@@ -447,9 +447,10 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python
from django.views.generic import DetailView
from django.views.generic import CreateView, DetailView
from core.auth.mixins import CanViewMixin, CanCreateMixin
from core.auth.mixins import CanViewMixin
from com.models import WeekmailArticle
@@ -458,15 +459,48 @@ from com.models import WeekmailArticle
# d'une classe de base pour fonctionner correctement.
class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle
```
Les mixins suivants sont implémentés :
- [CanCreateMixin][core.auth.mixins.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.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
!!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance"

View File

@@ -1,155 +0,0 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
super().__init__(queryset, **kwargs)
def clean(self, value):
qs = super().clean(value)
self.validate(qs)
return qs
def validate(self, qs):
if qs.count() > self.max_choice:
raise forms.ValidationError(
_("You have selected too many candidates."), code="invalid"
)
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
required_css_class = "required"
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, election: Election, can_edit: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["role"].queryset = election.roles.select_related("election")
self.fields["election_list"].queryset = election.election_lists.all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election: Election, user: User, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.can_vote(user):
return
for role in election.roles.all():
cand = role.candidatures
if role.max_choice > 1:
self.fields[role.title] = LimitedCheckboxField(
cand, role.max_choice, required=False
)
else:
self.fields[role.title] = forms.ModelChoiceField(
cand,
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
)
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
class Meta:
model = Role
fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
)
class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm):
class Meta:
model = Election
fields = [
"title",
"description",
"archived",
"start_candidature",
"end_candidature",
"start_date",
"end_date",
"edit_groups",
"view_groups",
"vote_groups",
"candidature_groups",
]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
}
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
start_candidature = forms.DateTimeField(
label=_("Start candidature"), widget=SelectDateTime, required=True
)
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
)

View File

@@ -1,30 +0,0 @@
# Generated by Django 4.2.20 on 2025-03-14 18:18
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),
("election", "0004_auto_20191006_0049"),
]
operations = [
migrations.AlterField(
model_name="candidature",
name="program",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="candidature",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="candidates",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

@@ -1,7 +1,5 @@
from django.db import models
from django.db.models import Count
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
@@ -24,18 +22,21 @@ class Election(models.Model):
verbose_name=_("edit groups"),
blank=True,
)
view_groups = models.ManyToManyField(
Group,
related_name="viewable_elections",
verbose_name=_("view groups"),
blank=True,
)
vote_groups = models.ManyToManyField(
Group,
related_name="votable_elections",
verbose_name=_("vote groups"),
blank=True,
)
candidature_groups = models.ManyToManyField(
Group,
related_name="candidate_elections",
@@ -44,7 +45,7 @@ class Election(models.Model):
)
voters = models.ManyToManyField(
User, verbose_name=_("voters"), related_name="voted_elections"
User, verbose_name=("voters"), related_name="voted_elections"
)
archived = models.BooleanField(_("archived"), default=False)
@@ -54,20 +55,20 @@ class Election(models.Model):
@property
def is_vote_active(self):
now = timezone.now()
return self.start_date <= now <= self.end_date
return bool(now <= self.end_date and now >= self.start_date)
@property
def is_vote_finished(self):
return timezone.now() > self.end_date
return bool(timezone.now() > self.end_date)
@property
def is_candidature_active(self):
now = timezone.now()
return self.start_candidature <= now <= self.end_candidature
return bool(now <= self.end_candidature and now >= self.start_candidature)
@property
def is_vote_editable(self):
return timezone.now() <= self.end_candidature
return bool(timezone.now() <= self.end_candidature)
def can_candidate(self, user):
for group_id in self.candidature_groups.values_list("pk", flat=True):
@@ -86,7 +87,7 @@ class Election(models.Model):
def has_voted(self, user):
return self.voters.filter(id=user.id).exists()
@cached_property
@property
def results(self):
results = {}
total_vote = self.voters.count()
@@ -94,6 +95,12 @@ class Election(models.Model):
results[role.title] = role.results(total_vote)
return results
def delete(self, *args, **kwargs):
self.election_lists.all().delete()
super().delete(*args, **kwargs)
# Permissions
class Role(OrderedModel):
"""This class allows to create a new role avaliable for a candidature."""
@@ -108,37 +115,36 @@ class Role(OrderedModel):
description = models.TextField(_("description"), null=True, blank=True)
max_choice = models.IntegerField(_("max choice"), default=1)
def __str__(self):
return f"{self.title} - {self.election.title}"
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0:
candidates = self.candidatures.values_list("user__username")
return {
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
}
def results(self, total_vote):
results = {}
total_vote *= self.max_choice
results = {"total vote": total_vote}
non_blank = 0
candidatures = self.candidatures.annotate(nb_votes=Count("votes")).values(
"nb_votes", "user__username"
)
for candidature in candidatures:
non_blank += candidature["nb_votes"]
results[candidature["user__username"]] = {
"vote": candidature["nb_votes"],
"percent": candidature["nb_votes"] * 100 / total_vote,
for candidature in self.candidatures.all():
cand_results = {}
cand_results["vote"] = self.votes.filter(candidature=candidature).count()
if total_vote == 0:
cand_results["percent"] = 0
else:
cand_results["percent"] = cand_results["vote"] * 100 / total_vote
non_blank += cand_results["vote"]
results[candidature.user.username] = cand_results
results["total vote"] = total_vote
if total_vote == 0:
results["blank vote"] = {"vote": 0, "percent": 0}
else:
results["blank vote"] = {
"vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote,
}
results["blank vote"] = {
"vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote,
}
return results
@property
def edit_groups(self):
return self.election.edit_groups
def __str__(self):
return ("%s : %s") % (self.election.title, self.title)
class ElectionList(models.Model):
"""To allow per list vote."""
@@ -157,6 +163,11 @@ class ElectionList(models.Model):
def can_be_edited_by(self, user):
return user.can_edit(self.election)
def delete(self, *args, **kwargs):
for candidature in self.candidatures.all():
candidature.delete()
super().delete(*args, **kwargs)
class Candidature(models.Model):
"""This class is a component of responsability."""
@@ -171,9 +182,10 @@ class Candidature(models.Model):
User,
verbose_name=_("user"),
related_name="candidates",
blank=True,
on_delete=models.CASCADE,
)
program = models.TextField(_("description"), default="", blank=True)
program = models.TextField(_("description"), null=True, blank=True)
election_list = models.ForeignKey(
ElectionList,
related_name="candidatures",
@@ -184,10 +196,13 @@ class Candidature(models.Model):
def __str__(self):
return f"{self.role.title} : {self.user.username}"
def delete(self):
for vote in self.votes.all():
vote.delete()
super().delete()
def can_be_edited_by(self, user):
return (
(user == self.user) or user.can_edit(self.role.election)
) and self.role.election.is_vote_editable
return (user == self.user) or user.can_edit(self.role.election)
class Vote(models.Model):

View File

@@ -31,7 +31,7 @@
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
</p>
{%- if user_has_voted %}
{%- if election.has_voted(user) %}
<p class="election__elector-infos">
{%- if election.is_vote_active %}
<span>{% trans %}You already have submitted your vote.{% endtrans %}</span>
@@ -45,11 +45,12 @@
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
{% csrf_token %}
<table class="election_table">
{%- set election_lists = election.election_lists.all() -%}
<thead class="lists">
<tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
<th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">
<span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
@@ -58,26 +59,18 @@
{%- endfor %}
</tr>
</thead>
{%- for role in election_roles %}
{%- set role_list = election.roles.order_by('order').all() %}
{%- for role in role_list %}
{%- set count = [0] %}
{%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
<tbody
{% if role.max_choice > 1 -%}
x-data x-limited-choices="{{ role.max_choice }}"
{%- endif %}
class="role {% if role.title in election_form.errors %}role_error{% endif %}"
>
<tbody data-max-choice="{{role.max_choice}}" class="role{{ ' role_error' if role.title in election_form.errors else '' }}{{ ' role__multiple-choices' if role.max_choice > 1 else ''}}">
<tr>
<td class="role_title">
<div class="role_text">
<h4>{{ role.title }}</h4>
<p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and show_vote_buttons %}
<strong>
{% trans trimmed nb_choices=role.max_choice %}
You may choose up to {{ nb_choices }} people.
{% endtrans %}
</strong>
{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %}
<strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong>
{%- endif %}
{%- if election_form.errors[role.title] is defined %}
@@ -88,40 +81,36 @@
</div>
{% if user.can_edit(role) and election.is_vote_editable -%}
<div class="role_buttons">
<a href="{{ url('election:update_role', role_id=role.id) }}">
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a href="{{ url('election:delete_role', role_id=role.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{%- if loop.last -%}
<a href="{{url('election:update_role', role_id=role.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a href="{{url('election:delete_role', role_id=role.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
{%- if role == role_list.last() %}
<button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%}
{%- else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
{%- endif -%}
{%- if loop.first -%}
{%- endif %}
{% if role == role_list.first() %}
<button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%}
{% else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
{%- endif -%}
{% endif %}
</div>
{%- endif -%}
</td>
</tr>
<tr class="role_candidates">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
{%- if role.max_choice == 1 and show_vote_buttons %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
{%- if role.max_choice == 1 and election.can_vote(user) %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
<input id="{{ input_id }}" type="radio" name="{{ role.title }}">
<label for="{{ input_id }}">
<input id="id_{{ role.title }}_{{ count[0] }}" type="radio" name="{{ role.title }}" value {{ '' if role_data in election_form else 'checked' }} {{ 'disabled' if election.has_voted(user) else '' }}>
<label for="id_{{ role.title }}_{{ count[0] }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span>
</label>
</div>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %}
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %}
@@ -131,17 +120,16 @@
{%- endif %}
</td>
{%- for election_list in election_lists %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
<ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
{%- for candidature in election_list.candidatures.filter(role=role) %}
<li class="candidate">
{%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %}
<input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<label for="{{ input_id }}">
{%- if election.can_vote(user) %}
<input id="id_{{ role.title }}_{{ count[0] }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if election.has_voted(user) else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<label for="id_{{ role.title }}_{{ count[0] }}">
{%- endif %}
<figure>
{%- if user.is_viewable %}
{%- if user.is_subscriber_viewable %}
{% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %}
@@ -152,7 +140,7 @@
<h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5>
{%- if not election.is_vote_finished %}
<q class="candidate_program" show-more="200">
{{ candidature.program|markdown }}
{{ candidature.program|markdown or '' }}
</q>
{%- endif %}
</figcaption>
@@ -165,8 +153,9 @@
{%- endif -%}
{%- endif -%}
</figure>
{%- if show_vote_buttons %}
{%- if election.can_vote(user) %}
</label>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %}
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title][candidature.user.username] %}
@@ -202,9 +191,36 @@
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section>
{%- if show_vote_buttons %}
{%- if not election.has_voted(user) and election.can_vote(user) %}
<section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section>
{%- endif %}
{% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.querySelectorAll('.role__multiple-choices').forEach(setupRestrictions);
function setupRestrictions(role) {
var selectedChoices = [];
role.querySelectorAll('input').forEach(setupRestriction);
function setupRestriction(choice) {
if (choice.checked)
selectedChoices.push(choice);
choice.addEventListener('change', onChange);
function onChange() {
if (choice.checked)
selectedChoices.push(choice);
else
selectedChoices.splice(selectedChoices.indexOf(choice), 1);
while (selectedChoices.length > role.dataset.maxChoice)
selectedChoices.shift().checked = false;
}
}
}
</script>
{% endblock %}

View File

@@ -1,15 +1,9 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.test import Client, TestCase
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from core.baker_recipes import subscriber_user
from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote
from election.models import Election
class TestElection(TestCase):
@@ -18,7 +12,8 @@ class TestElection(TestCase):
cls.election = Election.objects.first()
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.sli = User.objects.get(username="sli")
cls.public = baker.make(User)
cls.subscriber = User.objects.get(username="subscriber")
cls.public = User.objects.get(username="public")
class TestElectionDetail(TestElection):
@@ -41,7 +36,7 @@ class TestElectionDetail(TestElection):
class TestElectionUpdateView(TestElection):
def test_permission_denied(self):
self.client.force_login(subscriber_user.make())
self.client.force_login(self.subscriber)
response = self.client.get(
reverse("election:update", args=str(self.election.id))
)
@@ -50,68 +45,3 @@ class TestElectionUpdateView(TestElection):
reverse("election:update", args=str(self.election.id))
)
assert response.status_code == 403
@pytest.mark.django_db
def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))
groups = [
Group.objects.get(pk=settings.SITH_GROUP_SUBSCRIBERS_ID),
baker.make(Group),
]
election.candidature_groups.add(groups[0])
election.edit_groups.add(groups[1])
url = reverse("election:create_list", kwargs={"election_id": election.id})
for user in subscriber_user.make(), baker.make(User, groups=[groups[1]]):
client.force_login(user)
assert client.get(url).status_code == 200
# the post is a 200 instead of a 302, because we don't give form data,
# but we don't care as we only test permissions here
assert client.post(url).status_code == 200
client.force_login(baker.make(User))
assert client.get(url).status_code == 403
assert client.post(url).status_code == 403
@pytest.mark.django_db
def test_election_results():
election = baker.make(
Election, voters=baker.make(User, _quantity=50, _bulk_create=True)
)
lists = baker.make(ElectionList, election=election, _quantity=2, _bulk_create=True)
roles = baker.make(
Role, election=election, max_choice=iter([1, 2]), _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
cand = [
baker.make(Candidature, role=roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=roles[1], user=users[3], election_list=lists[1]),
]
votes = [
baker.make(Vote, role=roles[0], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[0], _quantity=25, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=35, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=10, _bulk_create=True),
]
cand[0].votes.set(votes[0])
cand[1].votes.set(votes[1])
cand[2].votes.set([*votes[2], *votes[4]])
cand[3].votes.set([*votes[3], *votes[4]])
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 40.0, "vote": 20},
cand[1].user.username: {"percent": 50.0, "vote": 25},
"blank vote": {"percent": 10.0, "vote": 5},
"total vote": 50,
},
roles[1].title: {
cand[2].user.username: {"percent": 30.0, "vote": 30},
cand[3].user.username: {"percent": 45.0, "vote": 45},
"blank vote": {"percent": 25.0, "vote": 25},
"total vote": 100,
},
}

View File

@@ -1,34 +1,183 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.conf import settings
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from election.forms import (
CandidateForm,
ElectionForm,
ElectionListForm,
RoleForm,
VoteForm,
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING:
from core.models import User
# Custom form field
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
super().__init__(queryset, **kwargs)
def clean(self, value):
qs = super().clean(value)
self.validate(qs)
return qs
def validate(self, qs):
if qs.count() > self.max_choice:
raise forms.ValidationError(
_("You have selected too much candidates."), code="invalid"
)
# Forms
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
can_edit = kwargs.pop("can_edit", False)
super().__init__(*args, **kwargs)
if election_id:
self.fields["role"].queryset = Role.objects.filter(
election__id=election_id
).all()
self.fields["election_list"].queryset = ElectionList.objects.filter(
election__id=election_id
).all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election, user, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.has_voted(user):
for role in election.roles.all():
cand = role.candidatures
if role.max_choice > 1:
self.fields[role.title] = LimitedCheckboxField(
cand, role.max_choice, required=False
)
else:
self.fields[role.title] = forms.ModelChoiceField(
cand,
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
)
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
class Meta:
model = Role
fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
)
class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm):
class Meta:
model = Election
fields = [
"title",
"description",
"archived",
"start_candidature",
"end_candidature",
"start_date",
"end_date",
"edit_groups",
"view_groups",
"vote_groups",
"candidature_groups",
]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
}
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
start_candidature = forms.DateTimeField(
label=_("Start candidature"), widget=SelectDateTime, required=True
)
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
)
# Display elections
@@ -36,21 +185,25 @@ class ElectionsListView(CanViewMixin, ListView):
"""A list of all non archived elections visible."""
model = Election
queryset = model.objects.filter(archived=False)
ordering = ["-id"]
paginate_by = 10
template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False).all()
class ElectionListArchivedView(CanViewMixin, ListView):
"""A list of all archived elections visible."""
model = Election
queryset = model.objects.filter(archived=True)
ordering = ["-id"]
paginate_by = 10
template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=True).all()
class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability."""
@@ -59,67 +212,46 @@ class ElectionDetailView(CanViewMixin, DetailView):
template_name = "election/election_detail.jinja"
pk_url_kwarg = "election_id"
@staticmethod
def _reorder_votes(action: str, role: int):
role = Role.objects.filter(id=role).first()
if not role:
return
if action == "up":
role.up()
elif action == "down":
role.down()
elif action == "bottom":
role.bottom()
elif action == "top":
role.top()
def get(self, request, *arg, **kwargs):
response = super().get(request, *arg, **kwargs)
election: Election = self.get_object()
if election.is_vote_editable and request.user.can_edit(election):
if request.user.can_edit(election) and election.is_vote_editable:
action = request.GET.get("action", None)
role = request.GET.get("role", None)
if action and role and role.isdigit():
self._reorder_votes(action, int(role))
return super().get(request, *arg, **kwargs)
if action and role and Role.objects.filter(id=role).exists():
if action == "up":
Role.objects.get(id=role).up()
elif action == "down":
Role.objects.get(id=role).down()
elif action == "bottom":
Role.objects.get(id=role).bottom()
elif action == "top":
Role.objects.get(id=role).top()
return redirect(
reverse("election:detail", kwargs={"election_id": election.id})
)
return response
def get_context_data(self, **kwargs):
"""Add additionnal data to the template."""
user: User = self.request.user
return super().get_context_data(**kwargs) | {
"election_form": VoteForm(self.object, user),
"show_vote_buttons": self.object.can_vote(user),
"user_has_voted": self.object.has_voted(user),
"election_results": (
self.object.results if self.object.is_vote_finished else None
),
"election_lists": list(self.object.election_lists.all()),
"election_roles": list(self.object.roles.order_by("order")),
}
kwargs = super().get_context_data(**kwargs)
kwargs["election_form"] = VoteForm(self.object, self.request.user)
kwargs["election_results"] = self.object.results
return kwargs
# Form view
class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
class VoteFormView(CanCreateMixin, FormView):
"""Alows users to vote."""
form_class = VoteForm
template_name = "election/election_detail.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
return super().dispatch(request, *arg, **kwargs)
def vote(self, election_data):
with transaction.atomic():
@@ -139,16 +271,20 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
self.election.voters.add(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"election": self.election,
"user": self.request.user,
}
kwargs = super().get_form_kwargs()
kwargs["election"] = self.election
kwargs["user"] = self.request.user
return kwargs
def form_valid(self, form):
"""Verify that the user is part in a vote group."""
data = form.clean()
self.vote(data)
return super().form_valid(form)
res = super(FormView, self).form_valid(form)
for grp_id in self.election.vote_groups.values_list("pk", flat=True):
if self.request.user.is_in_group(pk=grp_id):
self.vote(data)
return res
return res
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -174,22 +310,26 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
self.can_edit = self.request.user.can_edit(self.election)
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"user": self.request.user.id}
init = {}
self.can_edit = self.request.user.can_edit(self.election)
init["user"] = self.request.user.id
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"election": self.election,
"can_edit": self.can_edit,
}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
kwargs["can_edit"] = self.can_edit
return kwargs
def form_valid(self, form: CandidateForm):
def form_valid(self, form):
"""Verify that the selected user is in candidate group."""
obj = form.instance
obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit
):
@@ -197,7 +337,9 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
raise PermissionDenied
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
kwargs = super().get_context_data(**kwargs)
kwargs["election"] = self.election
return kwargs
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -213,79 +355,80 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class RoleCreateView(CanCreateMixin, CreateView):
model = Role
form_class = RoleForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_role"):
return True
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def form_valid(self, form):
"""Verify that the user can edit properly."""
obj: Role = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class ElectionListCreateView(CanCreateMixin, CreateView):
model = ElectionList
form_class = ElectionListForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_electionlist"):
return True
groups = set(
self.election.candidature_groups.values("id")
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def form_valid(self, form):
"""Verify that the user can vote on this election."""
obj: ElectionList = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.candidature_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
@@ -314,23 +457,45 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
class CandidatureUpdateView(CanEditMixin, UpdateView):
model = Candidature
form_class = CandidateForm
template_name = "core/edit.jinja"
pk_url_kwarg = "candidature_id"
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields.pop("role", None)
return form
def dispatch(self, request, *arg, **kwargs):
self.object = self.get_object()
if not self.object.role.election.is_vote_editable:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def remove_fields(self):
self.form.fields.pop("role", None)
def get(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
return self.render_to_response(self.get_context_data(form=self.form))
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
if (
request.user.is_authenticated
and request.user.can_edit(self.object)
and self.form.is_valid()
):
return super().form_valid(self.form)
return self.form_invalid(self.form)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.object.role.election}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.object.role.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.role.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.role.election.id}
)
@@ -381,12 +546,18 @@ class RoleUpdateView(CanEditMixin, UpdateView):
# Delete Views
class ElectionDeleteView(PermissionRequiredMixin, DeleteView):
class ElectionDeleteView(DeleteView):
model = Election
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "election_id"
permission_required = "election.delete_election"
success_url = reverse_lazy("election:list")
def dispatch(self, request, *args, **kwargs):
if request.user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse_lazy("election:list")
class CandidatureDeleteView(CanEditMixin, DeleteView):
@@ -402,7 +573,7 @@ class CandidatureDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class RoleDeleteView(CanEditMixin, DeleteView):
@@ -418,7 +589,7 @@ class RoleDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionListDeleteView(CanEditMixin, DeleteView):
@@ -434,4 +605,4 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})

View File

@@ -27,14 +27,14 @@ from functools import partial
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import IntegrityError
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.utils import html, timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, RedirectView
from django.views.generic.detail import SingleObjectMixin
@@ -44,6 +44,7 @@ from honeypot.decorators import check_honeypot
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
@@ -179,19 +180,11 @@ class ForumForm(forms.ModelForm):
)
class ForumCreateView(UserPassesTestMixin, CreateView):
class ForumCreateView(CanCreateMixin, CreateView):
model = Forum
form_class = ForumForm
template_name = "core/create.jinja"
def test_func(self):
if self.request.user.has_perm("forum.add_forum"):
return True
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
if parent is not None:
return self.request.user.is_owner(parent)
return False
def get_initial(self):
init = super().get_initial()
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
@@ -265,19 +258,18 @@ class TopicForm(forms.ModelForm):
@method_decorator(
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
)
class ForumTopicCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class ForumTopicCreateView(CanCreateMixin, CreateView):
model = ForumMessage
form_class = TopicForm
template_name = "forum/reply.jinja"
@cached_property
def forum(self):
return get_object_or_404(Forum, id=self.kwargs["forum_id"], is_category=False)
def test_func(self):
return self.request.user.has_perm("forum.add_forumtopic") or (
self.request.user.can_view(self.forum)
def dispatch(self, request, *args, **kwargs):
self.forum = get_object_or_404(
Forum, id=self.kwargs["forum_id"], is_category=False
)
if not request.user.can_view(self.forum):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
topic = ForumTopic(
@@ -412,7 +404,7 @@ class ForumMessageUndeleteView(SingleObjectMixin, RedirectView):
@method_decorator(
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
)
class ForumMessageCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class ForumMessageCreateView(CanCreateMixin, CreateView):
model = ForumMessage
form_class = forms.modelform_factory(
model=ForumMessage,
@@ -421,14 +413,11 @@ class ForumMessageCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
)
template_name = "forum/reply.jinja"
@cached_property
def topic(self):
return get_object_or_404(ForumTopic, id=self.kwargs["topic_id"])
def test_func(self):
return self.request.user.has_perm(
"forum.add_forummessage"
) or self.request.user.can_view(self.topic)
def dispatch(self, request, *args, **kwargs):
self.topic = get_object_or_404(ForumTopic, id=self.kwargs["topic_id"])
if not request.user.can_view(self.topic):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_initial(self):
init = super().get_initial()

View File

@@ -199,7 +199,7 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.filter(is_viewable=True)
User.objects.filter(is_subscriber_viewable=True)
.exclude(subscriptions=None)
.annotate(
pictures_count=Count("pictures"),

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-12 21:44+0100\n"
"POT-Creation-Date: 2025-11-07 14:50+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,7 +141,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py election/forms.py
#: club/forms.py com/forms.py counter/forms.py election/views.py
#: subscription/forms.py
msgid "End date"
msgstr "Date de fin"
@@ -247,7 +247,8 @@ msgstr "description"
msgid "past member"
msgstr "ancien membre"
#: club/models.py com/templates/com/mailing_admin.jinja
#: club/models.py club/templates/club/club_detail.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja
@@ -470,7 +471,7 @@ msgstr "Méthode de paiement"
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/macros.jinja core/templates/core/page/prop.jinja
#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
@@ -546,12 +547,11 @@ msgstr ""
"Les champs de formulaire suivants sont liées à la description basique d'un "
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja
#: core/templates/core/file_edit.jinja core/templates/core/page/edit.jinja
#: core/templates/core/page/prop.jinja
#: club/templates/club/edit_club.jinja com/templates/com/news_edit.jinja
#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
#: com/templates/com/weekmail.jinja core/templates/core/create.jinja
#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
#: core/templates/core/macros_pages.jinja core/templates/core/page_prop.jinja
#: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
@@ -638,9 +638,9 @@ msgstr "Nouvelle liste de diffusion"
msgid "Create mailing list"
msgstr "Créer une liste de diffusion"
#: club/templates/club/pagerev_edit.jinja core/templates/core/page/edit.jinja
msgid "Edit page"
msgstr "Éditer la page"
#: club/templates/club/page_history.jinja
msgid "No page existing for this club"
msgstr "Aucune page n'existe pour ce club"
#: club/views.py core/views/user.py sas/templates/sas/picture.jinja
msgid "Infos"
@@ -654,7 +654,7 @@ msgstr "Membres"
msgid "Old members"
msgstr "Anciens membres"
#: club/views.py core/templates/core/page/base.jinja
#: club/views.py core/templates/core/page.jinja
msgid "History"
msgstr "Historique"
@@ -666,7 +666,7 @@ msgstr "Outils"
#: club/views.py com/templates/com/news_admin_list.jinja
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
#: com/templates/com/weekmail.jinja core/templates/core/file.jinja
#: core/templates/core/group_list.jinja core/templates/core/page/base.jinja
#: core/templates/core/group_list.jinja core/templates/core/page.jinja
#: core/templates/core/user_tools.jinja core/views/user.py
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
@@ -704,8 +704,8 @@ msgid "Benefit"
msgstr "Bénéfice"
#: club/views.py
msgid "Unit price"
msgstr "Prix unitaire"
msgid "Selling price"
msgstr "Prix de vente"
#: club/views.py
msgid "Purchase price"
@@ -715,7 +715,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/forms.py subscription/forms.py
#: com/forms.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
@@ -980,7 +980,7 @@ msgid "Dates"
msgstr "Dates"
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page/base.jinja
#: core/templates/core/page.jinja
msgid "View"
msgstr "Voir"
@@ -1017,10 +1017,6 @@ msgstr "Événements à modérer"
msgid "Back to news"
msgstr "Retour aux nouvelles"
#: com/templates/com/news_detail.jinja
msgid "Share on Facebook"
msgstr "Partager sur Facebook"
#: com/templates/com/news_detail.jinja
msgid "Author: "
msgstr "Auteur : "
@@ -1536,15 +1532,8 @@ msgid "parent address"
msgstr "adresse des parents"
#: core/models.py
msgid "Profile visible by subscribers"
msgstr "Profil visible par les cotisants"
#: core/models.py
msgid ""
"If you disable this option, only admin users will be able to see your "
"profile."
msgstr ""
"Si vous désactivez cette option, seuls les admins pourront voir votre profil."
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
#: core/models.py
msgid "A user with that username already exists"
@@ -1960,7 +1949,7 @@ msgstr "Liste de fichiers"
msgid "New file"
msgstr "Nouveau fichier"
#: core/templates/core/file.jinja
#: core/templates/core/file.jinja core/templates/core/page.jinja
msgid "Not found"
msgstr "Non trouvé"
@@ -1968,7 +1957,7 @@ msgstr "Non trouvé"
msgid "My files"
msgstr "Mes fichiers"
#: core/templates/core/file.jinja core/templates/core/page/base.jinja
#: core/templates/core/file.jinja core/templates/core/page.jinja
msgid "Prop"
msgstr "Propriétés"
@@ -2106,6 +2095,14 @@ msgstr "Mot de passe perdu ?"
msgid "Create account"
msgstr "Créer un compte"
#: core/templates/core/macros.jinja
msgid "Share on Facebook"
msgstr "Partager sur Facebook"
#: core/templates/core/macros.jinja
msgid "Tweet"
msgstr "Tweeter"
#: core/templates/core/macros.jinja
#, python-format
msgid "Subscribed until %(subscription_end)s"
@@ -2127,6 +2124,19 @@ msgstr "Tout sélectionner"
msgid "Unselect All"
msgstr "Tout désélectionner"
#: core/templates/core/macros_pages.jinja
#, python-format
msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/macros_pages.jinja
msgid "last"
msgstr "actuel"
#: core/templates/core/macros_pages.jinja
msgid "Edit page"
msgstr "Éditer la page"
#: core/templates/core/new_user_email.jinja
msgid ""
"You're receiving this email because you subscribed to the UTBM student "
@@ -2177,47 +2187,38 @@ msgstr "Nouvelle cotisation à l'Association des Étudiants de l'UTBM"
msgid "Notification list"
msgstr "Liste des notifications"
#: core/templates/core/page/base.jinja
msgid "Page"
msgstr "Page"
#: core/templates/core/page.jinja core/templates/core/page_list.jinja
msgid "Page list"
msgstr "Liste des pages"
#: core/templates/core/page/base.jinja
#: core/templates/core/page.jinja
msgid "Create page"
msgstr "Créer une page"
#: core/templates/core/page.jinja
msgid "Return to club management"
msgstr "Retourner à la gestion du club"
#: core/templates/core/page/detail.jinja
#: core/templates/core/page.jinja
msgid "Page does not exist"
msgstr "La page n'existe pas"
#: core/templates/core/page.jinja
msgid "Create it?"
msgstr "La créer ?"
#: core/templates/core/page_detail.jinja
#, python-format
msgid "This may not be the last update, you are seeing revision %(rev_id)s!"
msgstr ""
"Ceci n'est peut-être pas la dernière version de la page. Vous consultez la "
"version %(rev_id)s."
#: core/templates/core/page/history.jinja
#: core/templates/core/page_hist.jinja
msgid "Page history"
msgstr "Historique de la page"
#: core/templates/core/page/list.jinja
msgid "Page list"
msgstr "Liste des pages"
#: core/templates/core/page/macros.jinja
#, python-format
msgid "You're seeing the history of page \"%(page_name)s\""
msgstr "Vous consultez l'historique de la page \"%(page_name)s\""
#: core/templates/core/page/macros.jinja
msgid "last"
msgstr "actuel"
#: core/templates/core/page/not_found.jinja
msgid "Page does not exist"
msgstr "La page n'existe pas"
#: core/templates/core/page/not_found.jinja
msgid "Create it?"
msgstr "La créer ?"
#: core/templates/core/page/prop.jinja
#: core/templates/core/page_prop.jinja
msgid "Page properties"
msgstr "Propriétés de la page"
@@ -2840,6 +2841,10 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement"
#: core/views/forms.py
msgid "Choose user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py
msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur"
@@ -4038,30 +4043,6 @@ msgstr ""
msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: election/forms.py
msgid "You have selected too many candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/forms.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/forms.py election/templates/election/election_detail.jinja
msgid "Blank vote"
msgstr "Vote blanc"
#: election/forms.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/forms.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/forms.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: election/models.py
msgid "start candidature"
msgstr "début des candidatures"
@@ -4086,10 +4067,6 @@ msgstr "groupe de vote"
msgid "candidature groups"
msgstr "groupe de candidature"
#: election/models.py
msgid "voters"
msgstr "électeurs"
#: election/models.py
msgid "election"
msgstr "élection"
@@ -4145,10 +4122,17 @@ msgstr "Vous avez déjà soumis votre vote."
msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection."
#: election/templates/election/election_detail.jinja election/views.py
msgid "Blank vote"
msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja
#, python-format
msgid "You may choose up to %(nb_choices)s people."
msgstr "Vous pouvez choisir jusqu'à %(nb_choices)s personnes."
msgid "You may choose up to"
msgstr "Vous pouvez choisir jusqu'à"
#: election/templates/election/election_detail.jinja
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja
msgid "Choose blank vote"
@@ -4190,6 +4174,26 @@ msgstr "au"
msgid "Polls open from"
msgstr "Votes ouverts du"
#: election/views.py
msgid "You have selected too much candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/views.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/views.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/views.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/views.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: forum/models.py
msgid "is a category"
msgstr "est une catégorie"
@@ -5107,6 +5111,14 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "One month for free"
msgstr "Un mois gratuit"
#: sith/settings.py
msgid "Two months for free"
msgstr "Deux mois gratuits"
#: sith/settings.py
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
@@ -5120,10 +5132,8 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member"
msgid "GA staff member"
msgstr "Membre staff GA"
msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py
msgid "One semester (-20%)"

View File

@@ -105,7 +105,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True)
self.init_query = self.init_query.exclude(is_subscriber_viewable=False)
return super().dispatch(request, *args, **kwargs)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else:
q = []
if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable]
q = [user for user in q if user.is_subscriber_viewable]
else:
search_dict = {}
for key, value in self.valid_form.items():

View File

@@ -19,7 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient
class UvController(ControllerBase):
@route.get(
"/{code}",
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[
# this route will almost always be called in the context
# of a UV creation/edition
@@ -45,7 +45,7 @@ class UvController(ControllerBase):
"",
response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[HasPerm("pedagogy.view_uv")],
)
@paginate(PageNumberPaginationExtra, page_size=100)

View File

@@ -44,7 +44,7 @@ dependencies = [
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0",
"redis[hiredis]>=5.3.0,<8",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0",
"honcho>=2.0.0",

View File

@@ -8,6 +8,7 @@ from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt
@@ -40,6 +41,7 @@ class AlbumController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[AlbumSchema],
permissions=[IsAuthenticated],
url_name="search-album",
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -52,7 +54,7 @@ class AlbumController(ControllerBase):
@route.get(
"/autocomplete-search",
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
auth=[ApiKeyAuth(), SessionAuth()],
auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
@@ -72,7 +74,12 @@ class AlbumController(ControllerBase):
@api_controller("/sas/picture")
class PicturesController(ControllerBase):
@route.get("", response=PaginatedResponseSchema[PictureSchema], url_name="pictures")
@route.get(
"",
response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated],
url_name="pictures",
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters.
@@ -134,18 +141,15 @@ class PicturesController(ControllerBase):
@route.get(
"/{picture_id}/identified",
permissions=[CanView],
permissions=[IsAuthenticated, CanView],
response=list[IdentifiedUserSchema],
url_name="picture_identifications",
)
def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.viewable_by(self.context.request.user).select_related(
"user"
)
return picture.people.select_related("user")
@route.put("/{picture_id}/identified", permissions=[CanView])
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(
Picture.objects.select_related("parent"), pk=picture_id
@@ -205,7 +209,7 @@ class PicturesController(ControllerBase):
@api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase):
@route.delete("/{relation_id}")
@route.delete("/{relation_id}", permissions=[IsAuthenticated])
def delete_relation(self, relation_id: NonNegativeInt):
"""Untag a user from a SAS picture.

View File

@@ -265,15 +265,6 @@ def sas_notification_callback(notif: Notification):
notif.param = str(count)
class PeoplePictureRelationQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self
if user.was_subscribed:
return self.filter(Q(user_id=user.id) | Q(user__is_viewable=True))
return self.filter(user_id=user.id)
class PeoplePictureRelation(models.Model):
"""The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -290,8 +281,6 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE,
)
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta:
unique_together = ["user", "picture"]

View File

@@ -55,7 +55,7 @@ class TestPictureSearch(TestSas):
def test_anonymous_user_forbidden(self):
res = self.client.get(self.url)
assert res.status_code == 401
assert res.status_code == 403
def test_filter_by_album(self):
self.client.force_login(self.user_b)
@@ -148,7 +148,7 @@ class TestPictureRelation(TestSas):
relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first()
res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 401
assert res.status_code == 403
for user in baker.make(User), self.user_a:
self.client.force_login(user)
@@ -186,29 +186,6 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count
def test_fetch_relations_including_hidden_users(self):
"""Test that normal subscribers users cannot see hidden profiles"""
picture = self.album_a.children_pictures.last()
self.user_a.is_viewable = False
self.user_a.save()
url = reverse("api:picture_identifications", kwargs={"picture_id": picture.id})
# a normal subscriber user shouldn't see user_a as identified
self.client.force_login(subscriber_user.make())
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_b.id, self.user_c.id}
# an admin should see everyone
self.client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
)
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_a.id, self.user_b.id, self.user_c.id}
class TestPictureModeration(TestSas):
@classmethod

View File

@@ -1,11 +1,10 @@
import pytest
from django.test import TestCase
from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.baker_recipes import picture_recipe
from sas.models import PeoplePictureRelation, Picture
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@@ -45,25 +44,3 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]]
@pytest.mark.django_db
def test_identifications_viewable_by_user():
picture = baker.make(Picture)
identifications = baker.make(
PeoplePictureRelation, picture=picture, _quantity=10, _bulk_create=True
)
identifications[0].user.is_viewable = False
identifications[0].user.save()
assert (
list(picture.people.viewable_by(old_subscriber_user.make()))
== identifications[1:]
)
assert (
list(picture.people.viewable_by(baker.make(User, is_superuser=True)))
== identifications
)
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]

View File

@@ -7,7 +7,6 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.utils import get_last_promo
from core.views.forms import SelectDate, SelectDateTime
from core.views.widgets.ajax_select import AutoCompleteSelectUser
from subscription.models import Subscription
@@ -126,17 +125,8 @@ class SubscriptionNewUserForm(SubscriptionForm):
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
"cursus-alternant",
]:
member.role = "STUDENT"
member.school = "UTBM"
if self.cleaned_data.get("subscription_type") == "cursus-tronc-commun":
member.promo = get_last_promo()
if self.cleaned_data.get("subscription_type") in [
"cursus-branche",
"cursus-alternant",
]:
member.promo = get_last_promo() - 2
member.generate_username()
member.set_password(secrets.token_urlsafe(nbytes=10))
self.instance.member = member

View File

@@ -27,7 +27,7 @@ from datetime import date
from django import forms
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
@@ -35,13 +35,17 @@ from django.forms.models import modelform_factory
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, RedirectView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.auth.mixins import (
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import User
from core.views.forms import SelectDate
from core.views.mixins import TabedViewMixin
@@ -113,25 +117,19 @@ class TrombiForm(forms.ModelForm):
widgets = {"subscription_deadline": SelectDate, "comments_deadline": SelectDate}
class TrombiCreateView(UserPassesTestMixin, CreateView):
class TrombiCreateView(CanCreateMixin, CreateView):
"""Create a trombi for a club."""
model = Trombi
form_class = TrombiForm
template_name = "core/create.jinja"
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.can_edit(self.club)
def post(self, request, *args, **kwargs):
"""Affect club."""
form = self.get_form()
if form.is_valid():
form.instance.club = self.club
club = get_object_or_404(Club, id=self.kwargs["club_id"])
form.instance.club = club
ret = self.form_valid(form)
return ret
else: