diff --git a/api/hashers.py b/api/hashers.py
index 95c16673..42909fde 100644
--- a/api/hashers.py
+++ b/api/hashers.py
@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher):
"""
- An API key hasher using the sha256 algorithm.
+ An API key hasher using the sha512 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
diff --git a/club/forms.py b/club/forms.py
index d2b24dde..dcd270e7 100644
--- a/club/forms.py
+++ b/club/forms.py
@@ -37,6 +37,7 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser,
)
from counter.models import Counter, Selling
+from counter.schemas import SaleFilterSchema
class ClubEditForm(forms.ModelForm):
@@ -191,6 +192,18 @@ 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(
diff --git a/club/templates/club/page_history.jinja b/club/templates/club/page_history.jinja
index 07c25146..825fa7ab 100644
--- a/club/templates/club/page_history.jinja
+++ b/club/templates/club/page_history.jinja
@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %}
-{% from 'core/macros_pages.jinja' import page_history %}
+{% from 'core/page/macros.jinja' import page_history %}
{% block content %}
- {% if club.page %}
- {{ page_history(club.page) }}
- {% else %}
- {% trans %}No page existing for this club{% endtrans %}
- {% endif %}
+ {{ page_history(club.page) }}
{% endblock %}
diff --git a/club/templates/club/pagerev_edit.jinja b/club/templates/club/pagerev_edit.jinja
index 5253805d..cc7d3038 100644
--- a/club/templates/club/pagerev_edit.jinja
+++ b/club/templates/club/pagerev_edit.jinja
@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %}
-{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block content %}
- {{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
+
{% trans %}Edit page{% endtrans %}
+
{% endblock %}
diff --git a/club/tests/test_membership.py b/club/tests/test_membership.py
index a3c0be50..2420043d 100644
--- a/club/tests/test_membership.py
+++ b/club/tests/test_membership.py
@@ -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 TestCase
+from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
@@ -532,6 +532,35 @@ 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)
diff --git a/club/tests/test_page.py b/club/tests/test_page.py
index 7e8c5efc..c368735d 100644
--- a/club/tests/test_page.py
+++ b/club/tests/test_page.py
@@ -3,9 +3,10 @@ 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
+from pytest_django.asserts import assertHTMLEqual, assertRedirects
-from club.models import Club
+from club.models import Club, Membership
+from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -16,7 +17,6 @@ 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,10 +30,42 @@ 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
diff --git a/club/tests/test_sales.py b/club/tests/test_sales.py
index 6e734f80..457b8967 100644
--- a/club/tests/test_sales.py
+++ b/club/tests/test_sales.py
@@ -1,3 +1,6 @@
+import csv
+import itertools
+
import pytest
from django.test import Client
from django.urls import reverse
@@ -7,16 +10,20 @@ 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
+from counter.models import Counter, Customer, Product, Selling
@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)
- response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
- assert response.status_code == 200
+ 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
@pytest.mark.django_db
@@ -36,3 +43,62 @@ 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,
+ ]
diff --git a/club/views.py b/club/views.py
index e6c86a7c..9077d0d7 100644
--- a/club/views.py
+++ b/club/views.py
@@ -22,25 +22,28 @@
#
#
+from __future__ import annotations
+
import csv
import itertools
-from typing import Any
+from typing import TYPE_CHECKING, Any
from django.conf import settings
-from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.auth.mixins import LoginRequiredMixin, 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, HttpResponseRedirect, StreamingHttpResponse
+from django.http import Http404, 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.safestring import SafeString
+from django.utils.functional import cached_property
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 (
@@ -61,11 +64,14 @@ from com.views import (
PosterListBaseView,
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
-from core.models import PageRev
-from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
+from core.models import Page, PageRev
+from core.views import BasePageEditView, DetailFormView, 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):
@@ -75,6 +81,8 @@ 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):
@@ -202,7 +210,7 @@ class ClubView(ClubTabsMixin, DetailView):
return kwargs
-class ClubRevView(ClubView):
+class ClubRevView(LoginRequiredMixin, ClubView):
"""Display a specific page revision."""
def dispatch(self, request, *args, **kwargs):
@@ -216,26 +224,26 @@ class ClubRevView(ClubView):
return kwargs
-class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
+class ClubPageEditView(ClubTabsMixin, BasePageEditView):
template_name = "club/pagerev_edit.jinja"
current_tab = "page_edit"
- 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 club(self):
+ return get_object_or_404(Club, pk=self.kwargs["club_id"])
- def get_object(self):
- self.page = self.club.page
- return self._get_revision()
+ @cached_property
+ def page(self) -> Page:
+ page = self.club.page
+ page.set_lock(self.request.user)
+ return page
def get_success_url(self, **kwargs):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
- """Modification hostory of the page."""
+ """Modification history of the page."""
model = Club
pk_url_kwarg = "club_id"
@@ -399,33 +407,14 @@ 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 = 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)
-
+ 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))
kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"]
@@ -472,15 +461,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.product.selling_price - selling.product.purchase_price)
+ row.append(selling.unit_price - selling.product.purchase_price)
else:
- row = [*row, "", "", ""]
+ row = [*row, "", ""]
return row
def get(self, request, *args, **kwargs):
@@ -501,9 +490,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"),
],
@@ -556,33 +545,17 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club"
-class MembershipSetOldView(CanEditMixin, DetailView):
- """Set a membership as beeing old."""
+class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
+ """Set a membership as being old."""
model = Membership
pk_url_kwarg = "membership_id"
- def get(self, request, *args, **kwargs):
+ def post(self, *_args, **_kwargs):
self.object = self.get_object()
self.object.end_date = timezone.now()
self.object.save()
- 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},
- )
- )
+ return redirect("core:user_clubs", user_id=self.object.user_id)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -594,7 +567,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):
diff --git a/com/tests/test_api.py b/com/tests/test_api.py
index 571bc2a0..ce747347 100644
--- a/com/tests/test_api.py
+++ b/com/tests/test_api.py
@@ -1,4 +1,3 @@
-from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
@@ -18,16 +17,6 @@ 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):
diff --git a/com/views.py b/com/views.py
index 2d5045d9..ea5b742d 100644
--- a/com/views.py
+++ b/com/views.py
@@ -240,10 +240,11 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"):
return []
return itertools.groupby(
- User.objects.filter(
+ User.objects.viewable_by(self.request.user)
+ .filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
- is_subscriber_viewable=True,
+ is_viewable=True,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
diff --git a/core/admin.py b/core/admin.py
index eff77817..a21086a0 100644
--- a/core/admin.py
+++ b/core/admin.py
@@ -74,9 +74,19 @@ 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)
diff --git a/core/api.py b/core/api.py
index ab69f86e..2f2c0fb1 100644
--- a/core/api.py
+++ b/core/api.py
@@ -1,6 +1,6 @@
from typing import Annotated, Any, Literal
-import annotated_types
+from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from django.http import HttpResponse
@@ -28,6 +28,7 @@ from core.schemas import (
UserSchema,
)
from core.templatetags.renderer import markdown
+from counter.utils import is_logged_in_counter
@api_controller("/markdown")
@@ -72,9 +73,9 @@ class MailingListController(ControllerBase):
@api_controller("/user")
class UserController(ControllerBase):
- @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
+ @route.get("", response=list[UserProfileSchema])
def fetch_profiles(self, pks: Query[set[int]]):
- return User.objects.filter(pk__in=pks)
+ return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int):
@@ -85,13 +86,18 @@ class UserController(ControllerBase):
"/search",
response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users",
- permissions=[CanAccessLookup],
+ # logged in barmen aren't authenticated stricto sensu, so no auth here
+ auth=None,
)
@paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]):
- return filters.filter(
- User.objects.order_by(F("last_login").desc(nulls_last=True))
- )
+ 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)))
@api_controller("/file")
@@ -103,7 +109,7 @@ class SithFileController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
- def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
+ def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -116,11 +122,11 @@ class GroupController(ControllerBase):
permissions=[CanAccessLookup],
)
@paginate(PageNumberPaginationExtra, page_size=50)
- def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
+ def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
-DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
+DepthValue = Annotated[int, Ge(0), Le(10)]
DEFAULT_DEPTH = 4
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index cd0087e7..60c282aa 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -150,7 +150,8 @@ 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(
diff --git a/core/migrations/0048_alter_user_options.py b/core/migrations/0048_alter_user_options.py
new file mode 100644
index 00000000..f446273a
--- /dev/null
+++ b/core/migrations/0048_alter_user_options.py
@@ -0,0 +1,33 @@
+# 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."
+ ),
+ ),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index a624ebf6..be3b5cc4 100644
--- a/core/models.py
+++ b/core/models.py
@@ -23,12 +23,13 @@
#
from __future__ import annotations
+import difflib
import string
import unicodedata
from datetime import timedelta
from io import BytesIO
from pathlib import Path
-from typing import TYPE_CHECKING, Self
+from typing import TYPE_CHECKING, Final, Self
from uuid import uuid4
from django.conf import settings
@@ -54,6 +55,8 @@ 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
@@ -86,12 +89,11 @@ class Group(AuthGroup):
def validate_promo(value: int) -> None:
- start_year = settings.SITH_SCHOOL_START_YEAR
- delta = (localdate() + timedelta(days=180)).year - start_year
- if value < 0 or delta < value:
+ last_promo = get_last_promo()
+ if not 0 < value <= last_promo:
raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
- params={"value": value, "end": delta},
+ params={"value": value, "end": last_promo},
)
@@ -136,6 +138,15 @@ 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
@@ -271,13 +282,24 @@ class User(AbstractUser):
parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default=""
)
- is_subscriber_viewable = models.BooleanField(
- _("is subscriber viewable"), default=True
+ 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,
)
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()
@@ -551,8 +573,12 @@ 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):
- return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
+ 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 get_mini_item(self):
return """
@@ -1319,6 +1345,9 @@ 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)
@@ -1360,6 +1389,32 @@ 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
diff --git a/core/static/core/colors.scss b/core/static/core/colors.scss
index 9ed493ba..490690d9 100644
--- a/core/static/core/colors.scss
+++ b/core/static/core/colors.scss
@@ -21,6 +21,8 @@ $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%);
diff --git a/core/static/core/footer.scss b/core/static/core/footer.scss
index 3c0306e0..aa2e048f 100644
--- a/core/static/core/footer.scss
+++ b/core/static/core/footer.scss
@@ -65,7 +65,7 @@ footer.bottom-links {
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
- box-shadow: black 0 8px 15px;
+ box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;
diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss
index d761331c..e1793a69 100644
--- a/core/static/core/forms.scss
+++ b/core/static/core/forms.scss
@@ -745,4 +745,32 @@ form {
background-repeat: no-repeat;
background-size: var(--nf-input-size);
}
+
+ &.no-margin {
+ margin:0;
+ }
+
+ // a submit input that should look like a regular
+ 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;
+ }
+ }
}
diff --git a/core/static/core/header.scss b/core/static/core/header.scss
index f43819c3..01e70403 100644
--- a/core/static/core/header.scss
+++ b/core/static/core/header.scss
@@ -5,14 +5,10 @@ $text-color: white;
$background-color-hovered: #283747;
-$red-text-color: #eb2f06;
-$hovered-red-text-color: #ff4d4d;
-
.header {
box-sizing: border-box;
background-color: $deepblue;
- box-shadow: black 0 1px 3px 0,
- black 0 4px 8px 3px;
+ box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
display: flex;
@@ -100,7 +96,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0;
margin: 0;
box-sizing: border-box;
- background-color: transparent;
+ background-color: $deepblue;
width: 45px;
height: 25px;
padding: 0;
@@ -252,12 +248,15 @@ $hovered-red-text-color: #ff4d4d;
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 {
@@ -269,19 +268,6 @@ $hovered-red-text-color: #ff4d4d;
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;
- }
}
}
}
@@ -332,7 +318,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px;
z-index: 100;
border-radius: 10px;
- @include shadow;
+ box-shadow: 3px 3px 3px 0 #767676;
>ul {
list-style-type: none;
diff --git a/core/static/core/img/gala25_background.webp b/core/static/core/img/gala25_background.webp
deleted file mode 100644
index 978e9946..00000000
Binary files a/core/static/core/img/gala25_background.webp and /dev/null differ
diff --git a/core/static/core/img/gala25_logo.webp b/core/static/core/img/gala25_logo.webp
deleted file mode 100644
index 3cbdb6f7..00000000
Binary files a/core/static/core/img/gala25_logo.webp and /dev/null differ
diff --git a/core/static/core/style.scss b/core/static/core/style.scss
index c23303a7..b48aa7c1 100644
--- a/core/static/core/style.scss
+++ b/core/static/core/style.scss
@@ -271,9 +271,8 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
- padding: 1.5em 2%;
- border-radius: 5px;
- box-shadow: black 0 8px 15px;
+ padding: 1em 1%;
+ box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
}
@@ -520,7 +519,6 @@ th {
td {
margin: 5px;
border-collapse: collapse;
- vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;
diff --git a/core/static/user/user_edit.scss b/core/static/user/user_edit.scss
index 888ae729..5b20fcee 100644
--- a/core/static/user/user_edit.scss
+++ b/core/static/user/user_edit.scss
@@ -7,10 +7,13 @@
.profile {
&-visible {
display: flex;
- justify-content: center;
+ flex-direction: column;
align-items: center;
gap: 5px;
padding-top: 10px;
+ input[type="checkbox"]+label {
+ max-width: unset;
+ }
}
&-pictures {
@@ -116,23 +119,19 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
- gap: 10px;
+ gap: var(--nf-input-size) 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%;
}
@@ -145,22 +144,6 @@
}
}
- &-label {
- text-align: left !important;
- }
-
- &-content {
- > * {
- box-sizing: border-box;
- text-align: left !important;
- margin: 0;
-
- > * {
- text-align: left !important;
- }
- }
- }
-
textarea {
height: 7rem;
}
diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja
index 356abdff..34a8040b 100644
--- a/core/templates/core/base.jinja
+++ b/core/templates/core/base.jinja
@@ -44,18 +44,6 @@
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
-
{% endblock %}
diff --git a/core/templates/core/base/header.jinja b/core/templates/core/base/header.jinja
index 65a15968..de0169b9 100644
--- a/core/templates/core/base/header.jinja
+++ b/core/templates/core/base/header.jinja
@@ -1,6 +1,6 @@