mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
102 Commits
dependabot
...
photos
Author | SHA1 | Date | |
---|---|---|---|
5788c1a8b3 | |||
ca31584ce1 | |||
30a8a9dc7c | |||
2e2676fd1f | |||
9c8e3b7cac | |||
c07f0c33cb | |||
7b778d3e6b | |||
4c67bb1e2a
|
|||
96f91138dd
|
|||
36d4a02a45
|
|||
4774a7b741
|
|||
d58c713fc5 | |||
6f48a9a151
|
|||
99be8a56f3 | |||
e04a99cabd | |||
bfea0989fb
|
|||
be32486115 | |||
861447ae36 | |||
5f701d1a17
|
|||
64fd123a85
|
|||
7090254658
|
|||
d80f2e73e8 | |||
ee3646594b | |||
b0d9063153 | |||
0980fccf93
|
|||
fb3fd9536e
|
|||
3892e1cee2
|
|||
c10b488080
|
|||
ad91c8ed4f | |||
3b90bd54fc | |||
350a92bc44 | |||
95b4b0ba03 | |||
bab44b31b1 | |||
f0fa27a8b5 | |||
6d16e35624 | |||
13b892cd01 | |||
54be8addeb | |||
9256aff944 | |||
7c989cd749 | |||
2fc51e9901 | |||
943fb5979d | |||
ff1f1040b6 | |||
fa90477de5 | |||
af613c4cca | |||
13f8b5db61
|
|||
e63a09ee7e
|
|||
6bb6be011c
|
|||
3a5bff8810
|
|||
d1e5c93a08
|
|||
19aac8f302
|
|||
9bd3c618a4
|
|||
68b1a96270
|
|||
48bf72f623
|
|||
1872e4abe5
|
|||
35e96fb875
|
|||
5e953d04fe | |||
cab2adb45d | |||
2ed0fad51a | |||
39422a0cb8 | |||
33ba1e8bbb | |||
5ca1f03d9d | |||
ac0c2c9880 | |||
1a81911cd6 | |||
934d17d9d2 | |||
f647feb8c8 | |||
73e9c3132b | |||
5da417c1a0 | |||
df26ab4d50 | |||
5dc9e24cd0 | |||
7c9a966e54 | |||
a31dc8254a | |||
2a7c1a6438 | |||
8af6af1303 | |||
4f5a69c353 | |||
7296640a8d | |||
e556305062 | |||
e9c956e08c | |||
da56a7f651 | |||
0654dfb05d | |||
2a381101ac | |||
ccd4275b02 | |||
f64409c612 | |||
f352b89fc0 | |||
2ae9baa82f | |||
bc99390b25 | |||
262ed7eb4c | |||
4fa83d0667 | |||
6b55b981ca | |||
e6668728f2 | |||
3431fbf2d1 | |||
77853b808a | |||
05756520a3 | |||
b3eb6a945f | |||
169faec479 | |||
42317bfecc | |||
d6e858e0e3 | |||
d8be9a62b5 | |||
2658244671 | |||
31575d0b64 | |||
4ce885ac6b | |||
5c2f324e13 | |||
ed52a4f828 |
@ -10,6 +10,7 @@ DATABASE_URL=sqlite:///db.sqlite3
|
||||
|
||||
REDIS_PORT=7963
|
||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
||||
TASK_BROKER_URL=redis://127.0.0.1:${REDIS_PORT}/1
|
||||
|
||||
# Used to select which other services to run alongside
|
||||
# manage.py, pytest and runserver
|
||||
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -11,6 +11,7 @@ env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
TASK_BROKER_URL: redis://127.0.0.1:6379/1
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
|
@ -1,10 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.4
|
||||
rev: v0.11.11
|
||||
hooks:
|
||||
- id: ruff # just check the code, and print the errors
|
||||
- id: ruff # actually fix the fixable errors, but print nothing
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
args: ["--fix", "--silent"]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
|
@ -1 +1,2 @@
|
||||
redis: redis-server --port $REDIS_PORT
|
||||
celery: uv run celery -A sith worker --beat -l INFO
|
@ -1,14 +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"
|
||||
#
|
||||
#
|
@ -1,280 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import counter.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"code",
|
||||
models.CharField(
|
||||
max_length=16,
|
||||
verbose_name="code",
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
"^[0-9]*$",
|
||||
"An accounting type code contains only numbers",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"movement_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CREDIT", "Credit"),
|
||||
("DEBIT", "Debit"),
|
||||
("NEUTRAL", "Neutral"),
|
||||
],
|
||||
max_length=12,
|
||||
verbose_name="movement type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "accounting type",
|
||||
"ordering": ["movement_type", "code"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BankAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
(
|
||||
"iban",
|
||||
models.CharField(max_length=255, blank=True, verbose_name="iban"),
|
||||
),
|
||||
(
|
||||
"number",
|
||||
models.CharField(
|
||||
max_length=255, blank=True, verbose_name="account number"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Bank account", "ordering": ["club", "name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubAccount",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Club account",
|
||||
"ordering": ["bank_account", "name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Company",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=60, verbose_name="name")),
|
||||
],
|
||||
options={"verbose_name": "company"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GeneralJournal",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(verbose_name="start date")),
|
||||
(
|
||||
"end_date",
|
||||
models.DateField(
|
||||
null=True, verbose_name="end date", default=None, blank=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=40, verbose_name="name")),
|
||||
(
|
||||
"closed",
|
||||
models.BooleanField(verbose_name="is closed", default=False),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
counter.fields.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
(
|
||||
"effective_amount",
|
||||
counter.fields.CurrencyField(
|
||||
decimal_places=2,
|
||||
default=0,
|
||||
verbose_name="effective_amount",
|
||||
max_digits=12,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "General journal", "ordering": ["-start_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Operation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("number", models.IntegerField(verbose_name="number")),
|
||||
(
|
||||
"amount",
|
||||
counter.fields.CurrencyField(
|
||||
decimal_places=2, max_digits=12, verbose_name="amount"
|
||||
),
|
||||
),
|
||||
("date", models.DateField(verbose_name="date")),
|
||||
("remark", models.CharField(max_length=128, verbose_name="comment")),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHECK", "Check"),
|
||||
("CASH", "Cash"),
|
||||
("TRANSFERT", "Transfert"),
|
||||
("CARD", "Credit card"),
|
||||
],
|
||||
max_length=255,
|
||||
verbose_name="payment method",
|
||||
),
|
||||
),
|
||||
(
|
||||
"cheque_number",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="cheque number",
|
||||
default="",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
("done", models.BooleanField(verbose_name="is done", default=False)),
|
||||
(
|
||||
"target_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("USER", "User"),
|
||||
("CLUB", "Club"),
|
||||
("ACCOUNT", "Account"),
|
||||
("COMPANY", "Company"),
|
||||
("OTHER", "Other"),
|
||||
],
|
||||
max_length=10,
|
||||
verbose_name="target type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
models.IntegerField(
|
||||
null=True, verbose_name="target id", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"target_label",
|
||||
models.CharField(
|
||||
max_length=32,
|
||||
blank=True,
|
||||
verbose_name="target label",
|
||||
default="",
|
||||
),
|
||||
),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="accounting type",
|
||||
to="accounting.AccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-number"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SimplifiedAccountingType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("label", models.CharField(max_length=128, verbose_name="label")),
|
||||
(
|
||||
"accounting_type",
|
||||
models.ForeignKey(
|
||||
verbose_name="simplified accounting types",
|
||||
to="accounting.AccountingType",
|
||||
related_name="simplified_types",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "simplified type",
|
||||
"ordering": ["accounting_type__movement_type", "accounting_type__code"],
|
||||
},
|
||||
),
|
||||
]
|
@ -1,105 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0001_initial"),
|
||||
("accounting", "0001_initial"),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="invoice",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="invoice",
|
||||
to="core.SithFile",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="journal",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="journal",
|
||||
to="accounting.GeneralJournal",
|
||||
related_name="operations",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="linked_operation",
|
||||
field=models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
blank=True,
|
||||
to="accounting.Operation",
|
||||
null=True,
|
||||
related_name="operation_linked_to",
|
||||
verbose_name="linked operation",
|
||||
default=None,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="simpleaccounting_type",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
related_name="operations",
|
||||
verbose_name="simple type",
|
||||
to="accounting.SimplifiedAccountingType",
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="generaljournal",
|
||||
name="club_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
related_name="journals",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="bank_account",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="bank account",
|
||||
to="accounting.BankAccount",
|
||||
related_name="club_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="clubaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="club_account",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bankaccount",
|
||||
name="club",
|
||||
field=models.ForeignKey(
|
||||
verbose_name="club",
|
||||
to="club.Club",
|
||||
related_name="bank_accounts",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="operation", unique_together={("number", "journal")}
|
||||
),
|
||||
]
|
@ -1,48 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0002_auto_20160824_2152")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="city",
|
||||
field=models.CharField(blank=True, verbose_name="city", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="country",
|
||||
field=models.CharField(blank=True, verbose_name="country", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="email",
|
||||
field=models.EmailField(blank=True, verbose_name="email", max_length=254),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="phone",
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(
|
||||
blank=True, verbose_name="phone", max_length=128
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="postcode",
|
||||
field=models.CharField(blank=True, verbose_name="postcode", max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="street",
|
||||
field=models.CharField(blank=True, verbose_name="street", max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="website",
|
||||
field=models.CharField(blank=True, verbose_name="website", max_length=64),
|
||||
),
|
||||
]
|
@ -1,50 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0003_auto_20160824_2203")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Label",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
primary_key=True,
|
||||
auto_created=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="label")),
|
||||
(
|
||||
"club_account",
|
||||
models.ForeignKey(
|
||||
related_name="labels",
|
||||
verbose_name="club account",
|
||||
to="accounting.ClubAccount",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="operation",
|
||||
name="label",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="operations",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="label",
|
||||
to="accounting.Label",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="label", unique_together={("name", "club_account")}
|
||||
),
|
||||
]
|
@ -1,17 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0004_auto_20161005_1505")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="operation",
|
||||
name="remark",
|
||||
field=models.CharField(
|
||||
null=True, max_length=128, blank=True, verbose_name="comment"
|
||||
),
|
||||
)
|
||||
]
|
@ -1,34 +0,0 @@
|
||||
# Generated by Django 4.2.20 on 2025-03-14 16:06
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounting", "0005_auto_20170324_0917")]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="bankaccount", name="club"),
|
||||
migrations.RemoveField(model_name="clubaccount", name="bank_account"),
|
||||
migrations.RemoveField(model_name="clubaccount", name="club"),
|
||||
migrations.DeleteModel(name="Company"),
|
||||
migrations.RemoveField(model_name="generaljournal", name="club_account"),
|
||||
migrations.AlterUniqueTogether(name="label", unique_together=None),
|
||||
migrations.RemoveField(model_name="label", name="club_account"),
|
||||
migrations.AlterUniqueTogether(name="operation", unique_together=None),
|
||||
migrations.RemoveField(model_name="operation", name="accounting_type"),
|
||||
migrations.RemoveField(model_name="operation", name="invoice"),
|
||||
migrations.RemoveField(model_name="operation", name="journal"),
|
||||
migrations.RemoveField(model_name="operation", name="label"),
|
||||
migrations.RemoveField(model_name="operation", name="linked_operation"),
|
||||
migrations.RemoveField(model_name="operation", name="simpleaccounting_type"),
|
||||
migrations.RemoveField(
|
||||
model_name="simplifiedaccountingtype", name="accounting_type"
|
||||
),
|
||||
migrations.DeleteModel(name="AccountingType"),
|
||||
migrations.DeleteModel(name="BankAccount"),
|
||||
migrations.DeleteModel(name="ClubAccount"),
|
||||
migrations.DeleteModel(name="GeneralJournal"),
|
||||
migrations.DeleteModel(name="Label"),
|
||||
migrations.DeleteModel(name="Operation"),
|
||||
migrations.DeleteModel(name="SimplifiedAccountingType"),
|
||||
]
|
@ -1,14 +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"
|
||||
#
|
||||
#
|
@ -24,13 +24,15 @@
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDate, SelectDateTime
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
||||
from counter.models import Counter
|
||||
from counter.models import Counter, Selling
|
||||
|
||||
|
||||
class ClubEditForm(forms.ModelForm):
|
||||
@ -159,12 +161,20 @@ class SellingsForm(forms.Form):
|
||||
label=_("End date"), widget=SelectDateTime, required=False
|
||||
)
|
||||
|
||||
counters = forms.ModelMultipleChoiceField(
|
||||
Counter.objects.order_by("name").all(), label=_("Counter"), required=False
|
||||
)
|
||||
|
||||
def __init__(self, club, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
counters_qs = (
|
||||
Counter.objects.filter(
|
||||
Q(club=club)
|
||||
| Q(products__club=club)
|
||||
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Lower("name"))
|
||||
)
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
self.fields["products"] = forms.ModelMultipleChoiceField(
|
||||
club.products.order_by("name").filter(archived=False).all(),
|
||||
label=_("Products"),
|
||||
|
@ -16,22 +16,13 @@
|
||||
</ul>
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
||||
{% for l in Launderette.objects.all() %}
|
||||
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
|
||||
{% endfor %}
|
||||
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
||||
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -3,8 +3,11 @@ from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -14,3 +17,22 @@ def test_sales_page_doesnt_crash(client: Client):
|
||||
client.force_login(admin)
|
||||
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sales_form_counter_filter():
|
||||
"""Test that counters are properly filtered in SellingsForm"""
|
||||
club = baker.make(Club)
|
||||
counters = baker.make(
|
||||
Counter, _quantity=5, _bulk_create=True, name=iter(["Z", "a", "B", "e", "f"])
|
||||
)
|
||||
counters[0].club = club
|
||||
counters[0].save()
|
||||
sale_recipe.make(
|
||||
counter=counters[1], club=club, unit_price=0, customer=baker.make(Customer)
|
||||
)
|
||||
product_recipe.make(counters=[counters[2]], club=club)
|
||||
|
||||
form = SellingsForm(club)
|
||||
form_counters = list(form.fields["counters"].queryset)
|
||||
assert form_counters == [counters[1], counters[2], counters[0]]
|
||||
|
@ -1,6 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.cache import add_never_cache_headers
|
||||
from ninja import Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
@ -18,7 +19,9 @@ from core.views.files import send_raw_file
|
||||
class CalendarController(ControllerBase):
|
||||
@route.get("/internal.ics", url_name="calendar_internal")
|
||||
def calendar_internal(self):
|
||||
return send_raw_file(IcsCalendar.get_internal())
|
||||
response = send_raw_file(IcsCalendar.get_internal())
|
||||
add_never_cache_headers(response)
|
||||
return response
|
||||
|
||||
@route.get(
|
||||
"/unpublished.ics",
|
||||
@ -26,10 +29,12 @@ class CalendarController(ControllerBase):
|
||||
url_name="calendar_unpublished",
|
||||
)
|
||||
def calendar_unpublished(self):
|
||||
return HttpResponse(
|
||||
response = HttpResponse(
|
||||
IcsCalendar.get_unpublished(self.context.request.user),
|
||||
content_type="text/calendar",
|
||||
)
|
||||
add_never_cache_headers(response)
|
||||
return response
|
||||
|
||||
|
||||
@api_controller("/news")
|
||||
|
@ -160,9 +160,12 @@ class News(models.Model):
|
||||
)
|
||||
|
||||
|
||||
def news_notification_callback(notif):
|
||||
def news_notification_callback(notif: Notification):
|
||||
# the NewsDate linked to the News
|
||||
# which creation triggered this callback may not exist yet,
|
||||
# so it's important to filter by "not past date" rather than by "future date"
|
||||
count = News.objects.filter(
|
||||
dates__start_date__gt=timezone.now(), is_published=False
|
||||
~Q(dates__start_date__gt=timezone.now()), is_published=False
|
||||
).count()
|
||||
if count:
|
||||
notif.viewed = False
|
||||
|
@ -17,11 +17,12 @@ import {
|
||||
|
||||
@registerComponent("ics-calendar")
|
||||
export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
static observedAttributes = ["locale", "can_moderate", "can_delete"];
|
||||
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
|
||||
private calendar: Calendar;
|
||||
private locale = "en";
|
||||
private canModerate = false;
|
||||
private canDelete = false;
|
||||
private helpUrl = "";
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||
if (name === "locale") {
|
||||
@ -33,6 +34,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (name === "can_delete") {
|
||||
this.canDelete = newValue.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
if (name === "ics-help-url") {
|
||||
this.helpUrl = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
@ -48,11 +53,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (this.isMobile()) {
|
||||
return {
|
||||
start: "",
|
||||
center: "getCalendarLink",
|
||||
center: "getCalendarLink helpButton",
|
||||
end: "",
|
||||
};
|
||||
}
|
||||
return { start: "getCalendarLink", center: "", end: "" };
|
||||
return { start: "getCalendarLink helpButton", center: "", end: "" };
|
||||
}
|
||||
|
||||
currentHeaderToolbar() {
|
||||
@ -87,15 +92,8 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
);
|
||||
}
|
||||
|
||||
async refreshEvents() {
|
||||
refreshEvents() {
|
||||
this.click(); // Remove focus from popup
|
||||
// We can't just refresh events because some ics files are in
|
||||
// local browser cache (especially internal.ics)
|
||||
// To invalidate the cache, we need to remove the source and add it again
|
||||
this.calendar.removeAllEventSources();
|
||||
for (const source of await this.getEventSources()) {
|
||||
this.calendar.addEventSource(source);
|
||||
}
|
||||
this.calendar.refetchEvents();
|
||||
}
|
||||
|
||||
@ -114,7 +112,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async unpublishNews(id: number) {
|
||||
@ -132,7 +130,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async deleteNews(id: number) {
|
||||
@ -150,22 +148,23 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async getEventSources() {
|
||||
const cacheInvalidate = `?invalidate=${Date.now()}`;
|
||||
return [
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
|
||||
url: `${await makeUrl(calendarCalendarInternal)}`,
|
||||
format: "ics",
|
||||
className: "internal",
|
||||
cache: false,
|
||||
},
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
|
||||
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
||||
format: "ics",
|
||||
color: "red",
|
||||
className: "unpublished",
|
||||
cache: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -320,14 +319,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
click: async (event: Event) => {
|
||||
const button = event.target as HTMLButtonElement;
|
||||
button.classList.add("text-copy");
|
||||
if (!button.hasAttribute("position")) {
|
||||
button.setAttribute("tooltip", gettext("Link copied"));
|
||||
button.setAttribute("position", "top");
|
||||
button.setAttribute("no-hover", "");
|
||||
button.setAttribute("tooltip-class", "calendar-copy-tooltip");
|
||||
if (!button.hasAttribute("tooltip-position")) {
|
||||
button.setAttribute("tooltip-position", "top");
|
||||
}
|
||||
if (button.classList.contains("text-copied")) {
|
||||
button.classList.remove("text-copied");
|
||||
}
|
||||
button.setAttribute("tooltip", gettext("Link copied"));
|
||||
navigator.clipboard.writeText(
|
||||
new URL(
|
||||
await makeUrl(calendarCalendarInternal),
|
||||
@ -335,12 +334,22 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
).toString(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied");
|
||||
button.classList.remove("text-copied");
|
||||
button.classList.add("text-copied");
|
||||
button.classList.remove("text-copy");
|
||||
}, 1500);
|
||||
},
|
||||
},
|
||||
helpButton: {
|
||||
text: "?",
|
||||
hint: gettext("How to use calendar link"),
|
||||
click: () => {
|
||||
if (this.helpUrl) {
|
||||
window.open(this.helpUrl, "_blank");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
height: "auto",
|
||||
locale: this.locale,
|
||||
@ -348,6 +357,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
headerToolbar: this.currentHeaderToolbar(),
|
||||
footerToolbar: this.currentFooterToolbar(),
|
||||
eventSources: await this.getEventSources(),
|
||||
lazyFetching: false,
|
||||
windowResize: () => {
|
||||
this.calendar.changeView(this.currentView());
|
||||
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
|
||||
|
@ -1,4 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
@import "core/static/core/tooltips";
|
||||
|
||||
|
||||
:root {
|
||||
@ -116,8 +117,33 @@ ics-calendar {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
button.text-copied[tooltip]::before {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
.fc .fc-getCalendarLink-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fc .fc-helpButton-button {
|
||||
border-radius: 70%;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transition: 100ms ease-out;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip.text-copied {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
@ -192,6 +192,7 @@
|
||||
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
|
||||
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
|
||||
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
|
||||
ics-help-url="{{ url('core:page', page_name='Index/calendrier')}}"
|
||||
locale="{{ get_language() }}"
|
||||
can_moderate="{{ user.has_perm("com.moderate_news") }}"
|
||||
can_delete="{{ user.has_perm("com.delete_news") }}"
|
||||
|
23
com/tests/test_notifications.py
Normal file
23
com/tests/test_notifications.py
Normal file
@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from model_bakery import baker
|
||||
|
||||
from com.models import News
|
||||
from core.models import Group, Notification, User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_created():
|
||||
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
|
||||
com_admin_group.users.all().delete()
|
||||
Notification.objects.all().delete()
|
||||
com_admin = baker.make(User, groups=[com_admin_group])
|
||||
for i in range(2):
|
||||
# news notifications are permanent, so the notification created
|
||||
# during the first iteration should be reused during the second one.
|
||||
baker.make(News)
|
||||
notifications = list(Notification.objects.all())
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0].user == com_admin
|
||||
assert notifications[0].type == "NEWS_MODERATION"
|
||||
assert notifications[0].param == str(i + 1)
|
@ -88,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SithFile)
|
||||
class SithFileAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||
list_display = ("name", "owner", "size", "date")
|
||||
autocomplete_fields = ("parent", "owner", "moderator")
|
||||
search_fields = ("name", "parent__name")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(OperationLog)
|
||||
|
@ -94,7 +94,7 @@ class SithFileController(ControllerBase):
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||
return SithFile.objects.filter(name__icontains=search)
|
||||
|
||||
|
||||
@api_controller("/group")
|
||||
|
@ -32,7 +32,6 @@ class SithConfig(AppConfig):
|
||||
verbose_name = "Core app of the Sith"
|
||||
|
||||
def ready(self):
|
||||
import core.signals # noqa F401
|
||||
from forum.models import Forum
|
||||
|
||||
cache.clear()
|
||||
|
@ -227,6 +227,19 @@ class FormerSubscriberMixin(AccessMixin):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class IsSubscriberMixin(AccessMixin):
|
||||
"""Check if the user is a subscriber.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: if the user isn't subscribed.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_subscribed:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class PermissionOrAuthorRequiredMixin(PermissionRequiredMixin):
|
||||
"""Require that the user has the required perm or is the object author.
|
||||
|
||||
|
@ -109,7 +109,6 @@ class Command(BaseCommand):
|
||||
p.save(force_lock=True)
|
||||
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||
main_club = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
@ -123,11 +122,6 @@ class Command(BaseCommand):
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
Club.objects.create(
|
||||
id=settings.SITH_LAUNDERETTE_CLUB_ID,
|
||||
name="Laverie",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
|
||||
self.reset_index("club")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||
@ -291,13 +285,7 @@ class Command(BaseCommand):
|
||||
page=services_page,
|
||||
title="Services",
|
||||
author=skia,
|
||||
content="""
|
||||
| | | |
|
||||
| :---: | :---: | :---: |
|
||||
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
|
||||
| SAS | Weekmail | Forum|
|
||||
|
||||
""",
|
||||
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
|
||||
)
|
||||
|
||||
index_page = Page(name="Index")
|
||||
@ -306,23 +294,10 @@ class Command(BaseCommand):
|
||||
page=index_page,
|
||||
title="Wiki index",
|
||||
author=root,
|
||||
content="""
|
||||
Welcome to the wiki page!
|
||||
""",
|
||||
content="Welcome to the wiki page!",
|
||||
)
|
||||
|
||||
laundry_page = Page(name="launderette")
|
||||
laundry_page.save(force_lock=True)
|
||||
PageRev.objects.create(
|
||||
page=laundry_page,
|
||||
title="Laverie",
|
||||
author=root,
|
||||
content="Fonctionnement de la laverie",
|
||||
)
|
||||
|
||||
groups.public.viewable_page.set(
|
||||
[syntax_page, services_page, index_page, laundry_page]
|
||||
)
|
||||
groups.public.viewable_page.set([syntax_page, services_page, index_page])
|
||||
|
||||
self._create_subscription(root)
|
||||
self._create_subscription(skia)
|
||||
@ -716,33 +691,21 @@ Welcome to the wiki page!
|
||||
# SAS
|
||||
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
||||
if f.is_dir():
|
||||
album = Album(
|
||||
parent=sas,
|
||||
name=f.name,
|
||||
owner=root,
|
||||
is_folder=True,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
)
|
||||
album.clean()
|
||||
album.save()
|
||||
album = Album.objects.create(name=f.name, is_moderated=True)
|
||||
for p in f.iterdir():
|
||||
file = resize_image(Image.open(p), 1000, "WEBP")
|
||||
pict = Picture(
|
||||
parent=album,
|
||||
name=p.name,
|
||||
file=file,
|
||||
original=file,
|
||||
owner=root,
|
||||
is_folder=False,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
mime_type="image/webp",
|
||||
size=file.size,
|
||||
)
|
||||
pict.file.name = p.name
|
||||
pict.full_clean()
|
||||
pict.original.name = pict.name
|
||||
pict.generate_thumbnails()
|
||||
pict.full_clean()
|
||||
pict.save()
|
||||
album.generate_thumbnail()
|
||||
|
||||
img_skia = Picture.objects.get(name="skia.jpg")
|
||||
img_sli = Picture.objects.get(name="sli.jpg")
|
||||
@ -839,8 +802,7 @@ Welcome to the wiki page!
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(content_type__app_label="accounting")
|
||||
| Q(
|
||||
Q(
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
@ -869,7 +831,7 @@ Welcome to the wiki page!
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(content_type__app_label__in=["counter", "launderette"])
|
||||
Q(content_type__app_label__in=["counter"])
|
||||
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
||||
)
|
||||
)
|
||||
|
27
core/migrations/0046_remove_sithfiles.py
Normal file
27
core/migrations/0046_remove_sithfiles.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-26 15:01
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import core.models
|
||||
|
||||
|
||||
def remove_sas_sithfiles(apps: StateApps, schema_editor):
|
||||
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
|
||||
SithFile.objects.filter(is_in_sas=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0045_quickuploadimage"),
|
||||
("sas", "0006_alter_peoplepicturerelation_picture_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
|
||||
)
|
||||
]
|
9
core/migrations/0047_remove_sithfile_is_in_sas.py
Normal file
9
core/migrations/0047_remove_sithfile_is_in_sas.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Generated by Django 4.2.17 on 2025-02-14 11:58
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0046_remove_sithfiles")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
|
@ -23,7 +23,6 @@
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import string
|
||||
@ -51,6 +50,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.timezone import localdate, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
@ -341,8 +341,8 @@ class User(AbstractUser):
|
||||
return reverse("core:user_profile", kwargs={"user_id": self.pk})
|
||||
|
||||
def promo_has_logo(self) -> bool:
|
||||
return Path(
|
||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
||||
return (
|
||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo:02d}.png"
|
||||
).exists()
|
||||
|
||||
@cached_property
|
||||
@ -396,19 +396,10 @@ class User(AbstractUser):
|
||||
return self.is_root
|
||||
return group in self.cached_groups
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def cached_groups(self) -> list[Group]:
|
||||
"""Get the list of groups this user is in.
|
||||
|
||||
The result is cached for the default duration (should be 5 minutes)
|
||||
|
||||
Returns: A list of all the groups this user is in.
|
||||
"""
|
||||
groups = cache.get(f"user_{self.id}_groups")
|
||||
if groups is None:
|
||||
groups = list(self.groups.all())
|
||||
cache.set(f"user_{self.id}_groups", groups)
|
||||
return groups
|
||||
"""Get the list of groups this user is in."""
|
||||
return list(self.groups.all())
|
||||
|
||||
@cached_property
|
||||
def is_root(self) -> bool:
|
||||
@ -421,14 +412,6 @@ class User(AbstractUser):
|
||||
def is_board_member(self) -> bool:
|
||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def is_launderette_manager(self):
|
||||
from club.models import Club
|
||||
|
||||
return Club.objects.get(
|
||||
id=settings.SITH_LAUNDERETTE_CLUB_ID
|
||||
).get_membership_for(self)
|
||||
|
||||
@cached_property
|
||||
def is_banned_alcohol(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||
@ -676,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
def is_board_member(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_launderette_manager(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_banned_alcohol(self):
|
||||
return False
|
||||
@ -867,9 +846,6 @@ class SithFile(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||
is_in_sas = models.BooleanField(
|
||||
_("is in the SAS"), default=False, db_index=True
|
||||
) # Allows to query this flag, updated at each call to save()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("file")
|
||||
@ -878,22 +854,10 @@ class SithFile(models.Model):
|
||||
return self.get_parent_path() + "/" + self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
|
||||
self.is_in_sas = sas in self.get_parent_list() or self == sas
|
||||
adding = self._state.adding
|
||||
super().save(*args, **kwargs)
|
||||
if adding:
|
||||
self.copy_rights()
|
||||
if self.is_in_sas:
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("sas:moderation"),
|
||||
type="SAS_MODERATION",
|
||||
param="1",
|
||||
).save()
|
||||
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
if user.is_anonymous:
|
||||
@ -906,8 +870,6 @@ class SithFile(models.Model):
|
||||
return user.is_board_member
|
||||
if user.is_com_admin:
|
||||
return True
|
||||
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||
return True
|
||||
return user.id == self.owner_id
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
@ -934,8 +896,6 @@ class SithFile(models.Model):
|
||||
super().clean()
|
||||
if "/" in self.name:
|
||||
raise ValidationError(_("Character '/' not authorized in name"))
|
||||
if self == self.parent:
|
||||
raise ValidationError(_("Loop in folder tree"), code="loop")
|
||||
if self == self.parent or (
|
||||
self.parent is not None and self in self.get_parent_list()
|
||||
):
|
||||
@ -1073,18 +1033,6 @@ class SithFile(models.Model):
|
||||
def is_file(self):
|
||||
return not self.is_folder
|
||||
|
||||
@cached_property
|
||||
def as_picture(self):
|
||||
from sas.models import Picture
|
||||
|
||||
return Picture.objects.filter(id=self.id).first()
|
||||
|
||||
@cached_property
|
||||
def as_album(self):
|
||||
from sas.models import Album
|
||||
|
||||
return Album.objects.filter(id=self.id).first()
|
||||
|
||||
def get_parent_list(self):
|
||||
parents = []
|
||||
current = self.parent
|
||||
@ -1473,22 +1421,24 @@ class Notification(models.Model):
|
||||
return self.get_type_display()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.id and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
|
||||
if self._state.adding and self.type in settings.SITH_PERMANENT_NOTIFICATIONS:
|
||||
old_notif = self.user.notifications.filter(type=self.type).last()
|
||||
if old_notif:
|
||||
old_notif.callback()
|
||||
old_notif.save()
|
||||
return
|
||||
# if this permanent notification is the first one,
|
||||
# go into the callback nonetheless, because the logic
|
||||
# to set Notification.param is here
|
||||
# (please don't be mad at me, I'm not the one who cooked this spaghetti)
|
||||
self.callback()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def callback(self):
|
||||
# Get the callback defined in settings to update existing
|
||||
# notifications
|
||||
mod_name, func_name = settings.SITH_PERMANENT_NOTIFICATIONS[self.type].rsplit(
|
||||
".", 1
|
||||
)
|
||||
mod = importlib.import_module(mod_name)
|
||||
getattr(mod, func_name)(self)
|
||||
func_name = settings.SITH_PERMANENT_NOTIFICATIONS.get(self.type)
|
||||
if not func_name:
|
||||
return
|
||||
import_string(func_name)(self)
|
||||
|
||||
|
||||
class Gift(models.Model):
|
||||
|
@ -1,15 +0,0 @@
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.models import User
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=User.groups.through, dispatch_uid="user_groups_changed")
|
||||
def user_groups_changed(sender, instance: User, **kwargs):
|
||||
"""Clear the cached groups of the user."""
|
||||
# As a m2m relationship doesn't live within the model
|
||||
# but rather on an intermediary table, there is no
|
||||
# model method to override, meaning we must use
|
||||
# a signal to invalidate the cache when a user is removed from a group
|
||||
cache.delete(f"user_{instance.pk}_groups")
|
174
core/static/bundled/core/tooltips-index.ts
Normal file
174
core/static/bundled/core/tooltips-index.ts
Normal file
@ -0,0 +1,174 @@
|
||||
import {
|
||||
type Placement,
|
||||
autoPlacement,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
size,
|
||||
} from "@floating-ui/dom";
|
||||
|
||||
/**
|
||||
* Library usage:
|
||||
*
|
||||
* Add a `tooltip` attribute to any html element with it's tooltip text
|
||||
* You can control the position of the tooltp with the `tooltip-position` attribute
|
||||
* Allowed placements are `auto`, `top`, `right`, `bottom`, `left`
|
||||
* You can add `-start` and `-end` to all allowed placement values except for `auto`
|
||||
* Default placement is `auto`
|
||||
* Note: placement are suggestions and the position could change if the popup gets
|
||||
* outside of the screen.
|
||||
*
|
||||
* You can customize your tooltip by passing additional classes or ids to it
|
||||
* You can use `tooltip-class` and `tooltip-id` to add additional elements to the
|
||||
* `class` and `id` attribute of the generated tooltip
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <p tooltip="tooltip text"></p>
|
||||
* <p tooltip="tooltip left" tooltip-position="left"></p>
|
||||
* <div tooltip="tooltip custom class" tooltip-class="custom custom-class"></div>
|
||||
* ```
|
||||
**/
|
||||
|
||||
type Status = "open" | "close";
|
||||
|
||||
const tooltips: Map<HTMLElement, HTMLElement> = new Map();
|
||||
|
||||
function getPosition(element: HTMLElement): Placement | "auto" {
|
||||
const position = element.getAttribute("tooltip-position");
|
||||
if (position) {
|
||||
return position as Placement | "auto";
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function getMiddleware(element: HTMLElement) {
|
||||
const middleware = [offset(6), size()];
|
||||
if (getPosition(element) === "auto") {
|
||||
middleware.push(autoPlacement());
|
||||
} else {
|
||||
middleware.push(flip());
|
||||
}
|
||||
return { middleware: middleware };
|
||||
}
|
||||
|
||||
function getPlacement(element: HTMLElement) {
|
||||
const position = getPosition(element);
|
||||
if (position !== "auto") {
|
||||
return { placement: position };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function createTooltip(element: HTMLElement) {
|
||||
const tooltip = document.createElement("div");
|
||||
document.body.append(tooltip);
|
||||
tooltips.set(element, tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
|
||||
// Update tooltip status and set it's attributes and content
|
||||
tooltip.setAttribute("tooltip-status", status);
|
||||
tooltip.innerText = element.getAttribute("tooltip");
|
||||
|
||||
for (const attributes of [
|
||||
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
|
||||
{ src: "tooltip-id", dst: "id", default: [] },
|
||||
]) {
|
||||
const populated = attributes.default;
|
||||
if (element.hasAttribute(attributes.src)) {
|
||||
populated.push(...element.getAttribute(attributes.src).split(" "));
|
||||
}
|
||||
tooltip.setAttribute(attributes.dst, populated.join(" "));
|
||||
}
|
||||
}
|
||||
|
||||
function getTooltip(element: HTMLElement) {
|
||||
const tooltip = tooltips.get(element);
|
||||
if (tooltip === undefined) {
|
||||
return createTooltip(element);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function tooltipMouseover(event: MouseEvent) {
|
||||
// We get the closest tooltip to have a consistent behavior
|
||||
// when hovering over a child element of a tooltip marked element
|
||||
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||
const tooltip = getTooltip(target);
|
||||
updateTooltip(target, tooltip, "open");
|
||||
|
||||
computePosition(target, tooltip, {
|
||||
...getPlacement(target),
|
||||
...getMiddleware(target),
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(tooltip.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function tooltipMouseout(event: MouseEvent) {
|
||||
// We get the closest tooltip to have a consistent behavior
|
||||
// when hovering over a child element of a tooltip marked element
|
||||
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||
updateTooltip(target, getTooltip(target), "close");
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
for (const el of document.querySelectorAll("[tooltip]")) {
|
||||
el.addEventListener("mouseover", tooltipMouseover);
|
||||
el.addEventListener("mouseout", tooltipMouseout);
|
||||
}
|
||||
});
|
||||
|
||||
// Add / remove callback when tooltip attribute is added / removed
|
||||
new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target as HTMLElement;
|
||||
target.removeEventListener("mouseover", tooltipMouseover);
|
||||
target.removeEventListener("mouseout", tooltipMouseout);
|
||||
if (target.hasAttribute("tooltip")) {
|
||||
target.addEventListener("mouseover", tooltipMouseover);
|
||||
target.addEventListener("mouseout", tooltipMouseout);
|
||||
if (target.matches(":hover")) {
|
||||
target.dispatchEvent(new Event("mouseover", { bubbles: true }));
|
||||
} else {
|
||||
target.dispatchEvent(new Event("mouseout", { bubbles: true }));
|
||||
}
|
||||
} else if (tooltips.has(target)) {
|
||||
// Remove corresponding tooltip
|
||||
tooltips.get(target).remove();
|
||||
tooltips.delete(target);
|
||||
}
|
||||
}
|
||||
}).observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["tooltip", "tooltip-class", "toolitp-position", "tooltip-id"],
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// Remove orphan tooltips
|
||||
new MutationObserver((mutations: MutationRecord[]) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
const target = node as HTMLElement;
|
||||
if (!target.hasAttribute("tooltip")) {
|
||||
continue;
|
||||
}
|
||||
if (tooltips.has(target)) {
|
||||
tooltips.get(target).remove();
|
||||
tooltips.delete(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
89
core/static/core/accordion.scss
Normal file
89
core/static/core/accordion.scss
Normal file
@ -0,0 +1,89 @@
|
||||
details.accordion>summary {
|
||||
margin: 2px 0 0 0;
|
||||
padding: .5em .5em .5em .7em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
}
|
||||
|
||||
details[open].accordion>summary {
|
||||
border: 1px solid #003eff;
|
||||
background: #007fff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
details:not([open]).accordion>summary {
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
|
||||
border: 1px solid #c5c5c5;
|
||||
background: #f6f6f6;
|
||||
color: #454545;
|
||||
}
|
||||
|
||||
details.accordion>summary::before {
|
||||
font-family: FontAwesome;
|
||||
content: '\f0da';
|
||||
margin-right: 5px;
|
||||
transition: 700ms;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
details[open].accordion>summary::before {
|
||||
font-family: FontAwesome;
|
||||
content: '\f0d7';
|
||||
}
|
||||
|
||||
details.accordion>.accordion-content {
|
||||
background: #ffffff;
|
||||
color: #333333;
|
||||
padding: 1em 2.2em;
|
||||
border: 1px solid #dddddd;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin animation($selector) {
|
||||
details.accordion#{$selector} {
|
||||
opacity: 0;
|
||||
|
||||
@supports (max-height: calc-size(max-content, size)) {
|
||||
max-height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
details[open].accordion#{$selector} {
|
||||
opacity: 1;
|
||||
|
||||
// Setting a transition on all states of the content
|
||||
// will create a strange behavior where the transition
|
||||
// continues without being shown, creating inconsistenties
|
||||
transition: all 300ms ease-out;
|
||||
|
||||
@supports (max-height: calc-size(max-content, size)) {
|
||||
max-height: calc-size(max-content, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ::details-content isn't available on firefox yet
|
||||
// we use .accordion-content as a workaround
|
||||
// But we need to use ::details-content for chrome because it's
|
||||
// not working correctly otherwise
|
||||
// it only happen in chrome, not safari or firefox
|
||||
// Note: `selector` is not supported by scss so we comment it out to
|
||||
// avoid compiling it and sending it straight to the css
|
||||
// This is a trick that comes from here :
|
||||
// https://stackoverflow.com/questions/62665318/using-supports-selector-despite-sass-not-supporting-it
|
||||
@supports #{'selector(details::details-content)'} {
|
||||
@include animation("::details-content")
|
||||
}
|
||||
|
||||
@supports #{'not selector(details::details-content)'} {
|
||||
@include animation(">.accordion-content")
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 27 KiB |
@ -1,5 +1,7 @@
|
||||
@import "colors";
|
||||
|
||||
$desktop-size: 500px;
|
||||
|
||||
nav.navbar {
|
||||
background-color: $primary-dark-color;
|
||||
margin: 1em;
|
||||
@ -7,15 +9,24 @@ nav.navbar {
|
||||
border-radius: 0.6em;
|
||||
min-height: 40px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: $desktop-size) {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
margin: .2em;
|
||||
|
||||
>.content[mobile-display="hidden"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
>.content[mobile-display="revealed"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> .expand-button {
|
||||
|
||||
>.expand-button {
|
||||
background-color: transparent;
|
||||
display: none;
|
||||
position: relative;
|
||||
@ -27,27 +38,27 @@ nav.navbar {
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
|
||||
> i {
|
||||
>i {
|
||||
font-size: 1.5em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: $desktop-size) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
@media (min-width: 500px) {display: flex;
|
||||
>.content {
|
||||
@media (min-width: $desktop-size) {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> .menu,
|
||||
> .link {
|
||||
>.menu,
|
||||
>.link {
|
||||
box-sizing: border-box;
|
||||
width: 130px;
|
||||
height: 52px;
|
||||
@ -56,7 +67,7 @@ nav.navbar {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: $desktop-size) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
@ -64,80 +75,75 @@ nav.navbar {
|
||||
&:last-child {
|
||||
border-radius: 0 0 .6em .6em;
|
||||
|
||||
> .content {
|
||||
>.content {
|
||||
box-shadow: 3px 3px 3px 0 #dfdfdf;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .menu > .head,
|
||||
> .link {
|
||||
>.menu>.head,
|
||||
>.link {
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
>.menu>summary,
|
||||
>.link {
|
||||
@media (max-width: $desktop-size) {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
>.link {
|
||||
padding: 10px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
.link:hover,
|
||||
.menu:hover {
|
||||
background-color: rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
> .menu > .head,
|
||||
> .link {
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
box-sizing: border-box;
|
||||
details.menu {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
align-items: center;
|
||||
display: inline-block;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
padding: 10px;
|
||||
summary {
|
||||
list-style: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
|
||||
@media (min-width: $desktop-size) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.menu:hover {
|
||||
background-color: rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
> .menu:hover > .content,
|
||||
> .menu > .head:hover + .content,
|
||||
> .menu > .content:hover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> .menu {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
> .content {
|
||||
z-index: 10;
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
width: 130px;
|
||||
box-shadow: 3px 3px 3px 0 #dfdfdf;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
position: absolute;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
box-shadow: inset 3px 3px 3px 0 #dfdfdf;
|
||||
ul.content {
|
||||
list-style-type: none;
|
||||
background: white;
|
||||
margin: 0;
|
||||
|
||||
@media (min-width: $desktop-size) {
|
||||
box-shadow: 3px 3px 3px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
> li > a {
|
||||
>li>a {
|
||||
display: flex;
|
||||
padding: 15px 20px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
@media (max-width: $desktop-size) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
@ -45,63 +45,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
[tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[tooltip]::before {
|
||||
@include shadow;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: attr(tooltip);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 0.5px solid hsl(0, 0%, 50%);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
top: 120%; // Put the tooltip under the element
|
||||
}
|
||||
|
||||
[tooltip]:hover::before {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
[no-hover][tooltip]::before {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
[position="top"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 120%;
|
||||
}
|
||||
|
||||
[position="bottom"][tooltip]::before {
|
||||
top: 120%;
|
||||
bottom: initial;
|
||||
}
|
||||
|
||||
[position="left"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 0%;
|
||||
left: initial;
|
||||
right: 65%;
|
||||
}
|
||||
|
||||
[position="right"][tooltip]::before {
|
||||
top: initial;
|
||||
bottom: 0%;
|
||||
left: 150%;
|
||||
right: initial;
|
||||
}
|
||||
|
||||
.ib {
|
||||
display: inline-block;
|
||||
padding: 1px;
|
||||
@ -361,19 +304,22 @@ body {
|
||||
align-items: center;
|
||||
text-align: justify;
|
||||
|
||||
&.alert-yellow {
|
||||
&.alert-yellow,
|
||||
&.alert-warning {
|
||||
background-color: rgb(255, 255, 240);
|
||||
color: rgb(99, 87, 6);
|
||||
border: rgb(192, 180, 16) 1px solid;
|
||||
}
|
||||
|
||||
&.alert-green {
|
||||
&.alert-green,
|
||||
&.alert-success {
|
||||
background-color: rgb(245, 255, 245);
|
||||
color: rgb(3, 84, 63);
|
||||
border: rgb(14, 159, 110) 1px solid;
|
||||
}
|
||||
|
||||
&.alert-red {
|
||||
&.alert-red,
|
||||
&.alert-error {
|
||||
background-color: rgb(255, 245, 245);
|
||||
color: #c53030;
|
||||
border: #fc8181 1px solid;
|
||||
@ -523,33 +469,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/*---------------------------ACCOUNTING----------------------------*/
|
||||
#accounting {
|
||||
.journal-table {
|
||||
tbody {
|
||||
.neg-amount {
|
||||
color: red;
|
||||
|
||||
&:before {
|
||||
font-family: FontAwesome;
|
||||
font-size: 1em;
|
||||
content: "\f063";
|
||||
}
|
||||
}
|
||||
|
||||
.pos-amount {
|
||||
color: green;
|
||||
|
||||
&:before {
|
||||
font-family: FontAwesome;
|
||||
font-size: 1em;
|
||||
content: "\f062";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*-----------------------------GENERAL-----------------------------*/
|
||||
h1,
|
||||
h2,
|
||||
@ -846,12 +765,6 @@ textarea {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/*---------------------------LAUNDERETTE-------------------------------*/
|
||||
|
||||
#token_form label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/*--------------------------------FOOTER-------------------------------*/
|
||||
|
||||
footer {
|
||||
|
26
core/static/core/tooltips.scss
Normal file
26
core/static/core/tooltips.scss
Normal file
@ -0,0 +1,26 @@
|
||||
@import "colors";
|
||||
|
||||
.tooltip {
|
||||
@include shadow;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 0.5px solid hsl(0, 0%, 50%);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
|
||||
white-space: normal;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.tooltip[tooltip-status=open] {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
@ -24,13 +24,21 @@ body {
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
|
||||
> .title {
|
||||
.alert {
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
>.title {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> div,
|
||||
> form {
|
||||
>div,
|
||||
>form {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -41,8 +49,8 @@ body {
|
||||
max-width: 500px;
|
||||
margin-top: 20px;
|
||||
|
||||
> p,
|
||||
> div {
|
||||
>p,
|
||||
>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@ -50,7 +58,7 @@ body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
> label {
|
||||
>label {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 500px) {
|
||||
@ -59,9 +67,9 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
> input,
|
||||
> p > input,
|
||||
> div > input {
|
||||
>input,
|
||||
>p>input,
|
||||
>div>input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
@ -71,35 +79,35 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
> .errorlist {
|
||||
>.errorlist {
|
||||
color: red;
|
||||
text-align: center;
|
||||
margin: 10px 0 0 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
> .required > .helptext {
|
||||
>.required>.helptext {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
> .required:last-of-type {
|
||||
>.required:last-of-type {
|
||||
box-sizing: border-box;
|
||||
max-width: 300px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
> label {
|
||||
>label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> img {
|
||||
>img {
|
||||
width: 70px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> input {
|
||||
>input {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,12 @@
|
||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
|
||||
@ -24,6 +26,7 @@
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
|
||||
|
||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||
@ -120,10 +123,27 @@
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||
const isMobile = () => {
|
||||
return window.innerWidth >= 500;
|
||||
}
|
||||
for (const item of menuItems){
|
||||
item.addEventListener("mouseover", () => {
|
||||
if (isMobile()){
|
||||
item.setAttribute("open", "");
|
||||
}
|
||||
})
|
||||
item.addEventListener("mouseout", () => {
|
||||
if (isMobile()){
|
||||
item.removeAttribute("open");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
let navbar = document.getElementById("navbar-content");
|
||||
const current = navbar.style.getPropertyValue("display");
|
||||
navbar.style.setProperty("display", current === "none" ? "block" : "none");
|
||||
const current = navbar.getAttribute("mobile-display");
|
||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
@ -1,48 +1,47 @@
|
||||
<nav class="navbar">
|
||||
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
|
||||
<div id="navbar-content" class="content" style="display: none;">
|
||||
<div id="navbar-content" class="content" mobile-display="hidden">
|
||||
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
|
||||
<div class="menu">
|
||||
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<span class="head">{% trans %}Events{% endtrans %}</span>
|
||||
</details>
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Events{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
|
||||
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
|
||||
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
|
||||
<div class="menu">
|
||||
<span class="head">{% trans %}Services{% endtrans %}</span>
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Services{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
||||
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
|
||||
</details>
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}My Benefits{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<span class="head">{% trans %}Help{% endtrans %}</span>
|
||||
</details>
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Help{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -29,14 +29,13 @@
|
||||
<form method="post" action="{{ url('core:login') }}">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||
<br>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
||||
<input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
|
||||
|
@ -86,19 +86,6 @@
|
||||
{% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro show_slots(user) %}
|
||||
{% if user.slots.filter(start_date__gte=timezone.now()).exists() %}
|
||||
<h5>{% trans %}Slot{% endtrans %}</h5>
|
||||
<ul>
|
||||
{% for i in user.slots.filter(start_date__gte=timezone.now().replace(tzinfo=None)).all() %}
|
||||
<li>{{ i.get_type_display() }} - {{i.machine.launderette }}, {{ i.start_date|date("l j") }} :
|
||||
{{ i.start_date|time(DATETIME_FORMAT) }} |
|
||||
<a href="{{ url('launderette:delete_slot', slot_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro show_tokens(user) %}
|
||||
{% if user.tokens.exists() %}
|
||||
<h5>{% trans %}Tokens{% endtrans %}</h5>
|
||||
|
@ -18,7 +18,7 @@
|
||||
<form action="{{ url('core:register') }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% render_honeypot_field %}
|
||||
{{ form }}
|
||||
{{ form.as_p() }}
|
||||
<input type="submit" value="{% trans %}Register{% endtrans %}" />
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% macro monthly(objects) %}
|
||||
<div>
|
||||
<div class="accordion-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -37,22 +37,28 @@
|
||||
{% if customer %}
|
||||
<h3>{% trans %}User account{% endtrans %}</h3>
|
||||
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
|
||||
<div id="drop">
|
||||
{% if buyings_month %}
|
||||
<h5>{% trans %}Account purchases{% endtrans %}</h5>
|
||||
{% if buyings_month %}
|
||||
<details class="accordion" name="account" open>
|
||||
<summary>{% trans %}Account purchases{% endtrans %}</summary>
|
||||
{{ monthly(buyings_month) }}
|
||||
{% endif %}
|
||||
{% if refilling_month %}
|
||||
<h5>{% trans %}Reloads{% endtrans %}</h5>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if refilling_month %}
|
||||
<details class="accordion" name="account">
|
||||
<summary>{% trans %}Reloads{% endtrans %}</summary>
|
||||
{{ monthly(refilling_month) }}
|
||||
{% endif %}
|
||||
{% if invoices_month %}
|
||||
<h5>{% trans %}Eboutic invoices{% endtrans %}</h5>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if invoices_month %}
|
||||
<details class="accordion" name="account">
|
||||
<summary>{% trans %}Eboutic invoices{% endtrans %}</summary>
|
||||
{{ monthly(invoices_month) }}
|
||||
{% endif %}
|
||||
{% if etickets %}
|
||||
<h4>{% trans %}Etickets{% endtrans %}</h4>
|
||||
<div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if etickets %}
|
||||
<details class="accordion" name="account">
|
||||
<summary>{% trans %}Etickets{% endtrans %}</summary>
|
||||
<div class="accordion-content">
|
||||
<ul>
|
||||
{% for s in etickets %}
|
||||
<li>
|
||||
@ -63,22 +69,9 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% trans %}User has no account{% endtrans %}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$(function(){
|
||||
$("#drop").accordion({
|
||||
heightStyle: "content"
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
@ -141,12 +141,6 @@
|
||||
{{ user_subscription(profile) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %}
|
||||
<div>
|
||||
{{ show_tokens(profile) }}
|
||||
{{ show_slots(profile) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>
|
||||
{% trans %}Not subscribed{% endtrans %}
|
||||
@ -260,13 +254,5 @@
|
||||
keys.shift();
|
||||
}
|
||||
});
|
||||
|
||||
$(function () {
|
||||
$("#drop_gifts").accordion({
|
||||
heightStyle: "content",
|
||||
collapsible: true,
|
||||
active: false
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -411,10 +411,11 @@ class TestUserIsInGroup(TestCase):
|
||||
"""Test that the number of db queries is stable
|
||||
and that less queries are made when making a new call.
|
||||
"""
|
||||
# make sure Skia is in at least one group
|
||||
group_in = baker.make(Group)
|
||||
self.public_user.groups.add(group_in)
|
||||
|
||||
# clear the cached property `User.cached_groups`
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
cache.clear()
|
||||
# Test when the user is in the group
|
||||
with self.assertNumQueries(2):
|
||||
@ -423,6 +424,7 @@ class TestUserIsInGroup(TestCase):
|
||||
self.public_user.is_in_group(pk=group_in.id)
|
||||
|
||||
group_not_in = baker.make(Group)
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
cache.clear()
|
||||
# Test when the user is not in the group
|
||||
with self.assertNumQueries(2):
|
||||
@ -447,24 +449,6 @@ class TestUserIsInGroup(TestCase):
|
||||
)
|
||||
assert cached_membership == "not_member"
|
||||
|
||||
def test_cache_properly_cleared_group(self):
|
||||
"""Test that when a user is removed from a group,
|
||||
the is_in_group_method return False when calling it again.
|
||||
"""
|
||||
# testing with pk
|
||||
self.public_user.groups.add(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
||||
|
||||
self.public_user.groups.remove(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
||||
|
||||
# testing with name
|
||||
self.public_user.groups.add(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is True
|
||||
|
||||
self.public_user.groups.remove(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is False
|
||||
|
||||
def test_not_existing_group(self):
|
||||
"""Test that searching for a not existing group
|
||||
returns False.
|
||||
|
@ -5,6 +5,7 @@ from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.test import Client, TestCase
|
||||
@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
|
||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.models import Picture
|
||||
from sith import settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -30,24 +31,19 @@ class TestImageAccess:
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
),
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
||||
"""Test that only authorized users can access the sas image."""
|
||||
user = user_factory()
|
||||
picture: SithFile = baker.make(
|
||||
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
)
|
||||
assert picture.is_owned_by(user)
|
||||
picture = picture_recipe.make()
|
||||
assert user.can_edit(picture)
|
||||
|
||||
def test_sas_image_access_owner(self):
|
||||
"""Test that the owner of the image can access it."""
|
||||
user = baker.make(User)
|
||||
picture: Picture = baker.make(Picture, owner=user)
|
||||
assert picture.is_owned_by(user)
|
||||
picture = picture_recipe.make(owner=user)
|
||||
assert user.can_edit(picture)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_factory",
|
||||
@ -63,7 +59,41 @@ class TestImageAccess:
|
||||
user = user_factory()
|
||||
owner = baker.make(User)
|
||||
picture: Picture = baker.make(Picture, owner=owner)
|
||||
assert not picture.is_owned_by(user)
|
||||
assert not user.can_edit(picture)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserPicture:
|
||||
def test_anonymous_user_unauthorized(self, client):
|
||||
"""An anonymous user shouldn't have access to an user's photo page."""
|
||||
response = client.get(
|
||||
reverse(
|
||||
"core:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "status"),
|
||||
[
|
||||
("guy", 403),
|
||||
("root", 200),
|
||||
("skia", 200),
|
||||
("sli", 200),
|
||||
],
|
||||
)
|
||||
def test_page_is_working(self, client, username, status):
|
||||
"""Only user that subscribed (or admins) should be able to see the page."""
|
||||
# Test for simple user
|
||||
client.force_login(User.objects.get(username=username))
|
||||
response = client.get(
|
||||
reverse(
|
||||
"core:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status
|
||||
|
||||
|
||||
# TODO: many tests on the pages:
|
||||
|
@ -22,6 +22,7 @@ from core.models import Group, User
|
||||
from core.views import UserTabsMixin
|
||||
from counter.models import Counter, Refilling, Selling
|
||||
from eboutic.models import Invoice, InvoiceItem
|
||||
from sas.models import Picture
|
||||
|
||||
|
||||
class TestSearchUsers(TestCase):
|
||||
@ -29,6 +30,7 @@ class TestSearchUsers(TestCase):
|
||||
def setUpTestData(cls):
|
||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||
News.objects.all().delete()
|
||||
Picture.objects.all().delete() # same for pictures
|
||||
User.objects.all().delete()
|
||||
user_recipe = Recipe(
|
||||
User,
|
||||
@ -318,3 +320,27 @@ def test_displayed_other_user_tabs(user_factory, expected_tabs: list[str]):
|
||||
view.object = subscriber_user.make() # user whose page is being seen
|
||||
tabs = [tab["slug"] for tab in view.get_list_of_tabs()]
|
||||
assert tabs == expected_tabs
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRedirectMe:
|
||||
@pytest.mark.parametrize(
|
||||
"route", ["core:user_profile", "core:user_account", "core:user_edit"]
|
||||
)
|
||||
def test_redirect(self, client: Client, route: str):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
target_url = reverse(route, kwargs={"user_id": user.id})
|
||||
src_url = target_url.replace(str(user.id), "me")
|
||||
assertRedirects(client.get(src_url), target_url)
|
||||
|
||||
def test_anonymous_user(self, client: Client):
|
||||
url = reverse("core:user_me_redirect")
|
||||
assertRedirects(client.get(url), reverse("core:login", query={"next": url}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("promo", [7, 22])
|
||||
@pytest.mark.django_db
|
||||
def test_promo_has_logo(promo):
|
||||
user = baker.make(User, promo=promo)
|
||||
assert user.promo_has_logo()
|
||||
|
@ -21,7 +21,6 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
|
||||
from django.urls import path, re_path, register_converter
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
@ -68,6 +67,7 @@ from core.views import (
|
||||
UserGodfathersTreeView,
|
||||
UserGodfathersView,
|
||||
UserListView,
|
||||
UserMeRedirect,
|
||||
UserMiniView,
|
||||
UserPreferencesView,
|
||||
UserStatsView,
|
||||
@ -141,6 +141,12 @@ urlpatterns = [
|
||||
),
|
||||
# User views
|
||||
path("user/", UserListView.as_view(), name="user_list"),
|
||||
path(
|
||||
"user/me/<path:remaining_path>/",
|
||||
UserMeRedirect.as_view(),
|
||||
name="user_me_redirect_with_path",
|
||||
),
|
||||
path("user/me/", UserMeRedirect.as_view(), name="user_me_redirect"),
|
||||
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
|
||||
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
|
||||
path(
|
||||
|
@ -12,18 +12,23 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Final
|
||||
from typing import Any, Final, Unpack
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.db import models
|
||||
from django.forms import BaseForm
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.shortcuts import get_list_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormFragmentTemplateData[T: BaseForm]:
|
||||
"""Dataclass used to pre-render form fragments"""
|
||||
|
||||
form: T
|
||||
template: str
|
||||
context: dict[str, Any]
|
||||
|
||||
def render(self, request: HttpRequest) -> SafeString:
|
||||
# Request is needed for csrf_tokens
|
||||
return render_to_string(
|
||||
self.template, context={"form": self.form, **self.context}, request=request
|
||||
)
|
||||
|
||||
|
||||
def get_start_of_semester(today: date | None = None) -> date:
|
||||
"""Return the date of the start of the semester of the given date.
|
||||
If no date is given, return the start date of the current semester.
|
||||
@ -195,3 +215,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Filterable = models.Model | models.QuerySet | models.Manager
|
||||
ListFilter = dict[str, list | tuple | set]
|
||||
|
||||
|
||||
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
|
||||
"""Use filter() to return a list of objects from a list of unique keys (like ids)
|
||||
or raises Http404 if the list has not the same length as the given one.
|
||||
|
||||
Work like `get_object_or_404()` but for lists of objects, with some caveats :
|
||||
|
||||
- The filter must be a list, a tuple or a set.
|
||||
- There can't be more than exactly one filter.
|
||||
- There must be no duplicate in the filter.
|
||||
- The filter should consist in unique keys (like ids), or it could fail randomly.
|
||||
|
||||
klass may be a Model, Manager, or QuerySet object. All other passed
|
||||
arguments and keyword arguments are used in the filter() query.
|
||||
|
||||
Raises:
|
||||
Http404: If the list is empty or doesn't have as many elements as the keys list.
|
||||
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
|
||||
ValueError: If more than one filter is passed.
|
||||
TypeError: If the given filter is not a list, a tuple or a set.
|
||||
|
||||
Examples:
|
||||
Get all the products with ids 1, 2, 3: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
|
||||
|
||||
Don't work with duplicate ids: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
|
||||
# Raises Http404: "The list of keys must contain no duplicates."
|
||||
"""
|
||||
if len(kwargs) > 1:
|
||||
raise ValueError("get_list_exact_or_404() only accepts one filter.")
|
||||
key, list_filter = next(iter(kwargs.items()))
|
||||
if not isinstance(list_filter, (list, tuple, set)):
|
||||
raise TypeError(
|
||||
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
|
||||
)
|
||||
if len(list_filter) != len(set(list_filter)):
|
||||
raise ValueError("The list of keys must contain no duplicates.")
|
||||
kwargs = {key: list_filter}
|
||||
obj_list = get_list_or_404(klass, **kwargs)
|
||||
if len(obj_list) != len(list_filter):
|
||||
raise Http404(
|
||||
"The given list of keys doesn't match the number of objects found."
|
||||
f"Expected {len(list_filter)} items, got {len(obj_list)}."
|
||||
)
|
||||
return obj_list
|
||||
|
@ -402,7 +402,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
class FileModerationView(AllowFragment, ListView):
|
||||
model = SithFile
|
||||
template_name = "core/file_moderation.jinja"
|
||||
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||
queryset = SithFile.objects.filter(is_moderated=False)
|
||||
ordering = "id"
|
||||
paginate_by = 100
|
||||
|
||||
|
@ -48,6 +48,7 @@ from django.views.generic import (
|
||||
DeleteView,
|
||||
DetailView,
|
||||
ListView,
|
||||
RedirectView,
|
||||
TemplateView,
|
||||
)
|
||||
from django.views.generic.dates import MonthMixin, YearMixin
|
||||
@ -182,6 +183,13 @@ class UserCreationView(FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class UserMeRedirect(LoginRequiredMixin, RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
if remaining := kwargs.get("remaining_path"):
|
||||
return f"/user/{self.request.user.id}/{remaining}/"
|
||||
return f"/user/{self.request.user.id}/"
|
||||
|
||||
|
||||
class UserTabsMixin(TabedViewMixin):
|
||||
def get_tabs_title(self):
|
||||
return self.object.get_display_name()
|
||||
@ -564,10 +572,8 @@ class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, Template
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object = self.request.user
|
||||
from launderette.models import Launderette
|
||||
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["launderettes"] = Launderette.objects.all()
|
||||
kwargs["profile"] = self.request.user
|
||||
kwargs["object"] = self.request.user
|
||||
return kwargs
|
||||
|
112
counter/forms.py
112
counter/forms.py
@ -1,4 +1,7 @@
|
||||
import math
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class ProductForm(forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, required=True)
|
||||
id = forms.IntegerField(min_value=0, required=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise forms.ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise forms.ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise forms.ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
@ -47,6 +47,10 @@ from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||
|
||||
|
||||
class CustomerQuerySet(models.QuerySet):
|
||||
def update_amount(self) -> int:
|
||||
"""Update the amount of all customers selected by this queryset.
|
||||
|
@ -103,7 +103,7 @@ document.addEventListener("alpine:init", () => {
|
||||
this.customerBalance += Number.parseFloat(
|
||||
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||
);
|
||||
document.getElementById("selling-accordion").click();
|
||||
document.getElementById("selling-accordion").setAttribute("open", "");
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
|
||||
@ -139,12 +139,6 @@ document.addEventListener("alpine:init", () => {
|
||||
});
|
||||
|
||||
$(() => {
|
||||
/* Accordion UI between basket and refills */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#click-form") as any).accordion({
|
||||
heightStyle: "content",
|
||||
activate: () => $(".focus").focus(),
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#products") as any).tabs();
|
||||
});
|
||||
|
@ -51,185 +51,192 @@
|
||||
</div>
|
||||
|
||||
<div id="click-form">
|
||||
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
|
||||
<div>
|
||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||
<details class="accordion" id="selling-accordion" name="selling" open>
|
||||
<summary>{% trans %}Selling{% endtrans %}</summary>
|
||||
<div class="accordion-content">
|
||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||
|
||||
<form method="post" action=""
|
||||
class="code_form" @submit.prevent="handleCode">
|
||||
<form method="post" action=""
|
||||
class="code_form" @submit.prevent="handleCode">
|
||||
|
||||
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
|
||||
<option value=""></option>
|
||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
||||
</optgroup>
|
||||
{% for category in categories.keys() %}
|
||||
<optgroup label="{{ category }}">
|
||||
{% for product in categories[category] %}
|
||||
<option value="{{ product.id }}">{{ product }}</option>
|
||||
{% endfor %}
|
||||
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
|
||||
<option value=""></option>
|
||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</counter-product-select>
|
||||
{% for category in categories.keys() %}
|
||||
<optgroup label="{{ category }}">
|
||||
{% for product in categories[category] %}
|
||||
<option value="{{ product.id }}">{{ product }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</counter-product-select>
|
||||
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
|
||||
{% for error in form.non_form_errors() %}
|
||||
<div class="alert alert-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
{% for error in form.non_form_errors() %}
|
||||
<div class="alert alert-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
|
||||
<form x-cloak method="post" action="" x-ref="basketForm">
|
||||
<form x-cloak method="post" action="" x-ref="basketForm">
|
||||
|
||||
<div class="basket-error-container">
|
||||
<div class="basket-error-container">
|
||||
<div
|
||||
x-cloak
|
||||
class="alert alert-red basket-error"
|
||||
x-show="alertMessage.show"
|
||||
x-transition.duration.500ms
|
||||
x-text="alertMessage.content"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
|
||||
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
<button
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.product.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<p class="margin-bottom">
|
||||
<strong>Total: </strong>
|
||||
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
||||
<strong> €</strong>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
class="btn btn-blue"
|
||||
type="submit"
|
||||
@click.prevent="finish"
|
||||
:disabled="getBasketSize() === 0"
|
||||
value="{% trans %}Finish{% endtrans %}"
|
||||
/>
|
||||
<input
|
||||
class="btn btn-grey"
|
||||
type="submit" @click.prevent="cancel"
|
||||
value="{% trans %}Cancel{% endtrans %}"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
<details class="accordion" name="selling">
|
||||
<summary>{% trans %}Refilling{% endtrans %}</summary>
|
||||
{% if object.type == "BAR" %}
|
||||
{% if refilling_fragment %}
|
||||
<div
|
||||
x-cloak
|
||||
class="alert alert-red basket-error"
|
||||
x-show="alertMessage.show"
|
||||
x-transition.duration.500ms
|
||||
x-text="alertMessage.content"
|
||||
></div>
|
||||
</div>
|
||||
class="accordion-content"
|
||||
@htmx:after-request="onRefillingSuccess"
|
||||
>
|
||||
{{ refilling_fragment }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="accordion-content">
|
||||
<p class="alert alert-yellow">
|
||||
{% trans trimmed %}
|
||||
As a barman, you are not able to refill any account on your own.
|
||||
An admin should be connected on this counter for that.
|
||||
The customer can refill by using the eboutic.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if student_card_fragment %}
|
||||
<details class="accordion" name="selling">
|
||||
<summary>{% trans %}Student card{% endtrans %}</summary>
|
||||
<div class="accordion-content">
|
||||
{{ student_card_fragment }}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
|
||||
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
<button
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.product.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<p class="margin-bottom">
|
||||
<strong>Total: </strong>
|
||||
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
||||
<strong> €</strong>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
class="btn btn-blue"
|
||||
type="submit"
|
||||
@click.prevent="finish"
|
||||
:disabled="getBasketSize() === 0"
|
||||
value="{% trans %}Finish{% endtrans %}"
|
||||
/>
|
||||
<input
|
||||
class="btn btn-grey"
|
||||
type="submit" @click.prevent="cancel"
|
||||
value="{% trans %}Cancel{% endtrans %}"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if object.type == "BAR" %}
|
||||
<h5>{% trans %}Refilling{% endtrans %}</h5>
|
||||
{% if refilling_fragment %}
|
||||
<div
|
||||
@htmx:after-request="onRefillingSuccess"
|
||||
>
|
||||
{{ refilling_fragment }}
|
||||
|
||||
<div id="products">
|
||||
{% if not products %}
|
||||
<div class="alert alert-red">
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<p class="alert alert-yellow">
|
||||
{% trans trimmed %}
|
||||
As a barman, you are not able to refill any account on your own.
|
||||
An admin should be connected on this counter for that.
|
||||
The customer can refill by using the eboutic.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if student_card_fragment %}
|
||||
<h5>{% trans %}Student card{% endtrans %}</h3>
|
||||
<div>
|
||||
{{ student_card_fragment }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="products">
|
||||
{% if not products %}
|
||||
<div class="alert alert-red">
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<ul>
|
||||
<ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% endif %}
|
||||
{%- endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
|
@ -5,12 +5,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="accounting">
|
||||
<main>
|
||||
<h3>{% trans %}Refound account{% endtrans %}</h3>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}Refound{% endtrans %}" /></p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
@ -46,6 +46,15 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
def set_age(user: User, age: int):
|
||||
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||
user.save()
|
||||
|
||||
|
||||
def force_refill_user(user: User, amount: Decimal | int):
|
||||
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
|
||||
|
||||
|
||||
class TestFullClickBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
|
||||
cls.banned_counter_customer = subscriber_user.make()
|
||||
cls.banned_alcohol_customer = subscriber_user.make()
|
||||
|
||||
cls.set_age(cls.customer, 20)
|
||||
cls.set_age(cls.barmen, 20)
|
||||
cls.set_age(cls.club_admin, 20)
|
||||
cls.set_age(cls.banned_alcohol_customer, 20)
|
||||
cls.set_age(cls.underage_customer, 17)
|
||||
set_age(cls.customer, 20)
|
||||
set_age(cls.barmen, 20)
|
||||
set_age(cls.club_admin, 20)
|
||||
set_age(cls.banned_alcohol_customer, 20)
|
||||
set_age(cls.underage_customer, 17)
|
||||
|
||||
cls.banned_alcohol_customer.ban_groups.add(
|
||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
|
||||
{"username": used_barman.username, "password": "plop"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_age(cls, user: User, age: int):
|
||||
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||
user.save()
|
||||
|
||||
def submit_basket(
|
||||
self,
|
||||
user: User,
|
||||
@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
|
||||
data,
|
||||
)
|
||||
|
||||
def refill_user(self, user: User, amount: Decimal | int):
|
||||
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
|
||||
|
||||
def test_click_eboutic_failure(self):
|
||||
eboutic = baker.make(Counter, type="EBOUTIC")
|
||||
self.client.force_login(self.club_admin)
|
||||
@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_click_office_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.client.force_login(self.club_admin)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
||||
@ -327,7 +328,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||
|
||||
# Test no special price on office counter
|
||||
self.refill_user(self.club_admin, 10)
|
||||
force_refill_user(self.club_admin, 10)
|
||||
res = self.submit_basket(
|
||||
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
||||
)
|
||||
@ -336,7 +337,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||
|
||||
def test_click_bar_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||
@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
# Test barmen special price
|
||||
|
||||
self.refill_user(self.barmen, 10)
|
||||
force_refill_user(self.barmen, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
||||
@ -356,7 +357,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.barmen) == Decimal("9")
|
||||
|
||||
def test_click_tray_price(self):
|
||||
self.refill_user(self.customer, 20)
|
||||
force_refill_user(self.customer, 20)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
# Not applying tray price
|
||||
@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.login_in_bar()
|
||||
|
||||
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
||||
self.refill_user(user, 10)
|
||||
force_refill_user(user, 10)
|
||||
|
||||
# Buy product without age limit
|
||||
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.banned_counter_customer,
|
||||
self.customer_old_can_not_buy,
|
||||
]:
|
||||
self.refill_user(user, 10)
|
||||
force_refill_user(user, 10)
|
||||
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
assert resp.status_code == 302
|
||||
assert resp.url == resolve_url(self.counter)
|
||||
@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_allowed_old_subscriber(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer_old_can_buy, 10)
|
||||
force_refill_user(self.customer_old_can_buy, 10)
|
||||
res = self.submit_basket(
|
||||
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
||||
)
|
||||
@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_wrong_counter(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
||||
)
|
||||
@ -444,7 +445,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_connected(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_not_in_counter(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_basket_empty(self):
|
||||
force_refill_user(self.customer, 10)
|
||||
|
||||
for basket in [
|
||||
[],
|
||||
[BasketItem(None, None)],
|
||||
[BasketItem(None, None), BasketItem(None, None)],
|
||||
]:
|
||||
assertRedirects(
|
||||
self.submit_basket(self.customer, basket),
|
||||
self.counter.get_absolute_url(),
|
||||
)
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_invalid(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
for item in [
|
||||
@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
|
||||
BasketItem(self.beer.id, -1),
|
||||
BasketItem(None, 1),
|
||||
BasketItem(self.beer.id, None),
|
||||
BasketItem(None, None),
|
||||
]:
|
||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_enough_money(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
res = self.submit_basket(
|
||||
self.customer,
|
||||
@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_recordings(self):
|
||||
self.refill_user(self.customer, self.cons.selling_price * 3)
|
||||
force_refill_user(self.customer, self.cons.selling_price * 3)
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
|
@ -1,5 +1,4 @@
|
||||
import itertools
|
||||
import json
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
@ -16,7 +15,6 @@ from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import User
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Refilling,
|
||||
@ -26,149 +24,6 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBillingInfo:
|
||||
@pytest.fixture
|
||||
def payload(self):
|
||||
return {
|
||||
"first_name": "Subscribed",
|
||||
"last_name": "User",
|
||||
"address_1": "3, rue de Troyes",
|
||||
"zip_code": "34301",
|
||||
"city": "Sète",
|
||||
"country": "FR",
|
||||
"phone_number": "0612345678",
|
||||
}
|
||||
|
||||
def test_edit_infos(self, client: Client, payload: dict):
|
||||
user = subscriber_user.make()
|
||||
baker.make(BillingInfo, customer=user.customer)
|
||||
client.force_login(user)
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
user.refresh_from_db()
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert response.status_code == 200
|
||||
assert hasattr(user.customer, "billing_infos")
|
||||
assert infos.customer == user.customer
|
||||
for key, val in payload.items():
|
||||
assert getattr(infos, key) == val
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_create_infos(self, client: Client, user_maker, payload):
|
||||
user = user_maker()
|
||||
client.force_login(user)
|
||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert hasattr(user, "customer")
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert hasattr(user.customer, "billing_infos")
|
||||
assert infos.customer == user.customer
|
||||
for key, val in payload.items():
|
||||
assert getattr(infos, key) == val
|
||||
|
||||
def test_invalid_data(self, client: Client, payload: dict[str, str]):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
# address_1, zip_code and country are missing
|
||||
del payload["city"]
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
user.customer.refresh_from_db()
|
||||
assert not hasattr(user.customer, "billing_infos")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operator_maker", "expected_code"),
|
||||
[
|
||||
(subscriber_user.make, 403),
|
||||
(lambda: baker.make(User), 403),
|
||||
(lambda: baker.make(User, is_superuser=True), 200),
|
||||
],
|
||||
)
|
||||
def test_edit_other_user(
|
||||
self, client: Client, operator_maker, expected_code: int, payload: dict
|
||||
):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(operator_maker())
|
||||
baker.make(BillingInfo, customer=user.customer)
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == expected_code
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phone_number",
|
||||
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
|
||||
)
|
||||
def test_phone_number_format(
|
||||
self, client: Client, payload: dict, phone_number: str
|
||||
):
|
||||
"""Test that various formats of phone numbers are accepted."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = phone_number
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert infos.phone_number == "0612345678"
|
||||
assert infos.phone_number.country_code == 33
|
||||
|
||||
def test_foreign_phone_number(self, client: Client, payload: dict):
|
||||
"""Test that a foreign phone number is accepted."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = "+49612345678"
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert infos.phone_number.as_national == "06123 45678"
|
||||
assert infos.phone_number.country_code == 49
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
|
||||
)
|
||||
def test_invalid_phone_number(
|
||||
self, client: Client, payload: dict, phone_number: str
|
||||
):
|
||||
"""Test that invalid phone numbers are rejected."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = phone_number
|
||||
response = client.put(
|
||||
reverse("api:put_billing_info", args=[user.id]),
|
||||
json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||
|
||||
|
||||
class TestStudentCard(TestCase):
|
||||
"""Tests for adding and deleting Stundent Cards
|
||||
Test that an user can be found with it's student card.
|
||||
|
@ -12,18 +12,17 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from datetime import datetime
|
||||
from datetime import timezone as tz
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.http import Http404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import UpdateView
|
||||
from django.views.generic.edit import BaseFormView, UpdateView
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from counter.forms import CashSummaryFormBase
|
||||
from counter.models import (
|
||||
CashRegisterSummary,
|
||||
@ -31,9 +30,7 @@ from counter.models import (
|
||||
Counter,
|
||||
Refilling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import (
|
||||
CounterAdminMixin,
|
||||
CounterAdminTabsMixin,
|
||||
CounterTabsMixin,
|
||||
)
|
||||
@ -157,11 +154,9 @@ class CashRegisterSummaryForm(forms.Form):
|
||||
else:
|
||||
self.instance = None
|
||||
|
||||
def save(self, counter=None):
|
||||
def save(self, counter: Counter | None = None, user: User | None = None):
|
||||
cd = self.cleaned_data
|
||||
summary = self.instance or CashRegisterSummary(
|
||||
counter=counter, user=counter.get_random_barman()
|
||||
)
|
||||
summary = self.instance or CashRegisterSummary(counter=counter, user=user)
|
||||
summary.comment = cd["comment"]
|
||||
summary.emptied = cd["emptied"]
|
||||
summary.save()
|
||||
@ -247,48 +242,33 @@ class CashRegisterSummaryForm(forms.Form):
|
||||
summary.delete()
|
||||
|
||||
|
||||
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
class CounterCashSummaryView(
|
||||
CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView
|
||||
):
|
||||
"""Provide the cash summary form."""
|
||||
|
||||
model = Counter
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "counter/cash_register_summary.jinja"
|
||||
current_tab = "cash_summary"
|
||||
permission_required = "counter.add_cashregistersummary"
|
||||
form_class = CashRegisterSummaryForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here again a very particular right handling."""
|
||||
self.object = self.get_object()
|
||||
if is_logged_in_counter(request) and self.object.barmen_list:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return HttpResponseRedirect(
|
||||
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||
+ "?bad_location"
|
||||
)
|
||||
if self.object.type != "BAR":
|
||||
raise Http404
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = CashRegisterSummaryForm()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = CashRegisterSummaryForm(request.POST)
|
||||
if self.form.is_valid():
|
||||
self.form.save(self.object)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
return super().get(request, *args, **kwargs)
|
||||
def form_valid(self, form):
|
||||
form.save(counter=self.object, user=self.request.user)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("counter:details", kwargs={"counter_id": self.object.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add form to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["form"] = self.form
|
||||
return kwargs
|
||||
return reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||
|
||||
|
||||
class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit cash summaries."""
|
||||
|
||||
model = CashRegisterSummary
|
||||
@ -297,12 +277,11 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
pk_url_kwarg = "cashsummary_id"
|
||||
form_class = CashRegisterSummaryForm
|
||||
current_tab = "cash_summary"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("counter:cash_summary_list")
|
||||
permission_required = "counter.change_cashregistersummary"
|
||||
success_url = reverse_lazy("counter:cash_summary_list")
|
||||
|
||||
|
||||
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||
"""Display a list of cash summaries."""
|
||||
|
||||
model = CashRegisterSummary
|
||||
@ -311,6 +290,7 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
current_tab = "cash_summary"
|
||||
queryset = CashRegisterSummary.objects.all().order_by("-date")
|
||||
paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
|
||||
permission_required = "counter.view_cashregistersummary"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add sums to the context."""
|
||||
@ -321,12 +301,12 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
kwargs["refilling_sums"] = {}
|
||||
for c in Counter.objects.filter(type="BAR").all():
|
||||
refillings = Refilling.objects.filter(counter=c)
|
||||
cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)
|
||||
cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
|
||||
if form.is_valid() and form.cleaned_data["begin_date"]:
|
||||
refillings = refillings.filter(
|
||||
date__gte=form.cleaned_data["begin_date"]
|
||||
)
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__gte=form.cleaned_data["begin_date"]
|
||||
)
|
||||
else:
|
||||
@ -337,23 +317,16 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
)
|
||||
if last_summary:
|
||||
refillings = refillings.filter(date__gt=last_summary.date)
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__gt=last_summary.date
|
||||
)
|
||||
else:
|
||||
refillings = refillings.filter(
|
||||
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||
) # My birth date should be old enough
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||
)
|
||||
if form.is_valid() and form.cleaned_data["end_date"]:
|
||||
refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__lte=form.cleaned_data["end_date"]
|
||||
)
|
||||
kwargs["summaries_sums"][c.name] = sum(
|
||||
[s.get_total() for s in cashredistersummaries.all()]
|
||||
[s.get_total() for s in cash_register_summaries.all()]
|
||||
)
|
||||
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
|
||||
return kwargs
|
||||
|
@ -12,23 +12,14 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import math
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.forms import (
|
||||
BaseFormSet,
|
||||
Form,
|
||||
IntegerField,
|
||||
ValidationError,
|
||||
formset_factory,
|
||||
)
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import RefillForm
|
||||
from counter.forms import BasketForm, RefillForm
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
||||
return counter.get_random_barman()
|
||||
|
||||
|
||||
class ProductForm(Form):
|
||||
quantity = IntegerField(min_value=1)
|
||||
id = IntegerField(min_value=0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(BaseFormSet):
|
||||
def clean(self):
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class CounterClick(
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
):
|
||||
|
@ -15,11 +15,12 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.base import View
|
||||
|
||||
from core.views.mixins import TabedViewMixin
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
class CounterAdminMixin(View):
|
||||
@ -49,34 +50,40 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
return self.object
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if self.object.type != "BAR":
|
||||
return []
|
||||
tab_list = [
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "counter",
|
||||
"name": _("Counter"),
|
||||
}
|
||||
]
|
||||
if self.object.type == "BAR":
|
||||
if self.request.user.has_perm("counter.add_cashregistersummary"):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:cash_summary", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "cash_summary",
|
||||
"name": _("Cash summary"),
|
||||
}
|
||||
)
|
||||
if is_logged_in_counter(self.request):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:last_ops", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "last_ops",
|
||||
"name": _("Last operations"),
|
||||
}
|
||||
)
|
||||
if len(tab_list) <= 1:
|
||||
# It would be strange to show only one tab
|
||||
return []
|
||||
return tab_list
|
||||
|
||||
|
||||
|
@ -131,6 +131,31 @@ de données fonctionnent avec l'un comme avec l'autre.
|
||||
Heureusement, et grâce à l'ORM de Django, cette
|
||||
double compatibilité est presque toujours possible.
|
||||
|
||||
### Celery
|
||||
|
||||
[Site officiel](https://docs.celeryq.dev/en/stable/)
|
||||
|
||||
Dans certaines situations, on veut séparer une tâche
|
||||
pour la faire tourner dans son coin.
|
||||
Deux cas qui correspondent à cette situation sont :
|
||||
|
||||
- les tâches longues à exécuter
|
||||
(comme l'envoi de mail ou la génération de documents),
|
||||
pour lesquelles on veut pouvoir dire à l'utilisateur
|
||||
que sa requête a été prise en compte, sans pour autant
|
||||
le faire trop patienter
|
||||
- les tâches régulières séparées du cycle requête/réponse.
|
||||
|
||||
Pour ça, nous utilisons Celery.
|
||||
Grâce à son intégration avec Django,
|
||||
il permet de mettre en place une queue de message
|
||||
avec assez peu complexité ajoutée.
|
||||
|
||||
En outre, ses extensions `django-celery-results`
|
||||
et `django-celery-beat` enrichissent son intégration
|
||||
avec django et offrent des moyens de manipuler certaines
|
||||
tâches directement dans l'interface admin de django.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Jinja2
|
||||
|
@ -1 +0,0 @@
|
||||
::: launderette.models
|
@ -1 +0,0 @@
|
||||
::: launderette.views
|
@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
|
||||
Par défaut, seuls les utilisateurs avec la permission
|
||||
`auth.change_permission` auront accès à ce formulaire
|
||||
(donc, normalement, uniquement les utilisateurs Root).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Utilisateur
|
||||
participant B as ReverseProxy
|
||||
participant C as MarkdownImage
|
||||
participant D as Model
|
||||
|
||||
A->>B: GET /page/foo
|
||||
B->>C: GET /page/foo
|
||||
C-->>B: La page, avec les urls
|
||||
B-->>A: La page, avec les urls
|
||||
alt image publique
|
||||
A->>B: GET markdown/public/2025/img.webp
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt image privée
|
||||
A->>B: GET markdown_image/{id}
|
||||
B->>C: GET markdown_image/{id}
|
||||
C->>D: user.can_view(image)
|
||||
alt l'utilisateur a le droit de voir l'image
|
||||
D-->>C: True
|
||||
C-->>B: 200 (avec le X-Accel-Redirect)
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt l'utilisateur n'a pas le droit de l'image
|
||||
D-->>C: False
|
||||
C-->>B: 403
|
||||
B-->>A: 403
|
||||
end
|
||||
end
|
||||
```
|
||||
|
@ -120,7 +120,7 @@ les conflits avec les instances de redis déjà en fonctionnement.
|
||||
|
||||
```dotenv
|
||||
REDIS_PORT=6379
|
||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
||||
CACHE_URL=redis://127.0.0.1:6379/0
|
||||
```
|
||||
|
||||
Si on souhaite configurer redis pour communiquer via un socket :
|
||||
@ -151,7 +151,7 @@ ALTER ROLE sith SET client_encoding TO 'utf8';
|
||||
ALTER ROLE sith SET default_transaction_isolation TO 'read committed';
|
||||
ALTER ROLE sith SET timezone TO 'UTC';
|
||||
|
||||
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
|
||||
GRANT ALL PRIVILEGES ON DATABASE sith TO sith;
|
||||
\q
|
||||
```
|
||||
|
||||
@ -279,6 +279,26 @@ Toutes les requêtes vers des fichiers statiques et les medias publiques
|
||||
seront servies directement par nginx.
|
||||
Toutes les autres requêtes seront transmises au serveur django.
|
||||
|
||||
## Celery
|
||||
|
||||
Celery ne tourne pas dans django.
|
||||
C'est une application à part, avec ses propres processus,
|
||||
qui tourne de manière indépendante et qui ne communique
|
||||
que par messages avec l'instance de django.
|
||||
|
||||
Pour faire tourner Celery, faites la commande suivante dans
|
||||
un terminal à part :
|
||||
|
||||
```bash
|
||||
uv run celery -A sith worker --beat -l INFO
|
||||
```
|
||||
|
||||
!!!note
|
||||
|
||||
Nous utilisons Redis comme broker pour Celery,
|
||||
donc vous devez aussi configurer l'URL du broker,
|
||||
de la même manière que ce qui est décrit plus haut
|
||||
pour Redis.
|
||||
|
||||
## Mettre à jour la base de données antispam
|
||||
|
||||
|
@ -100,7 +100,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||
installé par uv.
|
||||
|
||||
|
||||
## Finaliser l'installation
|
||||
|
||||
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
|
||||
@ -113,6 +112,7 @@ cd sith
|
||||
# Création de l'environnement et installation des dépendances
|
||||
uv sync
|
||||
npm install # Dépendances frontend
|
||||
cp .env.example .env # Configuration par défaut
|
||||
uv run ./manage.py install_xapian
|
||||
```
|
||||
|
||||
@ -122,17 +122,12 @@ uv run ./manage.py install_xapian
|
||||
de texte à l'écran.
|
||||
C'est normal, il ne faut pas avoir peur.
|
||||
|
||||
Une fois les dépendances installées, il faut encore
|
||||
mettre en place quelques éléments de configuration,
|
||||
qui peuvent varier d'un environnement à l'autre.
|
||||
|
||||
La modification de la configuration passe par la modification de variables
|
||||
d'environnement.
|
||||
Ces variables sont stockées dans un fichier `.env`.
|
||||
Pour le créer, vous pouvez copier le fichier `.env.example` :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Les variables par défaut contenues dans le fichier `.env`
|
||||
Les variables par défaut contenues dans le fichier `.env.example`
|
||||
devraient convenir pour le développement, sans modification.
|
||||
|
||||
Maintenant que les dépendances sont installées
|
||||
|
@ -24,66 +24,62 @@ sith/
|
||||
├── .github/
|
||||
│ ├── actions/ (1)
|
||||
│ └── workflows/ (2)
|
||||
├── accounting/ (3)
|
||||
├── club/ (3)
|
||||
│ └── ...
|
||||
├── club/ (4)
|
||||
├── com/ (4)
|
||||
│ └── ...
|
||||
├── com/ (5)
|
||||
├── core/ (5)
|
||||
│ └── ...
|
||||
├── core/ (6)
|
||||
├── counter/ (6)
|
||||
│ └── ...
|
||||
├── counter/ (7)
|
||||
├── docs/ (7)
|
||||
│ └── ...
|
||||
├── docs/ (8)
|
||||
├── eboutic/ (8)
|
||||
│ └── ...
|
||||
├── eboutic/ (9)
|
||||
├── election/ (9)
|
||||
│ └── ...
|
||||
├── election/ (10)
|
||||
├── forum/ (10)
|
||||
│ └── ...
|
||||
├── forum/ (11)
|
||||
├── galaxy/ (11)
|
||||
│ └── ...
|
||||
├── galaxy/ (12)
|
||||
├── locale/ (12)
|
||||
│ └── ...
|
||||
├── launderette/ (13)
|
||||
├── matmat/ (13)
|
||||
│ └── ...
|
||||
├── locale/ (14)
|
||||
├── pedagogy/ (14)
|
||||
│ └── ...
|
||||
├── matmat/ (15)
|
||||
├── rootplace/ (15)
|
||||
│ └── ...
|
||||
├── pedagogy/ (16)
|
||||
├── sas/ (16)
|
||||
│ └── ...
|
||||
├── rootplace/ (17)
|
||||
├── sith/ (17)
|
||||
│ └── ...
|
||||
├── sas/ (18)
|
||||
├── subscription/ (18)
|
||||
│ └── ...
|
||||
├── sith/ (19)
|
||||
├── trombi/ (19)
|
||||
│ └── ...
|
||||
├── subscription/ (20)
|
||||
├── antispam/ (20)
|
||||
│ └── ...
|
||||
├── trombi/ (21)
|
||||
├── staticfiles/ (21)
|
||||
│ └── ...
|
||||
├── antispam/ (22)
|
||||
│ └── ...
|
||||
├── staticfiles/ (23)
|
||||
│ └── ...
|
||||
├── processes/ (24)
|
||||
├── processes/ (22)
|
||||
│ └── ...
|
||||
│
|
||||
├── .coveragerc (25)
|
||||
├── .envrc (26)
|
||||
├── .coveragerc (23)
|
||||
├── .envrc (24)
|
||||
├── .gitattributes
|
||||
├── .gitignore
|
||||
├── .mailmap
|
||||
├── .env (27)
|
||||
├── .env.example (28)
|
||||
├── manage.py (29)
|
||||
├── mkdocs.yml (30)
|
||||
├── .env (25)
|
||||
├── .env.example (26)
|
||||
├── manage.py (27)
|
||||
├── mkdocs.yml (28)
|
||||
├── uv.lock
|
||||
├── pyproject.toml (31)
|
||||
├── .venv/ (32)
|
||||
├── .python-version (33)
|
||||
├── Procfile.static (34)
|
||||
├── Procfile.service (35)
|
||||
├── pyproject.toml (29)
|
||||
├── .venv/ (30)
|
||||
├── .python-version (31)
|
||||
├── Procfile.static (32)
|
||||
├── Procfile.service (33)
|
||||
└── README.md
|
||||
```
|
||||
</div>
|
||||
@ -96,55 +92,53 @@ sith/
|
||||
des workflows Github.
|
||||
Par exemple, le workflow `docs.yml` compile
|
||||
et publie la documentation à chaque push sur la branche `master`.
|
||||
3. Application de gestion de la comptabilité.
|
||||
4. Application de gestion des clubs et de leurs membres.
|
||||
5. Application contenant les fonctionnalités
|
||||
3. Application de gestion des clubs et de leurs membres.
|
||||
4. Application contenant les fonctionnalités
|
||||
destinées aux responsables communication de l'AE.
|
||||
6. Application contenant la modélisation centrale du site.
|
||||
5. Application contenant la modélisation centrale du site.
|
||||
On en reparle plus loin sur cette page.
|
||||
7. Application de gestion des comptoirs, des permanences
|
||||
6. Application de gestion des comptoirs, des permanences
|
||||
sur ces comptoirs et des transactions qui y sont effectuées.
|
||||
8. Dossier contenant la documentation.
|
||||
9. Application de gestion de la boutique en ligne.
|
||||
10. Application de gestion des élections.
|
||||
11. Application de gestion du forum
|
||||
12. Application de gestion de la galaxie ; la galaxie
|
||||
7. Dossier contenant la documentation.
|
||||
8. Application de gestion de la boutique en ligne.
|
||||
9. Application de gestion des élections.
|
||||
10. Application de gestion du forum
|
||||
11. Application de gestion de la galaxie ; la galaxie
|
||||
est un graphe des niveaux de proximité entre les différents
|
||||
étudiants.
|
||||
13. Gestion des machines à laver de l'AE
|
||||
14. Dossier contenant les fichiers de traduction.
|
||||
15. Fonctionnalités de recherche d'utilisateurs.
|
||||
16. Le guide des UEs du site, sur lequel les utilisateurs
|
||||
12. Dossier contenant les fichiers de traduction.
|
||||
13. Fonctionnalités de recherche d'utilisateurs.
|
||||
14. Le guide des UEs du site, sur lequel les utilisateurs
|
||||
peuvent également laisser leurs avis.
|
||||
17. Fonctionnalités utiles aux utilisateurs root.
|
||||
18. Le SAS, où l'on trouve toutes les photos de l'AE.
|
||||
19. Application principale du projet, contenant sa configuration.
|
||||
20. Gestion des cotisations des utilisateurs du site.
|
||||
21. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||
22. Fonctionnalités pour gérer le spam.
|
||||
23. Gestion des statics du site. Override le système de statics de Django.
|
||||
15. Fonctionnalités utiles aux utilisateurs root.
|
||||
16. Le SAS, où l'on trouve toutes les photos de l'AE.
|
||||
17. Application principale du projet, contenant sa configuration.
|
||||
18. Gestion des cotisations des utilisateurs du site.
|
||||
19. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||
20. Fonctionnalités pour gérer le spam.
|
||||
21. Gestion des statics du site. Override le système de statics de Django.
|
||||
Ajoute l'intégration du scss et du bundler js
|
||||
de manière transparente pour l'utilisateur.
|
||||
24. Module de gestion des services externes.
|
||||
22. Module de gestion des services externes.
|
||||
Offre une API simple pour utiliser les fichiers `Procfile.*`.
|
||||
25. Fichier de configuration de coverage.
|
||||
26. Fichier de configuration de direnv.
|
||||
27. Contient les variables d'environnement, qui sont susceptibles
|
||||
23. Fichier de configuration de coverage.
|
||||
24. Fichier de configuration de direnv.
|
||||
25. Contient les variables d'environnement, qui sont susceptibles
|
||||
de varier d'une machine à l'autre.
|
||||
28. Contient des valeurs par défaut pour le `.env`
|
||||
26. Contient des valeurs par défaut pour le `.env`
|
||||
pouvant convenir à un environnment de développement local
|
||||
29. Fichier généré automatiquement par Django. C'est lui
|
||||
27. Fichier généré automatiquement par Django. C'est lui
|
||||
qui permet d'appeler des commandes de gestion du projet
|
||||
avec la syntaxe `python ./manage.py <nom de la commande>`
|
||||
30. Le fichier de configuration de la documentation,
|
||||
28. Le fichier de configuration de la documentation,
|
||||
avec ses plugins et sa table des matières.
|
||||
31. Le fichier où sont déclarés les dépendances et la configuration
|
||||
29. Le fichier où sont déclarés les dépendances et la configuration
|
||||
de certaines d'entre elles.
|
||||
32. Dossier d'environnement virtuel généré par uv
|
||||
33. Fichier qui contrôle quelle version de python utiliser pour le projet
|
||||
34. Fichier qui contrôle les commandes à lancer pour gérer la compilation
|
||||
30. Dossier d'environnement virtuel généré par uv
|
||||
31. Fichier qui contrôle quelle version de python utiliser pour le projet
|
||||
32. Fichier qui contrôle les commandes à lancer pour gérer la compilation
|
||||
automatique des static et autres services nécessaires à la command runserver.
|
||||
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||
33. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||
du Sith tel que redis.
|
||||
|
||||
## L'application principale
|
||||
|
@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
|
||||
|
||||
Cette application contient les vues suivantes :
|
||||
|
||||
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
|
||||
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
|
||||
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
|
||||
fonction de ce qu'il a le droit d'acheter.
|
||||
Si cette vue est appelée lors d'une redirection parce qu'une erreur
|
||||
est survenue au cours de la navigation sur la boutique, il est possible
|
||||
de donner les messages d'erreur à donner à l'utilisateur dans la session
|
||||
avec la clef ``"errors"``.
|
||||
Elle est en charge de récupérer le formulaire de création d'un panier et
|
||||
redirige alors vers la vue de checkout.
|
||||
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
|
||||
si son paiement a échoué ou réussi. Cette vue est appelée par redirection
|
||||
lorsque l'utilisateur paye son panier avec son argent du compte AE.
|
||||
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
|
||||
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
|
||||
du panier à valider. Ce panier doit strictement être de la forme :
|
||||
```
|
||||
[
|
||||
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
|
||||
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
|
||||
<etc.>
|
||||
]
|
||||
```
|
||||
Si le panier est mal formaté ou contient des valeurs invalides,
|
||||
une redirection est faite vers `eboutic_main`.
|
||||
- ``pay_with_sith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
|
||||
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
|
||||
de paiement par carte bancaire.
|
||||
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||
AE. Redirige vers `payment_result`.
|
||||
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
||||
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
|
||||
- ``BillingInfoFormFragment`` (GET/POST) : vue destinée à gérer les informations de paiement de l'utilisateur courant.
|
||||
|
||||
# Les templates
|
||||
|
||||
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
||||
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
||||
Retourné par la vue ``payment_result``.
|
||||
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
|
||||
- ``eboutic_checkout.jinja`` : template contenant un résumé du panier et deux
|
||||
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
|
||||
Retourné par la vue ``EbouticCommand``
|
||||
Retourné par la vue ``EbouticCheckout``
|
||||
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
|
||||
Elle permet également de mettre à jour ses coordonnées de paiement
|
||||
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
|
||||
une interface pour que l'utilisateur puisse consulter les produits et remplir
|
||||
son panier. Les opérations de remplissage du panier se font entièrement côté client.
|
||||
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
|
||||
(AlpineJS, plus précisément) édite en même temps un cookie.
|
||||
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
|
||||
vérifier que la commande est valide et payer.
|
||||
(AlpineJS, plus précisément) édite en même temps le localStorage du navigateur.
|
||||
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
|
||||
|
||||
# Les modèles
|
||||
|
||||
|
@ -1,38 +1,21 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||
from ninja_extra.permissions import IsAuthenticated
|
||||
from pydantic import NonNegativeInt
|
||||
from ninja_extra.exceptions import NotFound
|
||||
|
||||
from core.models import User
|
||||
from counter.models import BillingInfo, Customer
|
||||
from core.auth.api_permissions import CanView
|
||||
from counter.models import BillingInfo
|
||||
from eboutic.models import Basket
|
||||
from eboutic.schemas import BillingInfoSchema
|
||||
|
||||
|
||||
@api_controller("/etransaction", permissions=[IsAuthenticated])
|
||||
@api_controller("/etransaction", permissions=[CanView])
|
||||
class EtransactionInfoController(ControllerBase):
|
||||
@route.put("/billing-info/{user_id}", url_name="put_billing_info")
|
||||
def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema):
|
||||
"""Update or create the billing info of this user."""
|
||||
if user_id == self.context.request.user.id:
|
||||
user = self.context.request.user
|
||||
elif self.context.request.user.is_root:
|
||||
user = get_object_or_404(User, pk=user_id)
|
||||
else:
|
||||
raise PermissionDenied
|
||||
customer, _ = Customer.get_or_create(user)
|
||||
BillingInfo.objects.update_or_create(
|
||||
customer=customer, defaults=info.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
@route.get("/data", url_name="etransaction_data")
|
||||
def fetch_etransaction_data(self):
|
||||
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||
def fetch_etransaction_data(self, basket_id: int):
|
||||
"""Generate the data to pay an eboutic command with paybox.
|
||||
|
||||
The data is generated with the basket that is used by the current session.
|
||||
"""
|
||||
basket = Basket.from_session(self.context.request.session)
|
||||
if basket is None:
|
||||
raise NotFound
|
||||
return dict(basket.get_e_transaction_data())
|
||||
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||
try:
|
||||
return dict(basket.get_e_transaction_data())
|
||||
except BillingInfo.DoesNotExist as e:
|
||||
raise NotFound from e
|
||||
|
128
eboutic/forms.py
128
eboutic/forms.py
@ -1,128 +0,0 @@
|
||||
#
|
||||
# Copyright 2022
|
||||
# - Maréchal <thgirod@hotmail.com
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
from functools import cached_property
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from pydantic import ValidationError
|
||||
|
||||
from eboutic.models import get_eboutic_products
|
||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||
|
||||
|
||||
class BasketForm:
|
||||
"""Class intended to perform checks on the request sended to the server when
|
||||
the user submits his basket from /eboutic/.
|
||||
|
||||
Because it must check an unknown number of fields, coming from a cookie
|
||||
and needing some databases checks to be performed, inheriting from forms.Form
|
||||
or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff.
|
||||
Thus this class is a pure standalone and performs its operations by its own means.
|
||||
However, it still tries to share some similarities with a standard django Form.
|
||||
|
||||
Examples:
|
||||
::
|
||||
|
||||
def my_view(request):
|
||||
form = BasketForm(request)
|
||||
form.clean()
|
||||
if form.is_valid():
|
||||
# perform operations
|
||||
else:
|
||||
errors = form.get_error_messages()
|
||||
|
||||
# return the cookie that was in the request, but with all
|
||||
# incorrects elements removed
|
||||
cookie = form.get_cleaned_cookie()
|
||||
|
||||
You can also use a little shortcut by directly calling `form.is_valid()`
|
||||
without calling `form.clean()`. In this case, the latter method shall be
|
||||
implicitly called.
|
||||
"""
|
||||
|
||||
def __init__(self, request: HttpRequest):
|
||||
self.user = request.user
|
||||
self.cookies = request.COOKIES
|
||||
self.error_messages = set()
|
||||
self.correct_items = []
|
||||
|
||||
def clean(self) -> None:
|
||||
"""Perform all the checks, but return nothing.
|
||||
To know if the form is valid, the `is_valid()` method must be used.
|
||||
|
||||
The form shall be considered as valid if it meets all the following conditions :
|
||||
- it contains a "basket_items" key in the cookies of the request given in the constructor
|
||||
- this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
|
||||
'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
|
||||
- all the ids are positive integers
|
||||
- all the ids refer to products available in the EBOUTIC
|
||||
- all the ids refer to products the user is allowed to buy
|
||||
- all the quantities are positive integers
|
||||
"""
|
||||
try:
|
||||
basket = PurchaseItemList.validate_json(
|
||||
unquote(self.cookies.get("basket_items", "[]"))
|
||||
)
|
||||
except ValidationError:
|
||||
self.error_messages.add(_("The request was badly formatted."))
|
||||
return
|
||||
if len(basket) == 0:
|
||||
self.error_messages.add(_("Your basket is empty."))
|
||||
return
|
||||
existing_ids = {product.id for product in get_eboutic_products(self.user)}
|
||||
for item in basket:
|
||||
# check a product with this id does exist
|
||||
if item.product_id in existing_ids:
|
||||
self.correct_items.append(item)
|
||||
else:
|
||||
self.error_messages.add(
|
||||
_(
|
||||
"%(name)s : this product does not exist or may no longer be available."
|
||||
)
|
||||
% {"name": item.name}
|
||||
)
|
||||
continue
|
||||
# this function does not return anything.
|
||||
# instead, it fills a set containing the collected error messages
|
||||
# an empty set means that no error was seen thus everything is ok
|
||||
# and the form is valid.
|
||||
# a non-empty set means there was at least one error thus
|
||||
# the form is invalid
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Return True if the form is correct else False.
|
||||
|
||||
If the `clean()` method has not been called beforehand, call it.
|
||||
"""
|
||||
if not self.error_messages and not self.correct_items:
|
||||
self.clean()
|
||||
return not self.error_messages
|
||||
|
||||
@cached_property
|
||||
def errors(self) -> list[str]:
|
||||
return list(self.error_messages)
|
||||
|
||||
@cached_property
|
||||
def cleaned_data(self) -> list[PurchaseItemSchema]:
|
||||
return self.correct_items
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
@ -27,23 +28,56 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import User
|
||||
from counter.fields import CurrencyField
|
||||
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
Refilling,
|
||||
Selling,
|
||||
get_eboutic,
|
||||
)
|
||||
|
||||
|
||||
def get_eboutic_products(user: User) -> list[Product]:
|
||||
products = (
|
||||
Counter.objects.get(type="EBOUTIC")
|
||||
get_eboutic()
|
||||
.products.filter(product_type__isnull=False)
|
||||
.filter(archived=False)
|
||||
.filter(limit_age__lte=user.age)
|
||||
.annotate(order=F("product_type__order"))
|
||||
.annotate(category=F("product_type__name"))
|
||||
.annotate(category_comment=F("product_type__comment"))
|
||||
.annotate(price=F("selling_price")) # <-- selected price for basket validation
|
||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||
)
|
||||
return [p for p in products if p.can_be_sold_to(user)]
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
MISSING_PHONE_NUMBER = 3
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, info: BillingInfo | None) -> BillingInfoState:
|
||||
if info is None:
|
||||
return cls.EMPTY
|
||||
for attr in [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"address_1",
|
||||
"zip_code",
|
||||
"city",
|
||||
"country",
|
||||
]:
|
||||
if getattr(info, attr) == "":
|
||||
return cls.EMPTY
|
||||
if info.phone_number is None:
|
||||
return cls.MISSING_PHONE_NUMBER
|
||||
return cls.VALID
|
||||
|
||||
|
||||
class Basket(models.Model):
|
||||
"""Basket is built when the user connects to an eboutic page."""
|
||||
|
||||
@ -59,6 +93,9 @@ class Basket(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.user == user
|
||||
|
||||
@cached_property
|
||||
def contains_refilling_item(self) -> bool:
|
||||
return self.items.filter(
|
||||
@ -73,13 +110,6 @@ class Basket(models.Model):
|
||||
)["total"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_session(cls, session) -> Basket | None:
|
||||
"""The basket stored in the session object, if it exists."""
|
||||
if "basket_id" in session:
|
||||
return cls.objects.filter(id=session["basket_id"]).first()
|
||||
return None
|
||||
|
||||
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||
"""Generate a list of sold items corresponding to the items
|
||||
of this basket WITHOUT saving them NOR deleting the basket.
|
||||
@ -114,7 +144,7 @@ class Basket(models.Model):
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=seller,
|
||||
customer=self.user.customer,
|
||||
customer=Customer.get_or_create(self.user)[0],
|
||||
unit_price=item.product_unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
@ -127,7 +157,11 @@ class Basket(models.Model):
|
||||
if not hasattr(user, "customer"):
|
||||
raise Customer.DoesNotExist
|
||||
customer = user.customer
|
||||
if not hasattr(user.customer, "billing_infos"):
|
||||
if (
|
||||
not hasattr(user.customer, "billing_infos")
|
||||
or BillingInfoState.from_model(user.customer.billing_infos)
|
||||
!= BillingInfoState.VALID
|
||||
):
|
||||
raise BillingInfo.DoesNotExist
|
||||
cart = {
|
||||
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
|
||||
|
22
eboutic/static/bundled/eboutic/checkout-index.ts
Normal file
22
eboutic/static/bundled/eboutic/checkout-index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||
data: initialData,
|
||||
isCbAvailable: Object.keys(initialData).length > 0,
|
||||
|
||||
async fill() {
|
||||
this.isCbAvailable = false;
|
||||
const res = await etransactioninfoFetchEtransactionData({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
basket_id: basketId,
|
||||
},
|
||||
});
|
||||
if (res.response.ok) {
|
||||
this.data = res.data;
|
||||
this.isCbAvailable = true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
@ -8,55 +8,55 @@ interface BasketItem {
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
||||
|
||||
/**
|
||||
* Search for a cookie by name
|
||||
* @param name Name of the cookie to get
|
||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
||||
*/
|
||||
function getCookie(name: string): string | null | undefined {
|
||||
if (!document.cookie || document.cookie.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${name}=`));
|
||||
|
||||
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the basket items from the associated cookie
|
||||
* @returns the items in the basket
|
||||
*/
|
||||
function getStartingItems(): BasketItem[] {
|
||||
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||
if (!cookie) {
|
||||
return [];
|
||||
}
|
||||
// Django cookie backend converts `,` to `\054`
|
||||
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
|
||||
if (typeof parsed === "string") {
|
||||
// In some conditions, a second parsing is needed
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
const res = Array.isArray(parsed) ? parsed : [];
|
||||
return res.filter((i) => !!document.getElementById(i.id));
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", () => ({
|
||||
items: getStartingItems() as BasketItem[],
|
||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||
basket: [] as BasketItem[],
|
||||
|
||||
init() {
|
||||
this.basket = this.loadBasket();
|
||||
this.$watch("basket", () => {
|
||||
this.saveBasket();
|
||||
});
|
||||
|
||||
// Invalidate basket if a purchase was made
|
||||
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||
if (
|
||||
new Date(lastPurchaseTime) >=
|
||||
new Date(Number.parseInt(localStorage.basketTimestamp))
|
||||
) {
|
||||
this.basket = [];
|
||||
}
|
||||
}
|
||||
|
||||
// It's quite tricky to manually apply attributes to the management part
|
||||
// of a formset so we dynamically apply it here
|
||||
this.$refs.basketManagementForm
|
||||
.querySelector("#id_form-TOTAL_FORMS")
|
||||
.setAttribute(":value", "basket.length");
|
||||
},
|
||||
|
||||
loadBasket(): BasketItem[] {
|
||||
if (localStorage.basket === undefined) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.basket);
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveBasket() {
|
||||
localStorage.basket = JSON.stringify(this.basket);
|
||||
localStorage.basketTimestamp = Date.now();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the total price of the basket
|
||||
* @returns {number} The total price of the basket
|
||||
*/
|
||||
getTotal() {
|
||||
return this.items.reduce(
|
||||
return this.basket.reduce(
|
||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||
0,
|
||||
);
|
||||
@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
add(item: BasketItem) {
|
||||
item.quantity++;
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param itemId the id of the item to remove
|
||||
*/
|
||||
remove(itemId: number) {
|
||||
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
||||
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.items[index].quantity -= 1;
|
||||
this.basket[index].quantity -= 1;
|
||||
|
||||
if (this.items[index].quantity === 0) {
|
||||
this.items = this.items.filter(
|
||||
(e: BasketItem) => e.id !== this.items[index].id,
|
||||
if (this.basket[index].quantity === 0) {
|
||||
this.basket = this.basket.filter(
|
||||
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||
);
|
||||
}
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||
*/
|
||||
clearBasket() {
|
||||
this.items = [];
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the cookie in the browser with the basket items
|
||||
* ! the cookie survives an hour
|
||||
*/
|
||||
setCookies() {
|
||||
if (this.items.length === 0) {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
||||
} else {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
||||
}
|
||||
this.basket = [];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
|
||||
unit_price: price,
|
||||
} as BasketItem;
|
||||
|
||||
this.items.push(newItem);
|
||||
this.basket.push(newItem);
|
||||
this.add(newItem);
|
||||
|
||||
return newItem;
|
||||
@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param price The unit price of the product
|
||||
*/
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.items.find((e: BasketItem) => e.id === id);
|
||||
let item = this.basket.find((e: BasketItem) => e.id === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
||||
|
@ -1,91 +0,0 @@
|
||||
import { exportToHtml } from "#core:utils/globals";
|
||||
import {
|
||||
type BillingInfoSchema,
|
||||
etransactioninfoFetchEtransactionData,
|
||||
etransactioninfoPutUserBillingInfo,
|
||||
} from "#openapi";
|
||||
|
||||
enum BillingInfoReqState {
|
||||
Success = "0",
|
||||
Failure = "1",
|
||||
Sending = "2",
|
||||
}
|
||||
|
||||
exportToHtml("BillingInfoReqState", BillingInfoReqState);
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("etransactionData", (initialData) => ({
|
||||
data: initialData,
|
||||
|
||||
async fill() {
|
||||
const button = document.getElementById("bank-submit-button") as HTMLButtonElement;
|
||||
button.disabled = true;
|
||||
const res = await etransactioninfoFetchEtransactionData();
|
||||
if (res.response.ok) {
|
||||
this.data = res.data;
|
||||
button.disabled = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.data("billing_infos", (userId: number) => ({
|
||||
/** @type {BillingInfoReqState | null} */
|
||||
reqState: null,
|
||||
|
||||
async sendForm() {
|
||||
this.reqState = BillingInfoReqState.Sending;
|
||||
const form = document.getElementById("billing_info_form");
|
||||
const submitButton = document.getElementById(
|
||||
"bank-submit-button",
|
||||
) as HTMLButtonElement;
|
||||
submitButton.disabled = true;
|
||||
const payload = Object.fromEntries(
|
||||
Array.from(form.querySelectorAll("input, select"))
|
||||
.filter((elem: HTMLInputElement) => elem.type !== "submit" && elem.value)
|
||||
.map((elem: HTMLInputElement) => [elem.name, elem.value]),
|
||||
);
|
||||
const res = await etransactioninfoPutUserBillingInfo({
|
||||
// biome-ignore lint/style/useNamingConvention: API is snake_case
|
||||
path: { user_id: userId },
|
||||
body: payload as unknown as BillingInfoSchema,
|
||||
});
|
||||
this.reqState = res.response.ok
|
||||
? BillingInfoReqState.Success
|
||||
: BillingInfoReqState.Failure;
|
||||
if (res.response.status === 422) {
|
||||
const errors = await res.response
|
||||
.json()
|
||||
.detail.flatMap((err: Record<"loc", string>) => err.loc);
|
||||
for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) =>
|
||||
errors.includes(elem.name),
|
||||
)) {
|
||||
elem.setCustomValidity(gettext("Incorrect value"));
|
||||
elem.reportValidity();
|
||||
elem.oninput = () => elem.setCustomValidity("");
|
||||
}
|
||||
} else if (res.response.ok) {
|
||||
this.$dispatch("billing-infos-filled");
|
||||
}
|
||||
},
|
||||
|
||||
getAlertColor() {
|
||||
if (this.reqState === BillingInfoReqState.Success) {
|
||||
return "green";
|
||||
}
|
||||
if (this.reqState === BillingInfoReqState.Failure) {
|
||||
return "red";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
getAlertMessage() {
|
||||
if (this.reqState === BillingInfoReqState.Success) {
|
||||
return gettext("Billing info registration success");
|
||||
}
|
||||
if (this.reqState === BillingInfoReqState.Failure) {
|
||||
return gettext("Billing info registration failure");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
}));
|
||||
});
|
@ -61,6 +61,7 @@
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
line-height: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#eboutic .fa-plus,
|
||||
|
42
eboutic/templates/eboutic/eboutic_billing_info.jinja
Normal file
42
eboutic/templates/eboutic/eboutic_billing_info.jinja
Normal file
@ -0,0 +1,42 @@
|
||||
<div id=billing-infos-fragment>
|
||||
<div
|
||||
class="collapse"
|
||||
:class="{'shadow': collapsed}"
|
||||
x-data="{collapsed: {{ "true" if messages or form.errors else "false" }}}"
|
||||
>
|
||||
<div class="collapse-header clickable" @click="collapsed = !collapsed">
|
||||
<span class="collapse-header-text">
|
||||
{% trans %}Billing information{% endtrans %}
|
||||
</span>
|
||||
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
class="collapse-body"
|
||||
hx-trigger="submit"
|
||||
hx-post="{{ action }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#billing-infos-fragment"
|
||||
x-show="collapsed"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<br>
|
||||
<input
|
||||
type="submit" class="btn btn-blue clickable"
|
||||
value="{% trans %}Validate{% endtrans %}"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
91
eboutic/templates/eboutic/eboutic_checkout.jinja
Normal file
91
eboutic/templates/eboutic/eboutic_checkout.jinja
Normal file
@ -0,0 +1,91 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Basket state{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<script type="text/javascript">
|
||||
let billingInfos = {{ billing_infos|safe }};
|
||||
</script>
|
||||
|
||||
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Article</td>
|
||||
<td>Quantity</td>
|
||||
<td>Unit price</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in basket.items.all() %}
|
||||
<tr>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.product_unit_price }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €</strong>
|
||||
|
||||
{% if customer_amount != None %}
|
||||
<br>
|
||||
{% trans %}Current account amount: {% endtrans %}
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
|
||||
{% if not basket.contains_refilling_item %}
|
||||
<br>
|
||||
{% trans %}Remaining account amount: {% endtrans %}
|
||||
<strong>{{ "%0.2f"|format(customer_amount|float - basket.total) }} €</strong>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<br>
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||
<div @htmx:after-request="fill">
|
||||
{{ billing_infos_form }}
|
||||
</div>
|
||||
<form
|
||||
method="post"
|
||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||
>
|
||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||
<input type="hidden" :name="key" :value="value">
|
||||
</template>
|
||||
<input
|
||||
x-cloak
|
||||
type="submit"
|
||||
id="bank-submit-button"
|
||||
:disabled="!isCbAvailable"
|
||||
class="btn btn-blue"
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||
/>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if basket.contains_refilling_item %}
|
||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||
{% csrf_token %}
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
@ -21,58 +21,90 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||
<div id="eboutic" x-data="basket">
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
||||
<div id="basket">
|
||||
<h3>Panier</h3>
|
||||
{% if errors %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in errors %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
|
||||
{% if form.non_form_errors() or form.errors %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in form.non_form_errors() + form.errors %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<form method="get" action="{{ url('eboutic:command') }}">
|
||||
{% csrf_token %}
|
||||
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click.prevent="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>
|
||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog">
|
||||
{% if not request.user.date_of_birth %}
|
||||
@ -96,23 +128,36 @@
|
||||
<div class="category-header">
|
||||
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
|
||||
{% if user.is_subscribed %}
|
||||
<a
|
||||
title="Logiciel billetterie en ligne"
|
||||
href="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
class="weezevent-widget-integration"
|
||||
target="_blank"
|
||||
data-src="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
data-width="650"
|
||||
data-height="600"
|
||||
data-resize="1"
|
||||
data-nopb="0"
|
||||
data-type="neo"
|
||||
data-width_auto="1"
|
||||
data-noscroll="0"
|
||||
data-id="1211855">
|
||||
Billetterie Weezevent
|
||||
</a>
|
||||
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
|
||||
<div id="eurok-partner" style="
|
||||
min-height: 600px;
|
||||
background-color: lightgrey;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
">
|
||||
<p style="text-align: center;">
|
||||
{% trans trimmed %}
|
||||
Our partner uses Weezevent to sell tickets.
|
||||
Weezevent may collect user info according to
|
||||
its own privacy policy.
|
||||
By clicking the accept button you consent to
|
||||
their terms of services.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
|
||||
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
|
||||
|
||||
<button
|
||||
hx-get="{{ url("eboutic:eurok") }}"
|
||||
hx-target="#eurok-partner"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
|
||||
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
|
||||
>{% trans %}Accept{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{%- trans trimmed %}
|
||||
@ -146,7 +191,7 @@
|
||||
<button
|
||||
id="{{ p.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
||||
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||
>
|
||||
{% if p.icon %}
|
||||
|
@ -1,158 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Basket state{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/eboutic/makecommand-index.ts') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<div>
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Article</td>
|
||||
<td>Quantity</td>
|
||||
<td>Unit price</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in basket.items.all() %}
|
||||
<tr>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.product_unit_price }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<strong>{% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €</strong>
|
||||
|
||||
{% if customer_amount != None %}
|
||||
<br>
|
||||
{% trans %}Current account amount: {% endtrans %}
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
|
||||
{% if not basket.contains_refilling_item %}
|
||||
<br>
|
||||
{% trans %}Remaining account amount: {% endtrans %}
|
||||
<strong>{{ "%0.2f"|format(customer_amount|float - basket.total) }} €</strong>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<br>
|
||||
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
|
||||
<div
|
||||
class="collapse"
|
||||
:class="{'shadow': collapsed}"
|
||||
x-data="{collapsed: !{{ "true" if billing_infos else "false" }}}"
|
||||
x-cloak
|
||||
>
|
||||
<div class="collapse-header clickable" @click="collapsed = !collapsed">
|
||||
<span class="collapse-header-text">
|
||||
{% trans %}Billing information{% endtrans %}
|
||||
</span>
|
||||
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
class="collapse-body"
|
||||
id="billing_info_form"
|
||||
x-data="billing_infos({{ user.id }})"
|
||||
x-show="collapsed"
|
||||
x-transition.scale.origin.top
|
||||
@submit.prevent="await sendForm()"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ billing_form }}
|
||||
<br />
|
||||
<div
|
||||
x-show="[BillingInfoReqState.Success, BillingInfoReqState.Failure].includes(reqState)"
|
||||
class="alert"
|
||||
:class="'alert-' + getAlertColor()"
|
||||
x-transition
|
||||
>
|
||||
<div class="alert-main" x-text="getAlertMessage()"></div>
|
||||
<div class="clickable" @click="reqState = null">
|
||||
<i class="fa fa-close"></i>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="submit" class="btn btn-blue clickable"
|
||||
value="{% trans %}Validate{% endtrans %}"
|
||||
:disabled="reqState === BillingInfoReqState.Sending"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<br>
|
||||
{% if billing_infos_state == BillingInfoState.EMPTY %}
|
||||
<div class="alert alert-yellow">
|
||||
{% trans trimmed %}
|
||||
You must fill your billing infos if you want to pay with your credit card
|
||||
{% endtrans %}
|
||||
</div>
|
||||
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
|
||||
<div class="alert alert-yellow">
|
||||
{% trans trimmed %}
|
||||
The Crédit Agricole changed its policy related to the billing
|
||||
information that must be provided in order to pay with a credit card.
|
||||
If you want to pay with your credit card, you must add a phone number
|
||||
to the data you already provided.
|
||||
{% endtrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form
|
||||
method="post"
|
||||
action="{{ settings.SITH_EBOUTIC_ET_URL }}"
|
||||
name="bank-pay-form"
|
||||
x-data="etransactionData(initialEtData)"
|
||||
@billing-infos-filled.window="await fill()"
|
||||
>
|
||||
<template x-for="[key, value] in Object.entries(data)" :key="key">
|
||||
<input type="hidden" :name="key" :value="value">
|
||||
</template>
|
||||
<input
|
||||
type="submit"
|
||||
id="bank-submit-button"
|
||||
{% if billing_infos_state != BillingInfoState.VALID %}disabled="disabled"{% endif %}
|
||||
value="{% trans %}Pay with credit card{% endtrans %}"
|
||||
/>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if basket.contains_refilling_item %}
|
||||
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
||||
<input type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
{% if billing_infos -%}
|
||||
const initialEtData = {{ billing_infos|safe }}
|
||||
{%- else -%}
|
||||
const initialEtData = {}
|
||||
{%- endif %}
|
||||
</script>
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
@ -4,6 +4,14 @@
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<div>
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
{% trans %}Payment successful{% endtrans %}
|
||||
{% else %}
|
||||
|
17
eboutic/templates/eboutic/eurok_fragment.jinja
Normal file
17
eboutic/templates/eboutic/eurok_fragment.jinja
Normal file
@ -0,0 +1,17 @@
|
||||
<a
|
||||
title="Logiciel billetterie en ligne"
|
||||
href="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
class="weezevent-widget-integration"
|
||||
target="_blank"
|
||||
data-src="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
data-width="650"
|
||||
data-height="600"
|
||||
data-resize="1"
|
||||
data-nopb="0"
|
||||
data-type="neo"
|
||||
data-width_auto="1"
|
||||
data-noscroll="0"
|
||||
data-id="1211855">
|
||||
Billetterie Weezevent
|
||||
</a>
|
||||
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
|
179
eboutic/tests/test_basket.py
Normal file
179
eboutic/tests/test_basket.py
Normal file
@ -0,0 +1,179 @@
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.models import Counter, ProductType, get_eboutic
|
||||
from counter.tests.test_counter import BasketItem
|
||||
from eboutic.models import Basket
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_eboutic():
|
||||
assert Counter.objects.get(name="Eboutic") == get_eboutic()
|
||||
|
||||
baker.make(Counter, type="EBOUTIC")
|
||||
|
||||
assert Counter.objects.get(name="Eboutic") == get_eboutic()
|
||||
|
||||
|
||||
class TestEboutic(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.group_cotiz = baker.make(Group)
|
||||
cls.group_public = baker.make(Group)
|
||||
|
||||
cls.new_customer = baker.make(User)
|
||||
cls.new_customer_adult = baker.make(User)
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
cls.set_age(cls.new_customer, 5)
|
||||
cls.set_age(cls.new_customer_adult, 20)
|
||||
cls.set_age(cls.subscriber, 20)
|
||||
|
||||
product_type = baker.make(ProductType)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18,
|
||||
selling_price=2.5,
|
||||
special_selling_price=1,
|
||||
product_type=product_type,
|
||||
)
|
||||
cls.not_in_counter = product_recipe.make(
|
||||
selling_price=3.5, product_type=product_type
|
||||
)
|
||||
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
|
||||
|
||||
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
|
||||
cls.group_cotiz.products.add(cls.cotiz)
|
||||
|
||||
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
|
||||
cls.new_customer.groups.add(cls.group_public)
|
||||
cls.new_customer_adult.groups.add(cls.group_public)
|
||||
|
||||
cls.eboutic = get_eboutic()
|
||||
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
|
||||
|
||||
@classmethod
|
||||
def set_age(cls, user: User, age: int):
|
||||
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||
user.save()
|
||||
|
||||
def submit_basket(
|
||||
self, basket: list[BasketItem], client: Client | None = None
|
||||
) -> HttpResponse:
|
||||
used_client: Client = client if client is not None else self.client
|
||||
data = {
|
||||
"form-TOTAL_FORMS": str(len(basket)),
|
||||
"form-INITIAL_FORMS": "0",
|
||||
}
|
||||
for index, item in enumerate(basket):
|
||||
data.update(item.to_form(index))
|
||||
return used_client.post(reverse("eboutic:main"), data)
|
||||
|
||||
def test_submit_empty_basket(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
for basket in [
|
||||
[],
|
||||
[BasketItem(None, None)],
|
||||
[BasketItem(None, None), BasketItem(None, None)],
|
||||
]:
|
||||
response = self.submit_basket(basket)
|
||||
assert response.status_code == 200
|
||||
assert "Votre panier est vide" in response.text
|
||||
|
||||
def test_submit_invalid_basket(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
for item in [
|
||||
BasketItem(-1, 2),
|
||||
BasketItem(self.snack.id, -1),
|
||||
BasketItem(None, 1),
|
||||
BasketItem(self.snack.id, None),
|
||||
]:
|
||||
response = self.submit_basket([item])
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_anonymous(self):
|
||||
assertRedirects(
|
||||
self.client.get(reverse("eboutic:main")),
|
||||
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||
)
|
||||
assertRedirects(
|
||||
self.submit_basket([]),
|
||||
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||
)
|
||||
assertRedirects(
|
||||
self.submit_basket([BasketItem(self.snack.id, 1)]),
|
||||
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||
)
|
||||
|
||||
def test_add_forbidden_product(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
response = self.submit_basket([BasketItem(self.beer.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
self.client.force_login(self.new_customer)
|
||||
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||
assert response.status_code == 200
|
||||
assert Basket.objects.first() is None
|
||||
|
||||
def test_create_basket(self):
|
||||
self.client.force_login(self.new_customer)
|
||||
assertRedirects(
|
||||
self.submit_basket([BasketItem(self.snack.id, 2)]),
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
|
||||
)
|
||||
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
|
||||
|
||||
self.client.force_login(self.new_customer_adult)
|
||||
assertRedirects(
|
||||
self.submit_basket(
|
||||
[BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1)]
|
||||
),
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
|
||||
)
|
||||
assert (
|
||||
Basket.objects.get(id=2).total
|
||||
== self.snack.selling_price * 2 + self.beer.selling_price
|
||||
)
|
||||
|
||||
self.client.force_login(self.subscriber)
|
||||
assertRedirects(
|
||||
self.submit_basket(
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
BasketItem(self.beer.id, 1),
|
||||
BasketItem(self.cotiz.id, 1),
|
||||
]
|
||||
),
|
||||
reverse("eboutic:checkout", kwargs={"basket_id": 3}),
|
||||
)
|
||||
assert (
|
||||
Basket.objects.get(id=3).total
|
||||
== self.snack.selling_price * 2
|
||||
+ self.beer.selling_price
|
||||
+ self.cotiz.selling_price
|
||||
)
|
138
eboutic/tests/test_billing_info.py
Normal file
138
eboutic/tests/test_billing_info.py
Normal file
@ -0,0 +1,138 @@
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
from counter.models import BillingInfo
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBillingInfo:
|
||||
@pytest.fixture
|
||||
def payload(self):
|
||||
return {
|
||||
"first_name": "Subscribed",
|
||||
"last_name": "User",
|
||||
"address_1": "3, rue de Troyes",
|
||||
"zip_code": "34301",
|
||||
"city": "Sète",
|
||||
"country": "FR",
|
||||
"phone_number": "0612345678",
|
||||
}
|
||||
|
||||
def test_not_authorized(self, client: Client, payload: dict[str, str]):
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("core:login", query={"next": reverse("eboutic:billing_infos")}),
|
||||
)
|
||||
|
||||
def test_edit_infos(self, client: Client, payload: dict[str, str]):
|
||||
user = subscriber_user.make()
|
||||
baker.make(BillingInfo, customer=user.customer)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
user.refresh_from_db()
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert response.status_code == 302
|
||||
assert hasattr(user.customer, "billing_infos")
|
||||
assert infos.customer == user.customer
|
||||
for key, val in payload.items():
|
||||
assert getattr(infos, key) == val
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
|
||||
)
|
||||
def test_create_infos(
|
||||
self, client: Client, user_maker: Callable[[], User], payload: dict[str, str]
|
||||
):
|
||||
user = user_maker()
|
||||
client.force_login(user)
|
||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
user.refresh_from_db()
|
||||
assert hasattr(user, "customer")
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert hasattr(user.customer, "billing_infos")
|
||||
assert infos.customer == user.customer
|
||||
for key, val in payload.items():
|
||||
assert getattr(infos, key) == val
|
||||
|
||||
def test_invalid_data(self, client: Client, payload: dict[str, str]):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
# address_1, zip_code and country are missing
|
||||
del payload["city"]
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user.customer.refresh_from_db()
|
||||
assert not hasattr(user.customer, "billing_infos")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phone_number",
|
||||
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
|
||||
)
|
||||
def test_phone_number_format(
|
||||
self, client: Client, payload: dict[str, str], phone_number: str
|
||||
):
|
||||
"""Test that various formats of phone numbers are accepted."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = phone_number
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert infos.phone_number == "0612345678"
|
||||
assert infos.phone_number.country_code == 33
|
||||
|
||||
def test_foreign_phone_number(self, client: Client, payload: dict[str, str]):
|
||||
"""Test that a foreign phone number is accepted."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = "+49612345678"
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
infos = BillingInfo.objects.get(customer__user=user)
|
||||
assert infos.phone_number.as_national == "06123 45678"
|
||||
assert infos.phone_number.country_code == 49
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
|
||||
)
|
||||
def test_invalid_phone_number(
|
||||
self, client: Client, payload: dict[str, str], phone_number: str
|
||||
):
|
||||
"""Test that invalid phone numbers are rejected."""
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
payload["phone_number"] = phone_number
|
||||
response = client.post(
|
||||
reverse("eboutic:billing_infos"),
|
||||
payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
269
eboutic/tests/test_payment.py
Normal file
269
eboutic/tests/test_payment.py
Normal file
@ -0,0 +1,269 @@
|
||||
import base64
|
||||
import urllib
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from django.conf import settings
|
||||
from django.contrib.messages import get_messages
|
||||
from django.contrib.messages.constants import DEFAULT_LEVELS
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.models import Product, ProductType, Selling
|
||||
from counter.tests.test_counter import force_refill_user
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
|
||||
class TestPaymentBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.customer = subscriber_user.make()
|
||||
cls.basket = baker.make(Basket, user=cls.customer)
|
||||
cls.refilling = product_recipe.make(
|
||||
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
||||
selling_price=15,
|
||||
)
|
||||
|
||||
product_type = baker.make(ProductType)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||
)
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18,
|
||||
selling_price=2.5,
|
||||
special_selling_price=1,
|
||||
product_type=product_type,
|
||||
)
|
||||
|
||||
BasketItem.from_product(cls.snack, 1, cls.basket).save()
|
||||
BasketItem.from_product(cls.beer, 2, cls.basket).save()
|
||||
|
||||
|
||||
class TestPaymentSith(TestPaymentBase):
|
||||
def test_anonymous(self):
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
def test_unauthorized(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
def test_not_found(self):
|
||||
self.client.force_login(self.customer)
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id + 1})
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
def test_only_post_allowed(self):
|
||||
self.client.force_login(self.customer)
|
||||
force_refill_user(self.customer, self.basket.total + 1)
|
||||
response = self.client.get(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == self.basket.total + 1
|
||||
|
||||
def test_buy_success(self):
|
||||
self.client.force_login(self.customer)
|
||||
force_refill_user(self.customer, self.basket.total + 1)
|
||||
assertRedirects(
|
||||
self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}),
|
||||
),
|
||||
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
||||
)
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == Decimal("1")
|
||||
|
||||
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||
"quantity"
|
||||
)
|
||||
assert len(sellings) == 2
|
||||
assert sellings[0].payment_method == "SITH_ACCOUNT"
|
||||
assert sellings[0].quantity == 1
|
||||
assert sellings[0].unit_price == self.snack.selling_price
|
||||
assert sellings[0].counter.type == "EBOUTIC"
|
||||
assert sellings[0].product == self.snack
|
||||
|
||||
assert sellings[1].payment_method == "SITH_ACCOUNT"
|
||||
assert sellings[1].quantity == 2
|
||||
assert sellings[1].unit_price == self.beer.selling_price
|
||||
assert sellings[1].counter.type == "EBOUTIC"
|
||||
assert sellings[1].product == self.beer
|
||||
|
||||
def test_not_enough_money(self):
|
||||
self.client.force_login(self.customer)
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert len(messages) == 1
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert messages[0].message == "Solde insuffisant"
|
||||
|
||||
assert Basket.objects.contains(self.basket), (
|
||||
"After an unsuccessful request, the basket should be kept"
|
||||
)
|
||||
|
||||
def test_refilling_in_basket(self):
|
||||
BasketItem.from_product(self.refilling, 1, self.basket).save()
|
||||
self.client.force_login(self.customer)
|
||||
force_refill_user(self.customer, self.basket.total + 1)
|
||||
self.customer.customer.refresh_from_db()
|
||||
initial_account_balance = self.customer.customer.amount
|
||||
response = self.client.post(
|
||||
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||
)
|
||||
assertRedirects(
|
||||
response,
|
||||
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||
)
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is not None
|
||||
messages = list(get_messages(response.wsgi_request))
|
||||
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||
assert (
|
||||
messages[0].message
|
||||
== "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
|
||||
)
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == initial_account_balance
|
||||
|
||||
|
||||
class TestPaymentCard(TestPaymentBase):
|
||||
def generate_bank_valid_answer(self, basket: Basket):
|
||||
query = (
|
||||
f"Amount={int(basket.total * 100)}&BasketID={basket.id}&Auto=42&Error=00000"
|
||||
)
|
||||
with open("./eboutic/tests/private_key.pem", "br") as f:
|
||||
PRIVKEY = f.read()
|
||||
with open("./eboutic/tests/public_key.pem") as f:
|
||||
settings.SITH_EBOUTIC_PUB_KEY = f.read()
|
||||
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
|
||||
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
|
||||
b64sig = base64.b64encode(sig).decode("ascii")
|
||||
|
||||
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
|
||||
query,
|
||||
urllib.parse.quote_plus(b64sig),
|
||||
)
|
||||
return url
|
||||
|
||||
def test_buy_success(self):
|
||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||
assert response.status_code == 200
|
||||
assert response.content.decode("utf-8") == "Payment successful"
|
||||
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||
|
||||
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||
"quantity"
|
||||
)
|
||||
assert len(sellings) == 2
|
||||
assert sellings[0].payment_method == "CARD"
|
||||
assert sellings[0].quantity == 1
|
||||
assert sellings[0].unit_price == self.snack.selling_price
|
||||
assert sellings[0].counter.type == "EBOUTIC"
|
||||
assert sellings[0].product == self.snack
|
||||
|
||||
assert sellings[1].payment_method == "CARD"
|
||||
assert sellings[1].quantity == 2
|
||||
assert sellings[1].unit_price == self.beer.selling_price
|
||||
assert sellings[1].counter.type == "EBOUTIC"
|
||||
assert sellings[1].product == self.beer
|
||||
|
||||
def test_buy_subscribe_product(self):
|
||||
customer = old_subscriber_user.make()
|
||||
assert customer.subscriptions.count() == 1
|
||||
assert not customer.subscriptions.first().is_valid_now()
|
||||
|
||||
basket = baker.make(Basket, user=customer)
|
||||
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
|
||||
response = self.client.get(self.generate_bank_valid_answer(basket))
|
||||
assert response.status_code == 200
|
||||
|
||||
assert customer.subscriptions.count() == 2
|
||||
|
||||
subscription = customer.subscriptions.order_by("-subscription_end").first()
|
||||
assert subscription.is_valid_now()
|
||||
assert subscription.subscription_type == "deux-semestres"
|
||||
assert subscription.location == "EBOUTIC"
|
||||
|
||||
def test_buy_refilling(self):
|
||||
BasketItem.from_product(self.refilling, 2, self.basket).save()
|
||||
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||
assert response.status_code == 200
|
||||
|
||||
self.customer.customer.refresh_from_db()
|
||||
assert self.customer.customer.amount == self.refilling.selling_price * 2
|
||||
|
||||
def test_multiple_responses(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text
|
||||
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
||||
)
|
||||
|
||||
def test_unknown_basket(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
self.basket.delete()
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text
|
||||
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
||||
)
|
||||
|
||||
def test_altered_basket(self):
|
||||
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||
BasketItem.from_product(self.snack, 1, self.basket).save()
|
||||
response = self.client.get(bank_response)
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text == "Basket processing failed with error: "
|
||||
"SuspiciousOperation('Basket total and amount do not match')"
|
||||
)
|
@ -1,254 +0,0 @@
|
||||
#
|
||||
# Copyright 2016,2017
|
||||
# - Skia <skia@libskia.so>
|
||||
# - Maréchal <thgirod@hotmail.com
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import base64
|
||||
import json
|
||||
import urllib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from django.conf import settings
|
||||
from django.db.models import Max
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import User
|
||||
from counter.models import Counter, Customer, Product, Selling
|
||||
from eboutic.models import Basket, BasketItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
|
||||
class TestEboutic(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.barbar = Product.objects.get(code="BARB")
|
||||
cls.refill = Product.objects.get(code="15REFILL")
|
||||
cls.cotis = Product.objects.get(code="1SCOTIZ")
|
||||
cls.eboutic = Counter.objects.get(name="Eboutic")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.subscriber = User.objects.get(username="subscriber")
|
||||
cls.old_subscriber = User.objects.get(username="old_subscriber")
|
||||
cls.public = User.objects.get(username="public")
|
||||
|
||||
def get_busy_basket(self, user) -> Basket:
|
||||
"""Create and return a basket with 3 barbar and 1 cotis in it.
|
||||
|
||||
Edit the client session to store the basket id in it.
|
||||
"""
|
||||
session = self.client.session
|
||||
basket = Basket.objects.create(user=user)
|
||||
session["basket_id"] = basket.id
|
||||
session.save()
|
||||
BasketItem.from_product(self.barbar, 3, basket).save()
|
||||
BasketItem.from_product(self.cotis, 1, basket).save()
|
||||
return basket
|
||||
|
||||
def generate_bank_valid_answer(self) -> str:
|
||||
basket = Basket.from_session(self.client.session)
|
||||
basket_id = basket.id
|
||||
amount = int(basket.total * 100)
|
||||
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
||||
with open("./eboutic/tests/private_key.pem", "br") as f:
|
||||
PRIVKEY = f.read()
|
||||
with open("./eboutic/tests/public_key.pem") as f:
|
||||
settings.SITH_EBOUTIC_PUB_KEY = f.read()
|
||||
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
|
||||
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
|
||||
b64sig = base64.b64encode(sig).decode("ascii")
|
||||
|
||||
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
|
||||
query,
|
||||
urllib.parse.quote_plus(b64sig),
|
||||
)
|
||||
return url
|
||||
|
||||
def test_buy_with_sith_account(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.subscriber.customer.amount = 100 # give money before test
|
||||
self.subscriber.customer.save()
|
||||
basket = self.get_busy_basket(self.subscriber)
|
||||
amount = basket.total
|
||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||
self.assertRedirects(response, "/eboutic/pay/success/")
|
||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||
assert float(new_balance) == 100 - amount
|
||||
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
|
||||
assert expected == self.client.cookies["basket_items"].OutputString()
|
||||
|
||||
def test_buy_with_sith_account_no_money(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
basket = self.get_busy_basket(self.subscriber)
|
||||
initial = basket.total - 1 # just not enough to complete the sale
|
||||
self.subscriber.customer.amount = initial
|
||||
self.subscriber.customer.save()
|
||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
||||
self.assertRedirects(response, "/eboutic/pay/failure/")
|
||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||
assert float(new_balance) == initial
|
||||
# this cookie should be removed after payment
|
||||
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
|
||||
assert expected == self.client.cookies["basket_items"].OutputString()
|
||||
|
||||
def test_submit_basket(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.cookies["basket_items"] = """[
|
||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
|
||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||
]"""
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
assert response.status_code == 200
|
||||
self.assertInHTML(
|
||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||
response.text,
|
||||
)
|
||||
self.assertInHTML(
|
||||
"<tr><td>Barbar</td><td>3</td><td>1.70 €</td></tr>",
|
||||
response.text,
|
||||
)
|
||||
assert "basket_id" in self.client.session
|
||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
||||
assert basket.items.count() == 2
|
||||
barbar = basket.items.filter(product_name="Barbar").first()
|
||||
assert barbar is not None
|
||||
assert barbar.quantity == 3
|
||||
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
|
||||
assert cotis is not None
|
||||
assert cotis.quantity == 1
|
||||
assert basket.total == 3 * 1.7 + 28
|
||||
|
||||
def test_submit_empty_basket(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.cookies["basket_items"] = "[]"
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertRedirects(response, "/eboutic/")
|
||||
|
||||
def test_submit_invalid_basket(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
max_id = Product.objects.aggregate(res=Max("id"))["res"]
|
||||
self.client.cookies["basket_items"] = f"""[
|
||||
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
|
||||
]"""
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
cookie = self.client.cookies["basket_items"].OutputString()
|
||||
assert 'basket_items="[]"' in cookie
|
||||
assert "Path=/eboutic" in cookie
|
||||
self.assertRedirects(response, "/eboutic/")
|
||||
|
||||
def test_submit_basket_illegal_quantity(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.cookies["basket_items"] = """[
|
||||
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
|
||||
]"""
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertRedirects(response, "/eboutic/")
|
||||
|
||||
def test_buy_subscribe_product_with_credit_card(self):
|
||||
self.client.force_login(self.old_subscriber)
|
||||
response = self.client.get(
|
||||
reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id})
|
||||
)
|
||||
assert "Non cotisant" in str(response.content)
|
||||
self.client.cookies["basket_items"] = """[
|
||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
|
||||
]"""
|
||||
response = self.client.get(reverse("eboutic:command"))
|
||||
self.assertInHTML(
|
||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
||||
response.text,
|
||||
)
|
||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
||||
assert basket.items.count() == 1
|
||||
response = self.client.get(self.generate_bank_valid_answer())
|
||||
assert response.status_code == 200
|
||||
assert response.content.decode("utf-8") == "Payment successful"
|
||||
|
||||
subscriber = User.objects.get(id=self.old_subscriber.id)
|
||||
assert subscriber.subscriptions.count() == 2
|
||||
sub = subscriber.subscriptions.order_by("-subscription_end").first()
|
||||
assert sub.is_valid_now()
|
||||
assert sub.member == subscriber
|
||||
assert sub.subscription_type == "deux-semestres"
|
||||
assert sub.location == "EBOUTIC"
|
||||
|
||||
def test_buy_refill_product_with_credit_card(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
# basket contains 1 refill item worth 15€
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
|
||||
)
|
||||
initial_balance = self.subscriber.customer.amount
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
|
||||
url = self.generate_bank_valid_answer()
|
||||
response = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.text == "Payment successful"
|
||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
||||
assert new_balance == initial_balance + 15
|
||||
|
||||
def test_alter_basket_after_submission(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||
)
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
et_answer_url = self.generate_bank_valid_answer()
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[ # alter basket
|
||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
||||
]
|
||||
)
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
response = self.client.get(et_answer_url)
|
||||
assert response.status_code == 500
|
||||
msg = (
|
||||
"Basket processing failed with error: "
|
||||
"SuspiciousOperation('Basket total and amount do not match'"
|
||||
)
|
||||
assert msg in response.content.decode("utf-8")
|
||||
|
||||
def test_buy_simple_product_with_credit_card(self):
|
||||
self.client.force_login(self.subscriber)
|
||||
self.client.cookies["basket_items"] = json.dumps(
|
||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
||||
)
|
||||
self.client.get(reverse("eboutic:command"))
|
||||
et_answer_url = self.generate_bank_valid_answer()
|
||||
response = self.client.get(et_answer_url)
|
||||
assert response.status_code == 200
|
||||
assert response.content.decode("utf-8") == "Payment successful"
|
||||
|
||||
selling = (
|
||||
Selling.objects.filter(customer=self.subscriber.customer)
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
assert selling.payment_method == "CARD"
|
||||
assert selling.quantity == 1
|
||||
assert selling.unit_price == self.barbar.selling_price
|
||||
assert selling.counter.type == "EBOUTIC"
|
||||
assert selling.product == self.barbar
|
@ -17,7 +17,7 @@
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
@ -26,11 +26,12 @@ from django.urls import path, register_converter
|
||||
|
||||
from eboutic.converters import PaymentResultConverter
|
||||
from eboutic.views import (
|
||||
EbouticCommand,
|
||||
BillingInfoFormFragment,
|
||||
EbouticCheckout,
|
||||
EbouticMainView,
|
||||
EbouticPayWithSith,
|
||||
EtransactionAutoAnswer,
|
||||
e_transaction_data,
|
||||
eboutic_main,
|
||||
pay_with_sith,
|
||||
EurokPartnerFragment,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
@ -38,11 +39,14 @@ register_converter(PaymentResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
path("", eboutic_main, name="main"),
|
||||
path("command/", EbouticCommand.as_view(), name="command"),
|
||||
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
|
||||
path("", EbouticMainView.as_view(), name="main"),
|
||||
path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
|
||||
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
||||
path(
|
||||
"pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
|
||||
),
|
||||
path("pay/<res:result>/", payment_result, name="payment_result"),
|
||||
path("et_data/", e_transaction_data, name="et_data"),
|
||||
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
||||
path(
|
||||
"et_autoanswer",
|
||||
EtransactionAutoAnswer.as_view(),
|
||||
|
317
eboutic/views.py
317
eboutic/views.py
@ -13,10 +13,11 @@
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sentry_sdk
|
||||
@ -25,60 +26,113 @@ from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||
from cryptography.hazmat.primitives.hashes import SHA1
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
)
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.db.models.fields import forms
|
||||
from django.db.utils import cached_property
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
from django.views.generic import TemplateView, View
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from counter.forms import BillingInfoForm
|
||||
from counter.models import Counter, Customer, Product
|
||||
from eboutic.forms import BasketForm
|
||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
|
||||
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
|
||||
from eboutic.models import (
|
||||
Basket,
|
||||
BasketItem,
|
||||
BillingInfoState,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
get_eboutic_products,
|
||||
)
|
||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from django.utils.html import SafeString
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
|
||||
class BaseEbouticBasketForm(BaseBasketForm):
|
||||
def _check_enough_money(self, *args, **kwargs):
|
||||
# Disable money check
|
||||
...
|
||||
|
||||
|
||||
EbouticBasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
"""Main view of the eboutic application.
|
||||
|
||||
Return an Http response whose content is of type text/html.
|
||||
The latter represents the page from which a user can see
|
||||
the catalogue of products that he can buy and fill
|
||||
his shopping cart.
|
||||
|
||||
The purchasable products are those of the eboutic which
|
||||
belong to a category of products of a product category
|
||||
(orphan products are inaccessible).
|
||||
|
||||
If the session contains a key-value pair that associates "errors"
|
||||
with a list of strings, this pair is removed from the session
|
||||
and its value displayed to the user when the page is rendered.
|
||||
"""
|
||||
errors = request.session.pop("errors", None)
|
||||
products = get_eboutic_products(request.user)
|
||||
context = {
|
||||
"errors": errors,
|
||||
"products": products,
|
||||
"customer_amount": request.user.account_balance,
|
||||
}
|
||||
return render(request, "eboutic/eboutic_main.jinja", context)
|
||||
|
||||
template_name = "eboutic/eboutic_main.jinja"
|
||||
form_class = EbouticBasketForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": get_eboutic(),
|
||||
"allowed_products": {product.id: product for product in self.products},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, formset):
|
||||
if len(formset) == 0:
|
||||
formset.errors.append(_("Your basket is empty"))
|
||||
return self.form_invalid(formset)
|
||||
|
||||
with transaction.atomic():
|
||||
self.basket = Basket.objects.create(user=self.request.user)
|
||||
for form in formset:
|
||||
BasketItem.from_product(
|
||||
form.product, form.cleaned_data["quantity"], self.basket
|
||||
).save()
|
||||
self.basket.save()
|
||||
return super().form_valid(formset)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
||||
|
||||
@cached_property
|
||||
def products(self) -> list[Product]:
|
||||
return get_eboutic_products(self.request.user)
|
||||
|
||||
@cached_property
|
||||
def customer(self) -> Customer:
|
||||
return Customer.get_or_create(self.request.user)[0]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["products"] = self.products
|
||||
context["customer_amount"] = self.request.user.account_balance
|
||||
last_purchase: Selling | None = (
|
||||
self.customer.buyings.filter(counter__type="EBOUTIC")
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
context["last_purchase_time"] = (
|
||||
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@require_GET
|
||||
@ -88,123 +142,126 @@ def payment_result(request, result: str) -> HttpResponse:
|
||||
return render(request, "eboutic/eboutic_payment_result.jinja", context)
|
||||
|
||||
|
||||
class BillingInfoState(Enum):
|
||||
VALID = 1
|
||||
EMPTY = 2
|
||||
MISSING_PHONE_NUMBER = 3
|
||||
class EurokPartnerFragment(IsSubscriberMixin, TemplateView):
|
||||
template_name = "eboutic/eurok_fragment.jinja"
|
||||
|
||||
|
||||
class EbouticCommand(LoginRequiredMixin, TemplateView):
|
||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||
basket: Basket
|
||||
class BillingInfoFormFragment(
|
||||
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
"""Update billing info"""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def post(self, request, *args, **kwargs):
|
||||
return redirect("eboutic:main")
|
||||
model = BillingInfo
|
||||
form_class = BillingInfoForm
|
||||
template_name = "eboutic/eboutic_billing_info.jinja"
|
||||
success_message = _("Billing info registration success")
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
form = BasketForm(request)
|
||||
if not form.is_valid():
|
||||
request.session["errors"] = form.errors
|
||||
request.session.modified = True
|
||||
res = redirect("eboutic:main")
|
||||
res.set_cookie(
|
||||
"basket_items",
|
||||
PurchaseItemList.dump_json(form.cleaned_data, by_alias=True).decode(),
|
||||
path="/eboutic",
|
||||
)
|
||||
return res
|
||||
basket = Basket.from_session(request.session)
|
||||
if basket is not None:
|
||||
basket.items.all().delete()
|
||||
else:
|
||||
basket = Basket.objects.create(user=request.user)
|
||||
request.session["basket_id"] = basket.id
|
||||
request.session.modified = True
|
||||
def get_initial(self):
|
||||
if self.object is None:
|
||||
return {
|
||||
"country": Country(code="FR"),
|
||||
}
|
||||
return {}
|
||||
|
||||
items: list[PurchaseItemSchema] = form.cleaned_data
|
||||
pks = {item.product_id for item in items}
|
||||
products = {p.pk: p for p in Product.objects.filter(pk__in=pks)}
|
||||
db_items = []
|
||||
for pk in pks:
|
||||
quantity = sum(i.quantity for i in items if i.product_id == pk)
|
||||
db_items.append(BasketItem.from_product(products[pk], quantity, basket))
|
||||
BasketItem.objects.bulk_create(db_items)
|
||||
self.basket = basket
|
||||
return super().get(request)
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.object = self.get_object()
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def customer(self) -> Customer:
|
||||
return Customer.get_or_create(self.request.user)[0]
|
||||
|
||||
def form_valid(self, form: BillingInfoForm):
|
||||
form.instance.customer = self.customer
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
# if a BillingInfo already exists, this view will behave like an UpdateView
|
||||
# otherwise, it will behave like a CreateView.
|
||||
return getattr(self.customer, "billing_infos", None)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
default_billing_info = None
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
|
||||
kwargs["action"] = reverse("eboutic:billing_infos")
|
||||
match BillingInfoState.from_model(self.object):
|
||||
case BillingInfoState.EMPTY:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"You must fill your billing infos if you want to pay with your credit card"
|
||||
),
|
||||
)
|
||||
case BillingInfoState.MISSING_PHONE_NUMBER:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"The Crédit Agricole changed its policy related to the billing "
|
||||
+ "information that must be provided in order to pay with a credit card. "
|
||||
+ "If you want to pay with your credit card, you must add a phone number "
|
||||
+ "to the data you already provided.",
|
||||
),
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return self.request.path
|
||||
|
||||
|
||||
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||
model = Basket
|
||||
pk_url_kwarg = "basket_id"
|
||||
context_object_name = "basket"
|
||||
template_name = "eboutic/eboutic_checkout.jinja"
|
||||
fragments = {
|
||||
"billing_infos_form": BillingInfoFormFragment,
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
if hasattr(self.request.user, "customer"):
|
||||
customer = self.request.user.customer
|
||||
kwargs["customer_amount"] = customer.amount
|
||||
if hasattr(customer, "billing_infos"):
|
||||
default_billing_info = customer.billing_infos
|
||||
else:
|
||||
kwargs["customer_amount"] = None
|
||||
# make the enum available in the template
|
||||
kwargs["BillingInfoState"] = BillingInfoState
|
||||
if default_billing_info is None:
|
||||
kwargs["billing_infos_state"] = BillingInfoState.EMPTY
|
||||
elif default_billing_info.phone_number is None:
|
||||
kwargs["billing_infos_state"] = BillingInfoState.MISSING_PHONE_NUMBER
|
||||
else:
|
||||
kwargs["billing_infos_state"] = BillingInfoState.VALID
|
||||
if kwargs["billing_infos_state"] == BillingInfoState.VALID:
|
||||
# the user has already filled all of its billing_infos, thus we can
|
||||
# get it without expecting an error
|
||||
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
|
||||
kwargs["basket"] = self.basket
|
||||
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
|
||||
kwargs["billing_infos"] = {}
|
||||
|
||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||
kwargs["billing_infos"] = json.dumps(
|
||||
dict(self.object.get_e_transaction_data())
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def e_transaction_data(request):
|
||||
basket = Basket.from_session(request.session)
|
||||
if basket is None:
|
||||
return HttpResponse(status=404, content=json.dumps({"data": []}))
|
||||
data = basket.get_e_transaction_data()
|
||||
data = {"data": [{"key": key, "value": val} for key, val in data]}
|
||||
return HttpResponse(status=200, content=json.dumps(data))
|
||||
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
model = Basket
|
||||
pk_url_kwarg = "basket_id"
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def pay_with_sith(request):
|
||||
basket = Basket.from_session(request.session)
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
||||
return redirect("eboutic:main")
|
||||
c = Customer.objects.filter(user__id=basket.user_id).first()
|
||||
if c is None:
|
||||
return redirect("eboutic:main")
|
||||
if c.amount < basket.total:
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
request.session.pop("basket_id", None)
|
||||
res = redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
scope.user = {"username": request.user.username}
|
||||
scope.set_extra("someVariable", e.__repr__())
|
||||
sentry_sdk.capture_message(
|
||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(type_id=refilling).exists():
|
||||
messages.error(
|
||||
self.request,
|
||||
_("You can't buy a refilling with sith money"),
|
||||
)
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
eboutic = get_eboutic()
|
||||
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, e.message)
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
|
||||
class EtransactionAutoAnswer(View):
|
||||
|
@ -25,13 +25,12 @@ import warnings
|
||||
from datetime import timedelta
|
||||
from typing import Final, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.models import Group, Page, SithFile, User
|
||||
from core.models import Group, Page, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from subscription.models import Subscription
|
||||
@ -91,13 +90,8 @@ class Command(BaseCommand):
|
||||
self.NB_CLUBS = options["club_count"]
|
||||
|
||||
root = User.objects.filter(username="root").first()
|
||||
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
self.galaxy_album = Album.objects.create(
|
||||
name="galaxy-register-file",
|
||||
owner=root,
|
||||
is_moderated=True,
|
||||
is_in_sas=True,
|
||||
parent=sas,
|
||||
name="galaxy-register-file", owner=root, is_moderated=True
|
||||
)
|
||||
|
||||
self.make_clubs()
|
||||
@ -285,14 +279,10 @@ class Command(BaseCommand):
|
||||
owner=u,
|
||||
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
||||
is_moderated=True,
|
||||
is_folder=False,
|
||||
parent=self.galaxy_album,
|
||||
is_in_sas=True,
|
||||
file=ContentFile(RED_PIXEL_PNG),
|
||||
original=ContentFile(RED_PIXEL_PNG),
|
||||
compressed=ContentFile(RED_PIXEL_PNG),
|
||||
thumbnail=ContentFile(RED_PIXEL_PNG),
|
||||
mime_type="image/png",
|
||||
size=len(RED_PIXEL_PNG),
|
||||
)
|
||||
)
|
||||
self.picts[i].file.name = self.picts[i].name
|
||||
|
@ -1,14 +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"
|
||||
#
|
||||
#
|
@ -1,39 +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.contrib import admin
|
||||
|
||||
from launderette.models import Launderette, Machine, Slot, Token
|
||||
|
||||
|
||||
@admin.register(Launderette)
|
||||
class LaunderetteAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "counter")
|
||||
|
||||
|
||||
@admin.register(Machine)
|
||||
class MachineAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "launderette", "type", "is_working")
|
||||
|
||||
|
||||
@admin.register(Token)
|
||||
class TokenAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "launderette", "type", "user")
|
||||
autocomplete_fields = ("user",)
|
||||
|
||||
|
||||
@admin.register(Slot)
|
||||
class SlotAdmin(admin.ModelAdmin):
|
||||
list_display = ("machine", "user", "start_date")
|
||||
autocomplete_fields = ("user",)
|
@ -1,181 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("subscription", "0001_initial"), ("counter", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Launderette",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
(
|
||||
"counter",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="launderette",
|
||||
verbose_name="counter",
|
||||
to="counter.Counter",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Launderette"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Machine",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=30, verbose_name="name")),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("WASHING", "Washing"), ("DRYING", "Drying")],
|
||||
max_length=10,
|
||||
verbose_name="type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_working",
|
||||
models.BooleanField(verbose_name="is working", default=True),
|
||||
),
|
||||
(
|
||||
"launderette",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
verbose_name="launderette",
|
||||
to="launderette.Launderette",
|
||||
related_name="machines",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Machine"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Slot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("start_date", models.DateTimeField(verbose_name="start date")),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("WASHING", "Washing"), ("DRYING", "Drying")],
|
||||
max_length=10,
|
||||
verbose_name="type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"machine",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
verbose_name="machine",
|
||||
to="launderette.Machine",
|
||||
related_name="slots",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Slot", "ordering": ["start_date"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Token",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
auto_created=True,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=5, verbose_name="name")),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("WASHING", "Washing"), ("DRYING", "Drying")],
|
||||
max_length=10,
|
||||
verbose_name="type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"borrow_date",
|
||||
models.DateTimeField(
|
||||
null=True, verbose_name="borrow date", blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"launderette",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
verbose_name="launderette",
|
||||
to="launderette.Launderette",
|
||||
related_name="tokens",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
null=True,
|
||||
related_name="tokens",
|
||||
verbose_name="user",
|
||||
to="core.User",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Token", "ordering": ["type", "name"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="slot",
|
||||
name="token",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
null=True,
|
||||
related_name="slots",
|
||||
verbose_name="token",
|
||||
to="launderette.Token",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="slot",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
verbose_name="user",
|
||||
to="core.User",
|
||||
related_name="slots",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="token", unique_together={("name", "launderette", "type")}
|
||||
),
|
||||
]
|
@ -1,193 +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.conf import settings
|
||||
from django.db import DataError, models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from counter.models import Counter
|
||||
|
||||
# Create your models here.
|
||||
|
||||
|
||||
class Launderette(models.Model):
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
counter = models.OneToOneField(
|
||||
Counter,
|
||||
verbose_name=_("counter"),
|
||||
related_name="launderette",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Launderette")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("launderette:launderette_list")
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
||||
m = launderette_club.get_membership_for(user)
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
||||
m = launderette_club.get_membership_for(user)
|
||||
return bool(m and m.role >= 2)
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return user.is_subscribed
|
||||
|
||||
def get_machine_list(self):
|
||||
return Machine.objects.filter(launderette_id=self.id)
|
||||
|
||||
def machine_list(self):
|
||||
return [m.id for m in self.get_machine_list()]
|
||||
|
||||
def get_token_list(self):
|
||||
return Token.objects.filter(launderette_id=self.id)
|
||||
|
||||
def token_list(self):
|
||||
return [t.id for t in self.get_token_list()]
|
||||
|
||||
|
||||
class Machine(models.Model):
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
launderette = models.ForeignKey(
|
||||
Launderette,
|
||||
related_name="machines",
|
||||
verbose_name=_("launderette"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
type = models.CharField(
|
||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
||||
)
|
||||
is_working = models.BooleanField(_("is working"), default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Machine")
|
||||
|
||||
def __str__(self):
|
||||
return "%s %s" % (self._meta.verbose_name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"launderette:launderette_admin",
|
||||
kwargs={"launderette_id": self.launderette.id},
|
||||
)
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
||||
m = launderette_club.get_membership_for(user)
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
|
||||
class Token(models.Model):
|
||||
name = models.CharField(_("name"), max_length=5)
|
||||
launderette = models.ForeignKey(
|
||||
Launderette,
|
||||
related_name="tokens",
|
||||
verbose_name=_("launderette"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
type = models.CharField(
|
||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
||||
)
|
||||
borrow_date = models.DateTimeField(_("borrow date"), null=True, blank=True)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
related_name="tokens",
|
||||
verbose_name=_("user"),
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Token")
|
||||
unique_together = ("name", "launderette", "type")
|
||||
ordering = ["type", "name"]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.__class__._meta.verbose_name} {self.get_type_display()} "
|
||||
f"#{self.name} ({self.launderette.name})"
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.name == "":
|
||||
raise DataError(_("Token name can not be blank"))
|
||||
else:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def is_owned_by(self, user):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
||||
m = launderette_club.get_membership_for(user)
|
||||
return bool(m and m.role >= 9)
|
||||
|
||||
|
||||
class Slot(models.Model):
|
||||
start_date = models.DateTimeField(_("start date"))
|
||||
type = models.CharField(
|
||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
||||
)
|
||||
machine = models.ForeignKey(
|
||||
Machine,
|
||||
related_name="slots",
|
||||
verbose_name=_("machine"),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
token = models.ForeignKey(
|
||||
Token,
|
||||
related_name="slots",
|
||||
verbose_name=_("token"),
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, related_name="slots", verbose_name=_("user"), on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Slot")
|
||||
ordering = ["start_date"]
|
||||
|
||||
def __str__(self):
|
||||
return "User: %s - Date: %s - Type: %s - Machine: %s - Token: %s" % (
|
||||
self.user,
|
||||
self.start_date,
|
||||
self.get_type_display(),
|
||||
self.machine.name,
|
||||
self.token,
|
||||
)
|
||||
|
||||
def is_owned_by(self, user):
|
||||
return user == self.user
|
@ -1,69 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Launderette admin{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Selling{% endtrans %}</h3>
|
||||
<p><a href="{{ url('launderette:main_click', launderette_id=launderette.id) }}">{% trans %}Sell{% endtrans %}</a></p>
|
||||
<hr>
|
||||
<h3>{% trans %}Machines{% endtrans %}</h3>
|
||||
<p><a href="{{ url('launderette:machine_new') }}?launderette={{ launderette.id }}">{% trans %}New machine{% endtrans %}</a></p>
|
||||
<ul>
|
||||
{% for m in launderette.machines.all() %}
|
||||
<li><a href="{{ url('launderette:machine_edit', machine_id=m.id) }}">{{ m }}</a> -
|
||||
<a href="{{ url('launderette:machine_delete', machine_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
<h3>{% trans %}Tokens{% endtrans %}</h3>
|
||||
<p>
|
||||
<form method="post" action="" id="token_form">
|
||||
{% csrf_token %}
|
||||
<p>{{ form.action.errors }}<label for="{{ form.action.name }}">{{ form.action.label }}</label>
|
||||
{% for c in form.action %}
|
||||
{{ c }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>{{ form.token_type.errors }}<label for="{{ form.token_type.name }}">{{ form.token_type.label }}</label>
|
||||
{% for c in form.token_type %}
|
||||
{{ c }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{{ form.tokens }}
|
||||
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
|
||||
</form>
|
||||
</p>
|
||||
<p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% trans %}Type{% endtrans %}</td>
|
||||
<td>{% trans %}Name{% endtrans %}</td>
|
||||
<td>{% trans %}User{% endtrans %}</td>
|
||||
<td>{% trans %}Since{% endtrans %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in launderette.tokens.all() %}
|
||||
<tr>
|
||||
<td>{{ t.get_type_display() }}</td>
|
||||
<td>{{ t.name }}</td>
|
||||
{% if t.user %}
|
||||
<td>{{ t.user.get_display_name() }}</td>
|
||||
<td>{{ t.borrow_date|date(DATETIME_FORMAT) }} - {{ t.borrow_date|time(DATETIME_FORMAT) }}</td>
|
||||
{% else %}
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
@ -1,58 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "core/macros.jinja" import show_slots, show_tokens %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Launderette{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro choose(date) %}
|
||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="slot_type" value="{{ slot_type }}">
|
||||
<button type="submit" name="slot" value="{{ date.isoformat() }}">{% trans %}Choose{% endtrans %}</button>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h3>{{ launderette }}</h3>
|
||||
<p>
|
||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}"
|
||||
class="inline" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="slot_type" value="BOTH" {% if slot_type == "BOTH" -%}style="background: #FF0"{% endif %}>{% trans %}Washing and drying{% endtrans %}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="slot_type" value="WASHING" {% if slot_type == "WASHING" -%}style="background: #FF0"{% endif %}>{% trans %}Washing{% endtrans %}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="slot_type" value="DRYING" {% if slot_type == "DRYING" -%}style="background: #FF0"{% endif %}>{% trans %}Drying{% endtrans %}</button>
|
||||
</form>
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{% for day in planning.keys() %}
|
||||
<th>{{ day|date('l') }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in range(0, 24) %}
|
||||
<tr>
|
||||
{% for hours in planning.values() %}
|
||||
<td>
|
||||
{% if hours[i] %}
|
||||
{{ hours[i]|time(TIME_FORMAT) }} {{ choose(hours[i]) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ show_slots(user) }}
|
||||
{{ show_tokens(user) }}
|
||||
{% endblock %}
|
@ -1,21 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Launderette{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if request.user.is_subscribed %}
|
||||
<ul>
|
||||
{% for l in launderette_list %}
|
||||
<li><a href="{{ url('launderette:book_slot', launderette_id=l.id) }}">{{ l }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1,16 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{{ counter }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans counter_name=counter %}{{ counter_name }} counter{% endtrans %}</h3>
|
||||
<div>
|
||||
<form method="post" action="" class="inline" style="display:inline">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
@ -1,25 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Launderette admin list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_root %}
|
||||
<p><a href="{{ url('launderette:launderette_new') }}">{% trans %}New launderette{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if launderette_list %}
|
||||
<h3>{% trans %}Launderette admin list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{% for l in launderette_list %}
|
||||
<li><a href="{{ url('launderette:launderette_admin', launderette_id=l.id) }}">{{ l }}</a> -
|
||||
<a href="{{ url('launderette:launderette_edit', launderette_id=l.id) }}">{% trans %}Edit{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no launderette in this website.{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user