Compare commits

..
Author SHA1 Message Date
imperosol 74d17e6a96 test is_logged_in_counter 2026-10-31 12:54:59 +01:00
imperosol 7bc980113a allow is_logged_in_counter to target a specific counter 2026-10-30 14:38:00 +01:00
18 changed files with 123 additions and 149 deletions

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@ from django import forms
from django.core.validators import EmailValidator from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from antispam.models.models import ToxicDomain from antispam.models import ToxicDomain
class AntiSpamEmailValidator(EmailValidator): class AntiSpamEmailValidator(EmailValidator):
@@ -17,6 +17,6 @@ validate_antispam_email = AntiSpamEmailValidator()
class AntiSpamEmailField(forms.EmailField): class AntiSpamEmailField(forms.EmailField):
"""An email field that forbids email addresses with a known toxic domain.""" """An email field that email addresses with a known toxic domain."""
default_validators = [validate_antispam_email] default_validators = [validate_antispam_email]
@@ -38,31 +38,22 @@ class Command(BaseCommand):
return domains return domains
def _update_domains(self, domains: set[str]): def _update_domains(self, domains: set[str]):
existing = set(ToxicDomain.objects.values_list("domain", flat=True)) # Cleanup database
if existing == domains: ToxicDomain.objects.filter(is_externally_managed=True).delete()
self.stdout.write("Domain database was up to date")
return # Create database
if to_remove := (existing - domains): ToxicDomain.objects.bulk_create(
# Cleanup database [
ToxicDomain.objects.filter( ToxicDomain(domain=domain, is_externally_managed=True)
domain__in=to_remove, is_externally_managed=True for domain in domains
).delete() ],
if to_create := (domains - existing): ignore_conflicts=True,
# Create database )
ToxicDomain.objects.bulk_create(
[
ToxicDomain(domain=domain, is_externally_managed=True)
for domain in to_create
],
ignore_conflicts=True,
)
self.stdout.write("Domain database updated") self.stdout.write("Domain database updated")
def handle(self, *args, **options): def handle(self, *args, **options):
if not self._should_update(force=options["force"]): if not self._should_update(force=options["force"]):
self.stdout.write( self.stdout.write("Domain database is up to date")
"No update required. Use --force if you want to do it anyway."
)
return return
self.stdout.write("Updating domain database") self.stdout.write("Updating domain database")
File renamed without changes.
-4
View File
@@ -1,4 +0,0 @@
from antispam.models.fields import AntiSpamEmailField
from antispam.models.models import ToxicDomain
__all__ = ["AntiSpamEmailField", "ToxicDomain"]
-21
View File
@@ -1,21 +0,0 @@
from django.conf import settings
from django.db import models
from antispam import forms
class AntiSpamEmailField(models.EmailField):
"""An email field that forbids email addresses with a known toxic domain."""
default_validators = [forms.validate_antispam_email]
if settings.TESTING:
from model_bakery import baker
baker.generators.add(
AntiSpamEmailField, lambda: baker.random_gen.gen_email(max_length=100)
)
else: # pragma: no cover
# baker is only used in tests, so we don't need coverage for this part
pass
View File
Whitespace-only changes.
+2 -3
View File
@@ -55,7 +55,6 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image, ImageOps from PIL import Image, ImageOps
from antispam.models import AntiSpamEmailField
from core.utils import get_last_promo from core.utils import get_last_promo
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -159,7 +158,7 @@ class User(AbstractUser):
first_name = models.CharField(_("first name"), max_length=64) first_name = models.CharField(_("first name"), max_length=64)
last_name = models.CharField(_("last name"), max_length=64) last_name = models.CharField(_("last name"), max_length=64)
email = AntiSpamEmailField(_("email address"), unique=True) email = models.EmailField(_("email address"), unique=True)
date_of_birth = models.DateField(_("date of birth"), blank=True, null=True) date_of_birth = models.DateField(_("date of birth"), blank=True, null=True)
nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True) nick_name = models.CharField(_("nick name"), max_length=64, null=True, blank=True)
last_update = models.DateTimeField(_("last update"), auto_now=True) last_update = models.DateTimeField(_("last update"), auto_now=True)
@@ -268,7 +267,7 @@ class User(AbstractUser):
forum_signature = models.TextField( forum_signature = models.TextField(
_("forum signature"), max_length=256, blank=True, default="" _("forum signature"), max_length=256, blank=True, default=""
) )
second_email = AntiSpamEmailField(_("second email address"), null=True, blank=True) second_email = models.EmailField(_("second email address"), null=True, blank=True)
phone = PhoneNumberField(_("phone"), null=True, blank=True) phone = PhoneNumberField(_("phone"), null=True, blank=True)
parent_phone = PhoneNumberField(_("parent phone"), null=True, blank=True) parent_phone = PhoneNumberField(_("parent phone"), null=True, blank=True)
address = models.CharField(_("address"), max_length=128, blank=True, default="") address = models.CharField(_("address"), max_length=128, blank=True, default="")
-30
View File
@@ -472,36 +472,6 @@ def test_user_preferences(client: Client):
assert user.preferences.notify_on_click is True assert user.preferences.notify_on_click is True
@pytest.mark.django_db
@pytest.mark.parametrize("can_edit", [True, False])
def test_user_visibility(client: Client, monkeypatch, can_edit):
# The view is a CanView, so just patch User.can_be_edited_by for the test
monkeypatch.setattr("core.models.User.can_be_edited_by", lambda *args: can_edit)
client.force_login(subscriber_user.make())
user, other_user = subscriber_user.make(_quantity=2)
url = reverse("core:user_visibility_fragment", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200 if can_edit else 403
response = client.post(
url,
{
"show_my_stats": False,
"is_viewable": False,
"whitelisted_users": [other_user.id],
},
)
if not can_edit:
assert response.status_code == 403
return
assertRedirects(response, url)
user.refresh_from_db()
user.preferences.refresh_from_db()
assert user.is_viewable is False
assert list(user.whitelisted_users.all()) == [other_user]
assert user.preferences.show_my_stats is False
@pytest.mark.django_db @pytest.mark.django_db
def test_user_stats(client: Client): def test_user_stats(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
+5 -1
View File
@@ -48,6 +48,7 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -184,7 +185,10 @@ class RegisteringForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = ("first_name", "last_name", "email", "cgu_approved_at") fields = ("first_name", "last_name", "email", "cgu_approved_at")
field_classes = {"cgu_approved_at": CGUApprovalField} field_classes = {
"email": AntiSpamEmailField,
"cgu_approved_at": CGUApprovalField,
}
class CGUApprovalForm(forms.ModelForm): class CGUApprovalForm(forms.ModelForm):
+2 -2
View File
@@ -94,8 +94,8 @@ class FragmentMixin(TemplateResponseMixin, AllowFragment, ContextMixin):
return render( return render(
request, request,
"app/template.jinja", "app/template.jinja",
context={"fragment": fragment(request)} context={"fragment": fragment(request)
) }
# in urls.py # in urls.py
urlpatterns = [ urlpatterns = [
+1 -3
View File
@@ -502,9 +502,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
current_tab = "clubs" current_tab = "clubs"
class UserVisibilityFormFragment( class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView):
FragmentMixin, SuccessMessageMixin, CanEditMixin, UpdateView
):
model = User model = User
form_class = UserVisibilityForm form_class = UserVisibilityForm
template_name = "core/fragment/user_visibility.jinja" template_name = "core/fragment/user_visibility.jinja"
+50 -5
View File
@@ -21,11 +21,12 @@ import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.contrib.messages import DEFAULT_LEVELS, get_messages from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.test import Client, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import resolve, reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from freezegun import freeze_time from freezegun import freeze_time
@@ -47,6 +48,7 @@ from counter.models import (
ReturnableProduct, ReturnableProduct,
Selling, Selling,
) )
from counter.utils import is_logged_in_counter
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpResponse from django.http import HttpResponse
@@ -65,8 +67,9 @@ class TestFullClickBase(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.customer = subscriber_user.make() cls.customer = subscriber_user.make()
cls.barmen = subscriber_user.make(password=make_password("plop")) password = make_password("plop")
cls.board_admin = board_user.make(password=make_password("plop")) cls.barmen = subscriber_user.make(password=password)
cls.board_admin = board_user.make(password=password)
cls.club_admin = subscriber_user.make() cls.club_admin = subscriber_user.make()
cls.root = baker.make(User, is_superuser=True) cls.root = baker.make(User, is_superuser=True)
cls.subscriber = subscriber_user.make() cls.subscriber = subscriber_user.make()
@@ -919,6 +922,48 @@ class TestBarmanConnection(TestCase):
assert self.barman in response.wsgi_request.barmen assert self.barman in response.wsgi_request.barmen
class TestIsLoggedInCounter(TestCase):
@classmethod
def setUpTestData(cls):
cls.counter = baker.make(Counter, type="BAR")
cls.other_counter = baker.make(Counter, type="BAR")
cls.barman = subscriber_user.make()
cls.other_barman = subscriber_user.make()
cls.url = reverse("counter:details", kwargs={"counter_id": cls.counter.id})
def make_request(self, barmen):
"""Requête émise depuis une vue du namespace `counter`."""
request = RequestFactory().get(self.url)
request.resolver_match = resolve(self.url)
request.barmen = set(barmen)
return request
def test_denied(self):
# nobody is logged
request = self.make_request([])
assert not is_logged_in_counter(request)
# somebody is logged, but on another counter
self.other_counter.permanencies.create(user=self.barman, start=now())
assert not is_logged_in_counter(request, counter=self.counter)
def test_ok(self):
request = self.make_request([self.barman])
assert is_logged_in_counter(request)
self.counter.permanencies.create(user=self.barman, start=now())
assert is_logged_in_counter(request, counter=self.counter)
def test_request_outside_counter_app(self):
"""A request coming from outside the counter app is denied."""
request = self.make_request([self.barman])
request.resolver_match = resolve(reverse("core:index"))
assert not is_logged_in_counter(request)
self.counter.permanencies.create(user=self.barman, start=now())
assert not is_logged_in_counter(request, counter=self.counter)
@pytest.mark.django_db @pytest.mark.django_db
def test_barman_timeout(client: Client): def test_barman_timeout(client: Client):
"""Test that barmen timeout is well managed.""" """Test that barmen timeout is well managed."""
+6 -2
View File
@@ -6,8 +6,10 @@ from django.urls import resolve
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest from django.http import HttpRequest
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool:
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
"""Check if the request is sent from a device logged to a counter. """Check if the request is sent from a device logged to a counter.
The request must also be sent within the frame of a counter's activity. The request must also be sent within the frame of a counter's activity.
@@ -30,4 +32,6 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
if not referer_ok and request.resolver_match.app_name != "counter": if not referer_ok and request.resolver_match.app_name != "counter":
return False return False
return bool(request.barmen) if counter is None:
return bool(request.barmen)
return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))
+25 -36
View File
@@ -306,52 +306,41 @@ class ReturnableProductDeleteView(
} }
class RefillingDeleteView(DeleteView): class CounterTransactionDeletionView(DeleteView):
template_name = "core/delete_confirm.jinja"
def get_queryset(self):
return super().get_queryset().select_related("counter", "customer")
def dispatch(self, request, *args, **kwargs):
self.object: Selling | Refilling = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request, counter=self.object.counter):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter_id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user_id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class RefillingDeleteView(CounterTransactionDeletionView):
"""Delete a refilling (for the admins).""" """Delete a refilling (for the admins)."""
model = Refilling model = Refilling
pk_url_kwarg = "refilling_id" pk_url_kwarg = "refilling_id"
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class SellingDeleteView(DeleteView): class SellingDeleteView(CounterTransactionDeletionView):
"""Delete a selling (for the admins).""" """Delete a selling (for the admins)."""
model = Selling model = Selling
pk_url_kwarg = "selling_id" pk_url_kwarg = "selling_id"
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class CounterStatView(PermissionRequiredMixin, DetailView): class CounterStatView(PermissionRequiredMixin, DetailView):
+3 -7
View File
@@ -242,15 +242,11 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not self.customer.can_buy: if not self.customer.can_buy:
raise Http404 raise Http404
if not is_logged_in_counter(request):
raise PermissionDenied
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"]) self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
if not ( if (
request.barmen not is_logged_in_counter(request, counter=self.counter)
and request.barmen.issubset(self.counter.barmen_list) or not self.counter.can_refill
and self.counter.can_refill
): ):
raise PermissionDenied raise PermissionDenied
+8 -9
View File
@@ -16,9 +16,9 @@ from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import F from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -36,6 +36,7 @@ from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
@@ -155,7 +156,7 @@ class CounterMain(
return super().form_valid(form) return super().form_valid(form)
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView): class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailView):
"""Provide the last operations to allow barmen to delete them.""" """Provide the last operations to allow barmen to delete them."""
model = Counter model = Counter
@@ -163,14 +164,12 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
template_name = "counter/last_ops.jinja" template_name = "counter/last_ops.jinja"
current_tab = "last_ops" current_tab = "last_ops"
def dispatch(self, request, *args, **kwargs): def test_func(self):
"""We have here again a very particular right handling."""
self.object = self.get_object() self.object = self.get_object()
if is_logged_in_counter(request) and self.object.barmen_list: return is_logged_in_counter(
return super().dispatch(request, *args, **kwargs) self.request, counter=self.object
return HttpResponseRedirect( ) or self.request.user.has_perms(
reverse("counter:details", kwargs={"counter_id": self.object.id}) ["counter.view_selling", "counter.view_refilling"]
+ "?bad_location"
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
+6 -2
View File
@@ -22,7 +22,7 @@ from counter.utils import is_logged_in_counter
class CounterTabsMixin(TabedViewMixin): class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
return self.object return self.object.name
def get_list_of_tabs(self): def get_list_of_tabs(self):
if self.object.type != "BAR": if self.object.type != "BAR":
@@ -46,7 +46,11 @@ class CounterTabsMixin(TabedViewMixin):
"name": _("Cash summary"), "name": _("Cash summary"),
} }
) )
if is_logged_in_counter(self.request): if is_logged_in_counter(
self.request, counter=self.object
) or self.request.user.has_perms(
["counter.view_selling", "counter.view_refilling"]
):
tab_list.append( tab_list.append(
{ {
"url": reverse( "url": reverse(
+1 -1
View File
@@ -68,7 +68,7 @@ dev = [
"pre-commit>=4.7.0,<5.0.0", "pre-commit>=4.7.0,<5.0.0",
"ruff>=0.17.2,<1.0.0", "ruff>=0.17.2,<1.0.0",
"djhtml>=3.0.11,<4.0.0", "djhtml>=3.0.11,<4.0.0",
"faker>=40.44.0,<41.0.0", "faker>=40.43.0,<41.0.0",
"rjsmin>=1.3.0,<2.0.0", "rjsmin>=1.3.0,<2.0.0",
] ]
tests = [ tests = [