Merge branch 'nfc_card' into 'master'

Can identify user on counter with student card UID

See merge request ae/Sith!172
This commit is contained in:
Antoine Bartuccio 2019-05-23 19:49:45 +02:00
commit 0f832a2774
10 changed files with 804 additions and 276 deletions

View File

@ -48,7 +48,7 @@ from accounting.models import (
from core.utils import resize_image
from club.models import Club, Membership
from subscription.models import Subscription
from counter.models import Customer, ProductType, Product, Counter, Selling
from counter.models import Customer, ProductType, Product, Counter, Selling, StudentCard
from com.models import Sith, Weekmail, News, NewsDate
from election.models import Election, Role, Candidature, ElectionList
from forum.models import Forum, ForumTopic
@ -870,6 +870,7 @@ Welcome to the wiki page!
start=s.subscription_start,
)
s.save()
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
# Adding subscription for Krophil
s = Subscription(
member=User.objects.filter(pk=krophil.pk).first(),

View File

@ -22,6 +22,24 @@
<p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a></p>
{% endif %}
{% if profile.customer %}
<h4>{% trans %}Student cards{% endtrans %}</h4>
<p>{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}</p>
<form action="{{ url('counter:add_student_card', customer_id=profile.customer.pk) }}" method="post">
{% csrf_token %}
{{ student_card_form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% if profile.customer.student_cards.exists() %}
<ul>
{% for card in profile.customer.student_cards.all() %}
<li>{{ card.uid }} - <a href="{{ url('counter:delete_student_card', customer_id=profile.customer.pk, card_id=card.id) }}">{% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% else %}
<p>{% trans %}No student cards registered.{% endtrans %}</p>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -64,6 +64,7 @@ from core.views.forms import (
)
from core.models import User, SithFile, Preferences, Gift
from subscription.models import Subscription
from counter.views import StudentCardForm
from trombi.views import UserTrombiForm
@ -741,6 +742,8 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
kwargs = super(UserPreferencesView, self).get_context_data(**kwargs)
if not hasattr(self.object, "trombi_user"):
kwargs["trombi_form"] = UserTrombiForm()
if self.object.customer:
kwargs["student_card_form"] = StudentCardForm()
return kwargs

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-10-18 23:15
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [("counter", "0016_producttype_comment")]
operations = [
migrations.CreateModel(
name="StudentCard",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uid",
models.CharField(
max_length=14,
unique=True,
validators=[django.core.validators.MinLengthValidator(4)],
verbose_name="uid",
),
),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="student_cards",
to="counter.Customer",
verbose_name="student cards",
),
),
],
)
]

View File

@ -27,8 +27,10 @@ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.conf import settings
from django.core.urlresolvers import reverse
from django.core.validators import MinLengthValidator
from django.forms import ValidationError
from django.utils.functional import cached_property
from django.core.exceptions import PermissionDenied
from datetime import timedelta, date
import random
@ -732,3 +734,42 @@ class Eticket(models.Model):
return hmac.new(
bytes(self.secret, "utf-8"), bytes(string, "utf-8"), hashlib.sha1
).hexdigest()
class StudentCard(models.Model):
"""
Alternative way to connect a customer into a counter
We are using Mifare DESFire EV1 specs since it's used for izly cards
https://www.nxp.com/docs/en/application-note/AN10927.pdf
UID is 7 byte long that means 14 hexa characters
"""
UID_SIZE = 14
@staticmethod
def is_valid(uid):
return (
uid.isupper()
and len(uid) == StudentCard.UID_SIZE
and not StudentCard.objects.filter(uid=uid).exists()
)
@staticmethod
def can_create(customer, user):
return user.pk == customer.user.pk or user.is_board_member or user.is_root
def can_be_edited_by(self, obj):
if isinstance(obj, User):
return StudentCard.can_create(self.customer, obj)
return False
uid = models.CharField(
_("uid"), max_length=14, unique=True, validators=[MinLengthValidator(4)]
)
customer = models.ForeignKey(
Customer,
related_name="student_cards",
verbose_name=_("student cards"),
null=False,
blank=False,
)

View File

@ -30,6 +30,26 @@
{{ user_mini_profile(customer.user) }}
{{ user_subscription(customer.user) }}
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
{% csrf_token %}
<input type="hidden" name="action" value="add_student_card">
{% trans %}Add a student card{% endtrans %}
<input type="input" name="student_card_uid" />
{% if request.session['not_valid_student_card_uid'] %}
<p><strong>{% trans %}This is not a valid student card UID{% endtrans %}</strong></p>
{% endif %}
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
<h6>{% trans %}Registered cards{% endtrans %}</h6>
{% if customer.student_cards.exists() %}
<ul>
{% for card in customer.student_cards.all() %}
<li>{{ card.uid }}</li>
{% endfor %}
</ul>
{% else %}
{% trans %}No card registered{% endtrans %}
{% endif %}
</div>
<div id="bar_ui">
<h5>{% trans %}Selling{% endtrans %}</h5>

View File

@ -142,3 +142,243 @@ class BarmanConnectionTest(TestCase):
self.assertFalse(
'<li><a href="/user/1/">S&#39; Kia</a></li>' in str(response_get.content)
)
class StudentCardTest(TestCase):
"""
Tests for adding and deleting Stundent Cards
Test that an user can be found with it's student card
"""
def setUp(self):
call_command("populate")
self.krophil = User.objects.get(username="krophil")
self.sli = User.objects.get(username="sli")
self.counter = Counter.objects.filter(id=2).first()
# Auto login on counter
self.client.post(
reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
)
def test_search_user_with_student_card(self):
response = self.client.post(
reverse("counter:details", args=[self.counter.id]),
{"code": "9A89B82018B0A0"},
)
self.assertEqual(
response.url,
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
)
def test_add_student_card_from_counter(self):
response = self.client.post(
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
{"student_card_uid": "8B90734A802A8F", "action": "add_student_card"},
)
self.assertContains(response, text="8B90734A802A8F")
def test_add_student_card_from_counter_fail(self):
# UID too short
response = self.client.post(
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
{"student_card_uid": "8B90734A802A8", "action": "add_student_card"},
)
self.assertContains(
response, text="Ce n'est pas un UID de carte étudiante valide"
)
# UID too long
response = self.client.post(
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
{"student_card_uid": "8B90734A802A8FA", "action": "add_student_card"},
)
self.assertContains(
response, text="Ce n'est pas un UID de carte étudiante valide"
)
# Test with already existing card
response = self.client.post(
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
{"student_card_uid": "9A89B82018B0A0", "action": "add_student_card"},
)
self.assertContains(
response, text="Ce n'est pas un UID de carte étudiante valide"
)
# Test with lowercase
response = self.client.post(
reverse(
"counter:click",
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
),
{"student_card_uid": "8b90734a802a9f", "action": "add_student_card"},
)
self.assertContains(
response, text="Ce n'est pas un UID de carte étudiante valide"
)
def test_delete_student_card_with_owner(self):
self.client.login(username="sli", password="plop")
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.sli.customer.pk,
"card_id": self.sli.customer.student_cards.first().id,
},
)
)
self.assertFalse(self.sli.customer.student_cards.exists())
def test_delete_student_card_with_board_member(self):
self.client.login(username="skia", password="plop")
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.sli.customer.pk,
"card_id": self.sli.customer.student_cards.first().id,
},
)
)
self.assertFalse(self.sli.customer.student_cards.exists())
def test_delete_student_card_with_root(self):
self.client.login(username="root", password="plop")
self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.sli.customer.pk,
"card_id": self.sli.customer.student_cards.first().id,
},
)
)
self.assertFalse(self.sli.customer.student_cards.exists())
def test_delete_student_card_fail(self):
self.client.login(username="krophil", password="plop")
response = self.client.post(
reverse(
"counter:delete_student_card",
kwargs={
"customer_id": self.sli.customer.pk,
"card_id": self.sli.customer.student_cards.first().id,
},
)
)
self.assertEqual(response.status_code, 403)
self.assertTrue(self.sli.customer.student_cards.exists())
def test_add_student_card_from_user_preferences(self):
# Test with owner of the card
self.client.login(username="sli", password="plop")
self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8F"},
)
response = self.client.get(
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
)
self.assertContains(response, text="8B90734A802A8F")
# Test with board member
self.client.login(username="skia", password="plop")
self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8A"},
)
response = self.client.get(
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
)
self.assertContains(response, text="8B90734A802A8A")
# Test with root
self.client.login(username="root", password="plop")
self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8B"},
)
response = self.client.get(
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
)
self.assertContains(response, text="8B90734A802A8B")
def test_add_student_card_from_user_preferences_fail(self):
self.client.login(username="sli", password="plop")
# UID too short
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8"},
)
self.assertContains(response, text="Cet UID est invalide")
# UID too long
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8FA"},
)
self.assertContains(response, text="Cet UID est invalide")
# Test with already existing card
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "9A89B82018B0A0"},
)
self.assertContains(
response, text="Un objet Student card avec ce champ Uid existe déjà."
)
# Test with lowercase
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8b90734a802a9f"},
)
self.assertContains(response, text="Cet UID est invalide")
# Test with unauthorized user
self.client.login(username="krophil", password="plop")
response = self.client.post(
reverse(
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
),
{"uid": "8B90734A802A8F"},
)
self.assertEqual(response.status_code, 403)

View File

@ -56,6 +56,16 @@ urlpatterns = [
EticketPDFView.as_view(),
name="eticket_pdf",
),
url(
r"^customer/(?P<customer_id>[0-9]+)/card/add$",
StudentCardFormView.as_view(),
name="add_student_card",
),
url(
r"^customer/(?P<customer_id>[0-9]+)/card/delete/(?P<card_id>[0-9]+)/$",
StudentCardDeleteView.as_view(),
name="delete_student_card",
),
url(r"^admin/(?P<counter_id>[0-9]+)$", CounterEditView.as_view(), name="admin"),
url(
r"^admin/(?P<counter_id>[0-9]+)/prop$",

View File

@ -33,6 +33,7 @@ from django.views.generic.edit import (
DeleteView,
ProcessFormView,
FormMixin,
FormView,
)
from django.forms.models import modelform_factory
from django.forms import CheckboxSelectMultiple
@ -50,13 +51,14 @@ from datetime import date, timedelta, datetime
from ajax_select.fields import AutoCompleteSelectField, AutoCompleteSelectMultipleField
from ajax_select import make_ajax_field
from core.views import CanViewMixin, TabedViewMixin
from core.views import CanViewMixin, TabedViewMixin, CanEditMixin
from core.views.forms import LoginForm, SelectDate, SelectDateTime
from core.models import User
from subscription.models import Subscription
from counter.models import (
Counter,
Customer,
StudentCard,
Product,
Selling,
Refilling,
@ -99,6 +101,43 @@ class CounterAdminMixin(View):
return super(CounterAdminMixin, self).dispatch(request, *args, **kwargs)
class StudentCardForm(forms.ModelForm):
"""
Form for adding student cards
Only used for user profile since CounterClick is to complicated
"""
class Meta:
model = StudentCard
fields = ["uid"]
def clean(self):
cleaned_data = super(StudentCardForm, self).clean()
uid = cleaned_data.get("uid", None)
if not uid or not StudentCard.is_valid(uid):
raise forms.ValidationError(_("This UID is invalid"), code="invalid")
return cleaned_data
class StudentCardDeleteView(DeleteView, CanEditMixin):
"""
View used to delete a card from a user
"""
model = StudentCard
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "card_id"
def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
return super(StudentCardDeleteView, self).dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
)
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,
@ -108,7 +147,9 @@ class GetUserForm(forms.Form):
some nickname, first name, or last name (TODO)
"""
code = forms.CharField(label="Code", max_length=10, required=False)
code = forms.CharField(
label="Code", max_length=StudentCard.UID_SIZE, required=False
)
id = AutoCompleteSelectField(
"users", required=False, label=_("Select user"), help_text=None
)
@ -121,9 +162,14 @@ class GetUserForm(forms.Form):
cleaned_data = super(GetUserForm, self).clean()
cus = None
if cleaned_data["code"] != "":
cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
card = StudentCard.objects.filter(uid=cleaned_data["code"])
if card.exists():
cus = card.first().customer
if cus is None:
cus = Customer.objects.filter(
account_id__iexact=cleaned_data["code"]
).first()
elif cleaned_data["id"] is not None:
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
if cus is None or not cus.can_buy:
@ -374,6 +420,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session["too_young"] = False
request.session["not_allowed"] = False
request.session["no_age"] = False
request.session["not_valid_student_card_uid"] = False
if self.object.type != "BAR":
self.operator = request.user
elif self.is_barman_price():
@ -383,6 +430,8 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
if "add_product" in request.POST["action"]:
self.add_product(request)
elif "add_student_card" in request.POST["action"]:
self.add_student_card(request)
elif "del_product" in request.POST["action"]:
self.del_product(request)
elif "refill" in request.POST["action"]:
@ -519,6 +568,27 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
request.session.modified = True
return True
def add_student_card(self, request):
"""
Add a new student card on the customer account
"""
uid = request.POST["student_card_uid"]
uid = str(uid)
if not StudentCard.is_valid(uid):
request.session["not_valid_student_card_uid"] = True
return False
if not (
self.object.type == "BAR"
and "counter_token" in request.session.keys()
and request.session["counter_token"] == self.object.token
and len(self.object.get_barmen_list()) > 0
):
raise PermissionDenied
StudentCard(customer=self.customer, uid=uid).save()
return True
def del_product(self, request):
""" Delete a product from the basket """
pid = str(request.POST["product_id"])
@ -642,6 +712,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
kwargs["basket_total"] = self.sum_basket(self.request)
kwargs["refill_form"] = self.refill_form or RefillForm()
kwargs["categories"] = ProductType.objects.all()
kwargs["student_card_max_uid_size"] = StudentCard.UID_SIZE
return kwargs
@ -1765,3 +1836,29 @@ class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListVie
kwargs = super(CounterRefillingListView, self).get_context_data(**kwargs)
kwargs["counter"] = self.counter
return kwargs
class StudentCardFormView(FormView):
"""
Add a new student card
"""
form_class = StudentCardForm
template_name = "core/create.jinja"
def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
if not StudentCard.can_create(self.customer, request.user):
raise PermissionDenied
return super(StudentCardFormView, self).dispatch(request, *args, **kwargs)
def form_valid(self, form):
data = form.clean()
res = super(FormView, self).form_valid(form)
StudentCard(customer=self.customer, uid=data["uid"]).save()
return res
def get_success_url(self, **kwargs):
return reverse_lazy(
"core:user_prefs", kwargs={"user_id": self.customer.user.pk}
)

File diff suppressed because it is too large Load Diff