use fragment for counter login

This commit is contained in:
imperosol
2026-05-29 00:29:29 +02:00
parent b4d76c4f85
commit 074ebcb011
8 changed files with 173 additions and 190 deletions
+23 -16
View File
@@ -17,6 +17,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet from core.models import User, UserQuerySet
from core.views import LoginForm
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField, FutureDateTimeField,
NFCTextInput, NFCTextInput,
@@ -91,30 +92,18 @@ class StudentCardForm(forms.ModelForm):
class GetUserForm(forms.Form): class GetUserForm(forms.Form):
"""The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view, """Find a user to show its click page."""
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField( code = forms.CharField(
label="Code", label="Code",
max_length=StudentCard.UID_SIZE, max_length=StudentCard.UID_SIZE,
required=False, required=False,
widget=NFCTextInput, widget=NFCTextInput(attrs={"autofocus": True}),
) )
id = forms.CharField( id = forms.CharField(
label=_("Select user"), label=_("Select user"), widget=AutoCompleteSelectUser, required=False
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super().as_p()
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
customer = None customer = None
@@ -136,11 +125,29 @@ class GetUserForm(forms.Form):
if customer is None or not customer.can_buy: if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found")) raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = customer.user.id cleaned_data["user_id"] = customer.user_id
cleaned_data["user"] = customer.user cleaned_data["user"] = customer.user
return cleaned_data return cleaned_data
class CounterLoginForm(LoginForm):
def __init__(self, *args, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.counter = counter
def confirm_login_allowed(self, user: User):
super().confirm_login_allowed(user)
if not self.counter.sellers.contains(user):
raise ValidationError(
message=_("You are not a barman of this counter."), code="not_barman"
)
if user in self.counter.barmen_list:
raise ValidationError(
message=_("You are already logged in this counter."),
code="not_logged_in",
)
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = [
Refilling.PaymentMethod.CASH, Refilling.PaymentMethod.CASH,
+12 -19
View File
@@ -32,12 +32,11 @@
</ul> </ul>
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p> <p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
{% endif %} {% endif %}
{% if barmen %} {% if can_click %}
<p>{% trans %}Enter client code:{% endtrans %}</p> <p>{% trans %}Enter client code:{% endtrans %}</p>
<form method="post" action=""> <form method="post" action="" id="select-user-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="counter_token" value="{{ counter.token }}" /> {{ form }}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
</form> </form>
{% else %} {% else %}
@@ -45,17 +44,11 @@
{% endif %} {% endif %}
</div> </div>
{% if counter.type == 'BAR' %} {% if counter.type == 'BAR' %}
<div> <h3>{% trans %}Barman: {% endtrans %}</h3>
<h3>{% trans %}Barman: {% endtrans %}</h3> {% for b in barmen %}
{% for b in barmen %} <p>{{ barman_logout_link(b) }}</p>
<p>{{ barman_logout_link(b) }}</p> {% endfor %}
{% endfor %} {{ login_fragment }}
<form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
{% csrf_token %}
{{ login_form.as_p() }}
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
</form>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -63,10 +56,10 @@
{{ super() }} {{ super() }}
<script type="text/javascript"> <script type="text/javascript">
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
// The login form annoyingly takes priority over the code form {# The login form annoyingly takes priority over the code form
// This is due to the loading time of the web component This is due to the loading time of the web component
// We can't rely on DOMContentLoaded to know if the component is there so we We can't rely on DOMContentLoaded to know if the component is there so we
// periodically run a script until the field is there periodically run a script until the field is there #}
const autofocus = () => { const autofocus = () => {
const field = document.querySelector("input[id='id_code']"); const field = document.querySelector("input[id='id_code']");
if (field === null){ if (field === null){
@@ -0,0 +1,5 @@
<form hx-post="{{ action }}" hx-swap="outerHTML">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
</form>
+33 -37
View File
@@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
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.models import Permission, make_password
@@ -718,51 +719,46 @@ class TestCounterStats(TestCase):
class TestBarmanConnection(TestCase): class TestBarmanConnection(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.krophil = User.objects.get(username="krophil") cls.barman = subscriber_user.make()
cls.skia = User.objects.get(username="skia") cls.barman.set_password("plop")
cls.skia.customer.account = 800 cls.barman.save()
cls.krophil.customer.save() cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman])
cls.skia.customer.save() cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id})
cls.detail_url = reverse(
cls.counter = Counter.objects.get(id=2) "counter:details", kwargs={"counter_id": cls.counter.id}
)
def test_barman_granted(self): def test_barman_granted(self):
self.client.post( response = self.client.post(
reverse("counter:login", args=[self.counter.id]), self.login_url, {"username": self.barman.username, "password": "plop"}
{"username": "krophil", "password": "plop"},
) )
response = self.client.get(reverse("counter:details", args=[self.counter.id])) assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
assert "<p>Entrez un code client : </p>" in str(response.content) last_perm = Permanency.objects.last()
assert last_perm.counter == self.counter
def test_counters_list_barmen(self): assert last_perm.user == self.barman
self.client.post( assert last_perm.end is None
reverse("counter:login", args=[self.counter.id]), response = self.client.get(
{"username": "krophil", "password": "plop"}, self.detail_url, {"username": self.barman.username, "password": "plop"}
) )
response = self.client.get(reverse("counter:activity", args=[self.counter.id])) assert response.context_data.get("barmen") == [self.barman]
soup = BeautifulSoup(response.text, "lxml")
assert '<li><a href="/user/10/">Kro Phil&#39;</a></li>' in str(response.content) assert soup.find("form", id="select-user-form") is not None
def test_barman_denied(self): def test_barman_denied(self):
self.client.post( not_barman = subscriber_user.make()
reverse("counter:login", args=[self.counter.id]), not_barman.set_password("plop")
{"username": "skia", "password": "plop"}, not_barman.save()
) response = self.client.post(
response_get = self.client.get( self.login_url, {"username": not_barman.username, "password": "plop"}
reverse("counter:details", args=[self.counter.id])
) )
assert "HX-Redirect" not in response.headers
assert not Permanency.objects.filter(user=not_barman).exists()
assert "<p>Merci de vous identifier</p>" in str(response_get.content) response = self.client.get(self.detail_url)
assert response.context_data.get("barmen") == []
def test_counters_list_no_barmen(self): soup = BeautifulSoup(response.text, "lxml")
self.client.post( assert soup.find("form", id="select-user-form") is None
reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
)
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
assert '<li><a href="/user/1/">S&#39; Kia</a></li>' not in str(response.content)
@pytest.mark.django_db @pytest.mark.django_db
+3 -2
View File
@@ -41,7 +41,6 @@ from counter.views.admin import (
ReturnableProductUpdateView, ReturnableProductUpdateView,
SellingDeleteView, SellingDeleteView,
) )
from counter.views.auth import counter_login, counter_logout
from counter.views.cash import ( from counter.views.cash import (
CashSummaryEditView, CashSummaryEditView,
CashSummaryListView, CashSummaryListView,
@@ -57,7 +56,9 @@ from counter.views.eticket import (
from counter.views.home import ( from counter.views.home import (
CounterActivityView, CounterActivityView,
CounterLastOperationsView, CounterLastOperationsView,
CounterLoginFragment,
CounterMain, CounterMain,
counter_logout,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
@@ -82,7 +83,7 @@ urlpatterns = [
), ),
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"), path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"), path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
path("<int:counter_id>/login/", counter_login, name="login"), path("<int:counter_id>/login/", CounterLoginFragment.as_view(), name="login"),
path("<int:counter_id>/logout/", counter_logout, name="logout"), path("<int:counter_id>/logout/", counter_logout, name="logout"),
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path( path(
-53
View File
@@ -1,53 +0,0 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
from counter.models import Counter, Permanency
@require_POST
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""Log a user in a counter.
A successful login will result in the beginning of a counter duty
for the user.
"""
counter = get_object_or_404(Counter, pk=counter_id)
form = LoginForm(request, data=request.POST)
if not form.is_valid():
return redirect(counter.get_absolute_url() + "?credentials")
user = form.get_user()
if not counter.sellers.contains(user) or user in counter.barmen_list:
return redirect(counter.get_absolute_url() + "?sellers")
if len(counter.barmen_list) == 0:
counter.gen_token()
request.session["counter_token"] = counter.token
counter.permanencies.create(user=user, start=timezone.now())
return redirect(counter)
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=now())
return redirect("counter:details", counter_id=counter_id)
+5 -10
View File
@@ -14,6 +14,7 @@
# #
from collections import defaultdict from collections import defaultdict
from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
@@ -21,6 +22,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
@@ -29,13 +31,7 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm from counter.forms import BasketForm, RefillForm
from counter.models import ( from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling
Counter,
Customer,
ProductFormula,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -97,10 +93,9 @@ class CounterClick(
raise PermissionDenied raise PermissionDenied
if obj.type == "BAR" and ( if obj.type == "BAR" and (
not obj.is_open not obj.is_open or request.session.get("counter_token", "") != obj.token
or "counter_token" not in request.session
or request.session["counter_token"] != obj.token
): ):
messages.error(request, _("You cannot click users on this counter"))
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.prices = list(obj.get_prices_for(self.customer)) self.prices = list(obj.get_prices_for(self.customer))
+92 -53
View File
@@ -15,78 +15,114 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.http import HttpResponseRedirect from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse, reverse_lazy from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.edit import FormMixin, ProcessFormView from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views.forms import LoginForm from core.views import FragmentMixin, UseFragmentsMixin
from counter.forms import GetUserForm from counter.forms import CounterLoginForm, GetUserForm
from counter.models import Counter from counter.models import Counter, Permanency
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
model = Counter
form_class = CounterLoginForm
reload_on_redirect = True
pk_url_kwarg = "counter_id"
template_name = "counter/fragments/login.jinja"
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"counter": self.object}
def form_valid(self, form: CounterLoginForm):
self.object.permanencies.create(user=form.get_user(), start=timezone.now())
if not self.object.barmen_list:
self.object.gen_token()
self.request.session["counter_token"] = self.object.token
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id}
)
return super().form_valid(form)
def render_fragment(self, request, **kwargs) -> SafeString:
self.object = kwargs.pop("counter")
return super().render_fragment(request, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse("counter:login", kwargs={"counter_id": self.object.id})
}
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=F("activity"))
return redirect("counter:details", counter_id=counter_id)
class CounterMain( class CounterMain(
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
): ):
"""The public (barman) view.""" """The public (barman) view."""
model = Counter model = Counter
queryset = Counter.objects.annotate_is_open().exclude(type="EBOUTIC")
template_name = "counter/counter_main.jinja" template_name = "counter/counter_main.jinja"
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
form_class = ( form_class = GetUserForm
GetUserForm # Form to enter a client code and get the corresponding user id
)
current_tab = "counter" current_tab = "counter"
def get_queryset(self): def dispatch(self, request, *args, **kwargs):
return super().get_queryset().exclude(type="EBOUTIC") self.object: Counter = self.get_object()
if self.object.type != "BAR" and self.request.method.upper() == "POST":
# barmen have to log in (thus do a POST request)
# only if it is a bar.
return self.http_method_not_allowed(request, *args, **kwargs)
if self.object.type == "BAR":
self.object.update_activity()
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def get_fragment_context_data(self) -> dict[str, SafeString]:
self.object = self.get_object() login_fragment = (
if self.object.type == "BAR" and not ( CounterLoginFragment.as_fragment()(self.request, counter=self.object)
"counter_token" in self.request.session if self.object.type == "BAR"
and self.request.session["counter_token"] == self.object.token else ""
): # Check the token to avoid the bar to be stolen )
return HttpResponseRedirect( return super().get_fragment_context_data() | {"login_fragment": login_fragment}
reverse_lazy(
"counter:details",
args=self.args,
kwargs={"counter_id": self.object.id},
)
+ "?bad_location"
)
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""We handle here the login form for the barman.""" """We handle here the login form for the barman."""
if self.request.method == "POST":
self.object = self.get_object()
self.object.update_activity()
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["login_form"] = LoginForm()
kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True
kwargs[
"login_form"
].cleaned_data = {} # add_error fails if there are no cleaned_data
if "credentials" in self.request.GET:
kwargs["login_form"].add_error(None, _("Bad credentials"))
if "sellers" in self.request.GET:
kwargs["login_form"].add_error(None, _("User is not barman"))
kwargs["form"] = self.get_form()
kwargs["form"].cleaned_data = {} # same as above
if "bad_location" in self.request.GET:
kwargs["form"].add_error(
None, _("Bad location, someone is already logged in somewhere else")
)
if self.object.type == "BAR": if self.object.type == "BAR":
kwargs["barmen"] = self.object.barmen_list kwargs["barmen"] = self.object.barmen_list
elif self.request.user.is_authenticated: kwargs["can_click"] = (
kwargs["barmen"] = [self.request.user] self.object.type == "BAR"
and self.object.is_open
and self.request.session.get("counter_token", "") == self.object.token
) or (
self.object.type == "OFFICE"
and (
self.object.sellers.contains(self.request.user)
or self.object.club.has_rights_in_club(self.request.user)
)
)
if "last_basket" in self.request.session: if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket") kwargs["last_basket"] = self.request.session.pop("last_basket")
kwargs["last_customer"] = self.request.session.pop("last_customer") kwargs["last_customer"] = self.request.session.pop("last_customer")
@@ -96,14 +132,17 @@ class CounterMain(
) )
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form: CounterLoginForm):
"""We handle here the redirection, passing the user id of the asked customer.""" """We handle here the redirection, passing the user id of the asked customer."""
self.kwargs["user_id"] = form.cleaned_data["user_id"] self.success_url = reverse(
"counter:click",
kwargs={
"counter_id": self.kwargs["counter_id"],
"user_id": form.cleaned_data["user_id"],
},
)
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs)
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView): class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
"""Provide the last operations to allow barmen to delete them.""" """Provide the last operations to allow barmen to delete them."""