3 Commits

Author SHA1 Message Date
14e8dc9408 Bump vite from 6.2.5 to 6.2.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.2.5 to 6.2.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.2.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.2.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.2.6
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-11 14:56:23 +00:00
b0febf4838 Merge pull request #1077 from ae-utbm/update-django
Update django to 5.2
2025-04-11 16:55:00 +02:00
3c8933461a Merge pull request #1075 from ae-utbm/taiste
SAS and markdown pictures upload improval, google calendar removal, calendar export link, css fixes and more
2025-04-10 13:15:02 +02:00
104 changed files with 3451 additions and 2629 deletions

View File

@ -10,7 +10,6 @@ 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

View File

@ -11,7 +11,6 @@ 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:

View File

@ -1,2 +1 @@
redis: redis-server --port $REDIS_PORT
celery: uv run celery -A sith worker --beat -l INFO

14
accounting/__init__.py Normal file
View File

@ -0,0 +1,14 @@
#
# 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"
#
#

View File

@ -0,0 +1,280 @@
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"],
},
),
]

View File

@ -0,0 +1,105 @@
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")}
),
]

View File

@ -0,0 +1,48 @@
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),
),
]

View File

@ -0,0 +1,50 @@
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")}
),
]

View File

@ -0,0 +1,17 @@
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"
),
)
]

View File

@ -0,0 +1,34 @@
# 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"),
]

View File

14
accounting/models.py Normal file
View File

@ -0,0 +1,14 @@
#
# 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"
#
#

View File

@ -24,15 +24,13 @@
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, Selling
from counter.models import Counter
class ClubEditForm(forms.ModelForm):
@ -161,20 +159,12 @@ 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"),

View File

@ -16,13 +16,22 @@
</ul>
<h4>{% trans %}Counters:{% endtrans %}</h4>
<ul>
{% 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 %}
{% 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 %}
</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 %}

View File

@ -3,11 +3,8 @@ 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
@ -17,22 +14,3 @@ 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]]

View File

@ -17,12 +17,11 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
static observedAttributes = ["locale", "can_moderate", "can_delete"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
@ -34,10 +33,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
this.helpUrl = newValue;
}
}
isMobile() {
@ -53,11 +48,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
if (this.isMobile()) {
return {
start: "",
center: "getCalendarLink helpButton",
center: "getCalendarLink",
end: "",
};
}
return { start: "getCalendarLink helpButton", center: "", end: "" };
return { start: "getCalendarLink", center: "", end: "" };
}
currentHeaderToolbar() {
@ -92,8 +87,15 @@ export class IcsCalendar extends inheritHtmlElement("div") {
);
}
refreshEvents() {
async 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();
}
@ -112,7 +114,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
this.refreshEvents();
await this.refreshEvents();
}
async unpublishNews(id: number) {
@ -130,7 +132,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
this.refreshEvents();
await this.refreshEvents();
}
async deleteNews(id: number) {
@ -148,23 +150,22 @@ export class IcsCalendar extends inheritHtmlElement("div") {
},
}),
);
this.refreshEvents();
await this.refreshEvents();
}
async getEventSources() {
const cacheInvalidate = `?invalidate=${Date.now()}`;
return [
{
url: `${await makeUrl(calendarCalendarInternal)}`,
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
format: "ics",
className: "internal",
cache: false,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
format: "ics",
color: "red",
className: "unpublished",
cache: false,
},
];
}
@ -319,14 +320,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
click: async (event: Event) => {
const button = event.target as HTMLButtonElement;
button.classList.add("text-copy");
button.setAttribute("tooltip-class", "calendar-copy-tooltip");
if (!button.hasAttribute("tooltip-position")) {
button.setAttribute("tooltip-position", "top");
if (!button.hasAttribute("position")) {
button.setAttribute("tooltip", gettext("Link copied"));
button.setAttribute("position", "top");
button.setAttribute("no-hover", "");
}
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),
@ -334,22 +335,12 @@ 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,
@ -357,7 +348,6 @@ 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());

View File

@ -1,5 +1,4 @@
@import "core/static/core/colors";
@import "core/static/core/tooltips";
:root {
@ -117,33 +116,8 @@ ics-calendar {
transition: 500ms ease-out;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
button.text-copied[tooltip]::before {
opacity: 0;
transition: opacity 500ms ease-out;
}
.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;
}

View File

@ -192,7 +192,6 @@
@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") }}"

View File

@ -32,6 +32,7 @@ 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()

View File

@ -227,19 +227,6 @@ 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.

View File

@ -123,6 +123,11 @@ 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:
@ -286,7 +291,13 @@ class Command(BaseCommand):
page=services_page,
title="Services",
author=skia,
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
content="""
| | | |
| :---: | :---: | :---: |
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
| SAS | Weekmail | Forum|
""",
)
index_page = Page(name="Index")
@ -295,10 +306,23 @@ class Command(BaseCommand):
page=index_page,
title="Wiki index",
author=root,
content="Welcome to the wiki page!",
content="""
Welcome to the wiki page!
""",
)
groups.public.viewable_page.set([syntax_page, services_page, index_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]
)
self._create_subscription(root)
self._create_subscription(skia)
@ -815,7 +839,8 @@ class Command(BaseCommand):
accounting_admin.permissions.add(
*list(
perms.filter(
Q(
Q(content_type__app_label="accounting")
| Q(
codename__in=[
"view_customer",
"view_product",
@ -844,7 +869,7 @@ class Command(BaseCommand):
counter_admin.permissions.add(
*list(
perms.filter(
Q(content_type__app_label__in=["counter"])
Q(content_type__app_label__in=["counter", "launderette"])
& ~Q(codename__in=["delete_product", "delete_producttype"])
)
)

View File

@ -396,10 +396,19 @@ class User(AbstractUser):
return self.is_root
return group in self.cached_groups
@cached_property
@property
def cached_groups(self) -> list[Group]:
"""Get the list of groups this user is in."""
return list(self.groups.all())
"""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
@cached_property
def is_root(self) -> bool:
@ -412,6 +421,14 @@ 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()
@ -659,6 +676,10 @@ 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

15
core/signals.py Normal file
View File

@ -0,0 +1,15 @@
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")

View File

@ -1,174 +0,0 @@
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,
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -45,6 +45,63 @@ 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;
@ -304,22 +361,19 @@ body {
align-items: center;
text-align: justify;
&.alert-yellow,
&.alert-warning {
&.alert-yellow {
background-color: rgb(255, 255, 240);
color: rgb(99, 87, 6);
border: rgb(192, 180, 16) 1px solid;
}
&.alert-green,
&.alert-success {
&.alert-green {
background-color: rgb(245, 255, 245);
color: rgb(3, 84, 63);
border: rgb(14, 159, 110) 1px solid;
}
&.alert-red,
&.alert-error {
&.alert-red {
background-color: rgb(255, 245, 245);
color: #c53030;
border: #fc8181 1px solid;
@ -469,6 +523,33 @@ 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,
@ -566,10 +647,6 @@ th {
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
padding: unset;
}
>ul {
margin-top: 0;
}
@ -769,6 +846,12 @@ textarea {
margin-top: 10px;
}
/*---------------------------LAUNDERETTE-------------------------------*/
#token_form label {
display: inline;
}
/*--------------------------------FOOTER-------------------------------*/
footer {

View File

@ -1,26 +0,0 @@
@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;
}

View File

@ -24,21 +24,13 @@ body {
background-color: white;
margin: 0;
.alert {
word-wrap: break-word;
white-space: normal;
text-align: center;
display: block;
width: fit-content;
}
>.title {
> .title {
text-align: center;
margin: 0;
}
>div,
>form {
> div,
> form {
box-sizing: border-box;
display: flex;
flex-direction: column;
@ -49,8 +41,8 @@ body {
max-width: 500px;
margin-top: 20px;
>p,
>div {
> p,
> div {
display: flex;
flex-direction: column;
justify-content: center;
@ -58,7 +50,7 @@ body {
width: 100%;
margin: 0;
>label {
> label {
width: 100%;
@media (min-width: 500px) {
@ -67,9 +59,9 @@ body {
}
}
>input,
>p>input,
>div>input {
> input,
> p > input,
> div > input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@ -79,35 +71,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;
}
}

View File

@ -7,7 +7,6 @@
<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') }}">
@ -25,7 +24,6 @@
<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>

View File

@ -24,6 +24,7 @@
<span class="head">{% trans %}Services{% endtrans %}</span>
<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>

View File

@ -29,13 +29,14 @@
<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>
{{ form.username }}
<input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
{{ form.username.errors }}
</div>

View File

@ -86,6 +86,19 @@
{% 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>

View File

@ -18,7 +18,7 @@
<form action="{{ url('core:register') }}" method="post">
{% csrf_token %}
{% render_honeypot_field %}
{{ form.as_p() }}
{{ form }}
<input type="submit" value="{% trans %}Register{% endtrans %}" />
</form>
{% endblock %}

View File

@ -141,6 +141,12 @@
{{ 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 %}

View File

@ -411,11 +411,10 @@ 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):
@ -424,7 +423,6 @@ 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):
@ -449,6 +447,24 @@ 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.

View File

@ -318,20 +318,3 @@ 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}))

View File

@ -21,6 +21,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
@ -67,7 +68,6 @@ from core.views import (
UserGodfathersTreeView,
UserGodfathersView,
UserListView,
UserMeRedirect,
UserMiniView,
UserPreferencesView,
UserStatsView,
@ -141,12 +141,6 @@ 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(

View File

@ -48,7 +48,6 @@ from django.views.generic import (
DeleteView,
DetailView,
ListView,
RedirectView,
TemplateView,
)
from django.views.generic.dates import MonthMixin, YearMixin
@ -183,13 +182,6 @@ 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()
@ -572,8 +564,10 @@ 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

View File

@ -1,7 +1,4 @@
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
@ -19,7 +16,6 @@ from counter.models import (
Counter,
Customer,
Eticket,
InvoiceCall,
Product,
Refilling,
ReturnableProduct,
@ -265,144 +261,3 @@ 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
)
class InvoiceCallForm(forms.Form):
def __init__(self, *args, month=None, clubs=None, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
self.clubs = clubs
for club in self.clubs:
field_name = f"club_{club.id}"
initial = (
InvoiceCall.objects.filter(club=club, month=month)
.values_list("is_validated", flat=True)
.first()
)
self.fields[field_name] = forms.BooleanField(
required=False,
initial=initial,
)
def save(self):
for club in self.clubs:
field_name = f"club_{club.id}"
is_validated = self.cleaned_data.get(field_name, False)
InvoiceCall.objects.update_or_create(
month=self.month, club=club, defaults={"is_validated": is_validated}
)
def get_club_name(self, club_id):
return f"club_{club_id}"

View File

@ -1,47 +0,0 @@
# Generated by Django 5.2 on 2025-06-14 14:35
import django.db.models.deletion
from django.db import migrations, models
import counter.models
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("counter", "0031_alter_counter_options"),
]
operations = [
migrations.CreateModel(
name="InvoiceCall",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("is_validated", models.BooleanField(verbose_name="is validated")),
(
"month",
counter.models.MonthField(
max_length=7, verbose_name="invoice date"
),
),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="club.club"
),
),
],
options={
"verbose_name": "Invoice call",
"verbose_name_plural": "Invoice calls",
},
),
]

View File

@ -47,10 +47,6 @@ 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.
@ -1362,58 +1358,3 @@ class ReturnableProductBalance(models.Model):
f"return balance of {self.customer} "
f"for {self.returnable.product_id} : {self.balance}"
)
class MonthField(models.CharField):
description = _("Year + month field")
def __init__(self, *args, **kwargs):
kwargs["max_length"] = 7
super().__init__(*args, **kwargs)
def db_type(self, connection):
return "char(7)"
def from_db_value(self, value, expression, connection):
if value is None:
return value
try:
year, month = value.split("-")
return date(year, month, 1)
except (ValueError, TypeError):
return value
def to_python(self, value):
if isinstance(value, date):
return value
if isinstance(value, str):
try:
year, month = value.split("-")
return date(year, month, 1)
except ValueError:
pass
return value
def get_prep_value(self, value):
if isinstance(value, date):
return value.strftime("%Y-%m")
if isinstance(value, str) and len(value) == 7 and value[4] == "-":
return value
return value
def value_to_string(self, obj):
value = self.value_from_object(obj)
return self.get_prep_value(value)
class InvoiceCall(models.Model):
is_validated = models.BooleanField(verbose_name=_("is validated"), default=False)
club = models.ForeignKey(Club, on_delete=models.CASCADE)
month = MonthField(verbose_name=_("invoice date"))
class Meta:
verbose_name = _("Invoice call")
verbose_name_plural = _("Invoice calls")
def __str__(self):
return f"invoice call of {self.month} made by {self.club}"

View File

@ -15,32 +15,24 @@
</select>
<input type="submit" value="{% trans %}Go{% endtrans %}" />
</form>
<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
</thead>
<tbody>
{% for i in sums %}
<tr>
<td>{{ i['club__name'] }}</td>
<td>{{ i['selling_sum'] }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
<form method="post" action="">
{% csrf_token %}
<br>
<p>{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €</p>
<br>
<table>
<thead>
<td>{% trans %}Club{% endtrans %}</td>
<td>{% trans %}Sum{% endtrans %}</td>
<td>{% trans %}Validated{% endtrans %}</td>
</thead>
<tbody>
{% for data in club_data %}
<tr>
<td>{{ data.club.name }}</td>
<td>{{"%.2f"|format(data.sum)}} €</td>
<td>
{{ form[form.get_club_name(data.club.id)] }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="month" value="{{ start_date|date('Y-m') }}">
<button type="submit">{% trans %}Save validation{% endtrans %}</button>
</form>
{% endblock %}

View File

@ -5,12 +5,12 @@
{% endblock %}
{% block content %}
<main>
<div id="accounting">
<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>
</main>
</div>
{% endblock %}

View File

@ -46,15 +46,6 @@ 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):
@ -235,11 +226,11 @@ class TestCounterClick(TestFullClickBase):
cls.banned_counter_customer = subscriber_user.make()
cls.banned_alcohol_customer = subscriber_user.make()
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.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)
cls.banned_alcohol_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
@ -287,6 +278,11 @@ 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,
@ -310,6 +306,9 @@ 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)
@ -319,7 +318,7 @@ class TestCounterClick(TestFullClickBase):
assert res.status_code == 404
def test_click_office_success(self):
force_refill_user(self.customer, 10)
self.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
@ -328,7 +327,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("2.5")
# Test no special price on office counter
force_refill_user(self.club_admin, 10)
self.refill_user(self.club_admin, 10)
res = self.submit_basket(
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
)
@ -337,7 +336,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.club_admin) == Decimal("8.5")
def test_click_bar_success(self):
force_refill_user(self.customer, 10)
self.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)]
@ -348,7 +347,7 @@ class TestCounterClick(TestFullClickBase):
# Test barmen special price
force_refill_user(self.barmen, 10)
self.refill_user(self.barmen, 10)
assert (
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
@ -357,7 +356,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.barmen) == Decimal("9")
def test_click_tray_price(self):
force_refill_user(self.customer, 20)
self.refill_user(self.customer, 20)
self.login_in_bar(self.barmen)
# Not applying tray price
@ -374,7 +373,7 @@ class TestCounterClick(TestFullClickBase):
self.login_in_bar()
for user in [self.underage_customer, self.banned_alcohol_customer]:
force_refill_user(user, 10)
self.refill_user(user, 10)
# Buy product without age limit
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
@ -395,7 +394,7 @@ class TestCounterClick(TestFullClickBase):
self.banned_counter_customer,
self.customer_old_can_not_buy,
]:
force_refill_user(user, 10)
self.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)
@ -411,7 +410,7 @@ class TestCounterClick(TestFullClickBase):
def test_click_allowed_old_subscriber(self):
self.login_in_bar()
force_refill_user(self.customer_old_can_buy, 10)
self.refill_user(self.customer_old_can_buy, 10)
res = self.submit_basket(
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
)
@ -421,7 +420,7 @@ class TestCounterClick(TestFullClickBase):
def test_click_wrong_counter(self):
self.login_in_bar()
force_refill_user(self.customer, 10)
self.refill_user(self.customer, 10)
res = self.submit_basket(
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
)
@ -445,7 +444,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self):
force_refill_user(self.customer, 10)
self.refill_user(self.customer, 10)
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
assertRedirects(res, self.counter.get_absolute_url())
@ -457,29 +456,15 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_not_in_counter(self):
force_refill_user(self.customer, 10)
self.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):
force_refill_user(self.customer, 10)
self.refill_user(self.customer, 10)
self.login_in_bar()
for item in [
@ -487,12 +472,14 @@ 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):
force_refill_user(self.customer, 10)
self.refill_user(self.customer, 10)
self.login_in_bar()
res = self.submit_basket(
self.customer,
@ -522,7 +509,7 @@ class TestCounterClick(TestFullClickBase):
assert self.updated_amount(self.customer) == 0
def test_recordings(self):
force_refill_user(self.customer, self.cons.selling_price * 3)
self.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

View File

@ -1,4 +1,5 @@
import itertools
import json
import string
from datetime import timedelta
@ -15,6 +16,7 @@ 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,
@ -24,6 +26,149 @@ 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.

View File

@ -12,17 +12,18 @@
# 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.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404
from django.http import HttpResponseRedirect
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 BaseFormView, UpdateView
from django.views.generic.edit import UpdateView
from core.models import User
from core.auth.mixins import CanViewMixin
from counter.forms import CashSummaryFormBase
from counter.models import (
CashRegisterSummary,
@ -30,7 +31,9 @@ from counter.models import (
Counter,
Refilling,
)
from counter.utils import is_logged_in_counter
from counter.views.mixins import (
CounterAdminMixin,
CounterAdminTabsMixin,
CounterTabsMixin,
)
@ -154,9 +157,11 @@ class CashRegisterSummaryForm(forms.Form):
else:
self.instance = None
def save(self, counter: Counter | None = None, user: User | None = None):
def save(self, counter=None):
cd = self.cleaned_data
summary = self.instance or CashRegisterSummary(counter=counter, user=user)
summary = self.instance or CashRegisterSummary(
counter=counter, user=counter.get_random_barman()
)
summary.comment = cd["comment"]
summary.emptied = cd["emptied"]
summary.save()
@ -242,33 +247,48 @@ class CashRegisterSummaryForm(forms.Form):
summary.delete()
class CounterCashSummaryView(
CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView
):
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, 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 self.object.type != "BAR":
raise Http404
return super().dispatch(request, *args, **kwargs)
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"
)
def form_valid(self, form):
form.save(counter=self.object, user=self.request.user)
return super().form_valid(form)
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 get_success_url(self):
return reverse("counter:details", kwargs={"counter_id": self.object.id})
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
class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit cash summaries."""
model = CashRegisterSummary
@ -277,11 +297,12 @@ class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, Update
pk_url_kwarg = "cashsummary_id"
form_class = CashRegisterSummaryForm
current_tab = "cash_summary"
permission_required = "counter.change_cashregistersummary"
success_url = reverse_lazy("counter:cash_summary_list")
def get_success_url(self):
return reverse("counter:cash_summary_list")
class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""Display a list of cash summaries."""
model = CashRegisterSummary
@ -290,7 +311,6 @@ class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListVi
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."""
@ -301,12 +321,12 @@ class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListVi
kwargs["refilling_sums"] = {}
for c in Counter.objects.filter(type="BAR").all():
refillings = Refilling.objects.filter(counter=c)
cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
cashredistersummaries = 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"]
)
cash_register_summaries = cash_register_summaries.filter(
cashredistersummaries = cashredistersummaries.filter(
date__gte=form.cleaned_data["begin_date"]
)
else:
@ -317,16 +337,23 @@ class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListVi
)
if last_summary:
refillings = refillings.filter(date__gt=last_summary.date)
cash_register_summaries = cash_register_summaries.filter(
cashredistersummaries = cashredistersummaries.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"])
cash_register_summaries = cash_register_summaries.filter(
cashredistersummaries = cashredistersummaries.filter(
date__lte=form.cleaned_data["end_date"]
)
kwargs["summaries_sums"][c.name] = sum(
[s.get_total() for s in cash_register_summaries.all()]
[s.get_total() for s in cashredistersummaries.all()]
)
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
return kwargs

View File

@ -12,14 +12,23 @@
# 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
@ -27,10 +36,11 @@ 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 BasketForm, RefillForm
from counter.forms import RefillForm
from counter.models import (
Counter,
Customer,
Product,
ReturnableProduct,
Selling,
)
@ -47,6 +57,113 @@ 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
):

View File

@ -12,17 +12,15 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from datetime import timezone as tz
from django.db.models import Exists, F, OuterRef
from django.shortcuts import redirect
from django.db.models import F
from django.utils import timezone
from django.views.generic import TemplateView
from counter.fields import CurrencyField
from counter.forms import InvoiceCallForm
from counter.models import Club, InvoiceCall, Refilling, Selling
from counter.models import Refilling, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
@ -30,30 +28,12 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
template_name = "counter/invoices_call.jinja"
current_tab = "invoices_call"
def get(self, request, *args, **kwargs):
month_str = request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(month_str, "%Y-%m").date()
today = timezone.now().date().replace(day=1)
if start_date > today:
return redirect("counter:invoices_call")
except ValueError:
return redirect("counter:invoices_call")
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""Add sums to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
month_str = self.request.GET.get("month")
if month_str:
try:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
except ValueError:
return redirect("counter:invoices_call")
if "month" in self.request.GET:
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
else:
start_date = datetime(
year=timezone.now().year,
@ -66,23 +46,30 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
)
from django.db.models import Case, Sum, When
kwargs["sum_cb"] = Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
).aggregate(amount=Sum(F("amount"), default=0))["amount"]
kwargs["sum_cb"] += Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"]
kwargs["sum_cb"] = sum(
[
r.amount
for r in Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
)
kwargs["sum_cb"] += sum(
[
s.quantity * s.unit_price
for s in Selling.objects.filter(
payment_method="CARD",
is_validated=True,
date__gte=start_date,
date__lte=end_date,
)
]
)
kwargs["start_date"] = start_date
kwargs["sums"] = list(
kwargs["sums"] = (
Selling.objects.values("club__name")
.annotate(
selling_sum=Sum(
@ -99,56 +86,4 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
.exclude(selling_sum=None)
.order_by("-selling_sum")
)
club_names = [i["club__name"] for i in kwargs["sums"]]
clubs = Club.objects.filter(name__in=club_names)
invoice_calls = InvoiceCall.objects.filter(month=month_str, club__in=clubs)
invoice_statuses = {ic.club.name: ic.is_validated for ic in invoice_calls}
kwargs["form"] = InvoiceCallForm(clubs=clubs, month=month_str)
kwargs["club_data"] = []
for club in clubs:
selling_sum = next(
(
item["selling_sum"]
for item in kwargs["sums"]
if item["club__name"] == club.name
),
0,
)
kwargs["club_data"].append(
{
"club": club,
"sum": selling_sum,
"validated": invoice_statuses.get(club.name, False),
}
)
return kwargs
def post(self, request, *args, **kwargs):
month_str = request.POST.get("month")
if not month_str:
return self.get(request, *args, **kwargs)
try:
start_date = datetime.strptime(month_str, "%Y-%m")
start_date = date(start_date.year, start_date.month, 1)
except ValueError:
return redirect(request.path)
selling_subquery = Selling.objects.filter(
club=OuterRef("pk"),
date__year=start_date.year,
date__month=start_date.month,
)
clubs = Club.objects.filter(Exists(selling_subquery))
form = InvoiceCallForm(request.POST, clubs=clubs, month=month_str)
if form.is_valid():
form.save()
return redirect(f"{request.path}?month={request.POST.get('month', '')}")

View File

@ -15,12 +15,11 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.urls import 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):
@ -50,40 +49,34 @@ class CounterTabsMixin(TabedViewMixin):
return self.object
def get_list_of_tabs(self):
if self.object.type != "BAR":
return []
tab_list = [
{
"url": reverse(
"url": reverse_lazy(
"counter:details", kwargs={"counter_id": self.object.id}
),
"slug": "counter",
"name": _("Counter"),
}
]
if self.request.user.has_perm("counter.add_cashregistersummary"):
if self.object.type == "BAR":
tab_list.append(
{
"url": reverse(
"url": reverse_lazy(
"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(
"url": reverse_lazy(
"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

View File

@ -131,31 +131,6 @@ 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

View File

@ -0,0 +1 @@
::: launderette.models

View File

@ -0,0 +1 @@
::: launderette.views

View File

@ -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:6379/0
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/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,26 +279,6 @@ 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

View File

@ -100,6 +100,7 @@ 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)
@ -112,7 +113,6 @@ 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,12 +122,17 @@ uv run ./manage.py install_xapian
de texte à l'écran.
C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables
d'environnement.
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.
Ces variables sont stockées dans un fichier `.env`.
Pour le créer, vous pouvez copier le fichier `.env.example` :
Les variables par défaut contenues dans le fichier `.env.example`
```bash
cp .env.example .env
```
Les variables par défaut contenues dans le fichier `.env`
devraient convenir pour le développement, sans modification.
Maintenant que les dépendances sont installées

View File

@ -24,64 +24,66 @@ sith/
├── .github/
│ ├── actions/ (1)
│ └── workflows/ (2)
├── club/ (3)
├── accounting/ (3)
│ └── ...
├── com/ (4)
├── club/ (4)
│ └── ...
├── core/ (5)
├── com/ (5)
│ └── ...
├── counter/ (6)
├── core/ (6)
│ └── ...
├── docs/ (7)
├── counter/ (7)
│ └── ...
├── eboutic/ (8)
├── docs/ (8)
│ └── ...
├── election/ (9)
├── eboutic/ (9)
│ └── ...
├── forum/ (10)
├── election/ (10)
│ └── ...
├── galaxy/ (11)
├── forum/ (11)
│ └── ...
├── launderette/ (12)
├── galaxy/ (12)
│ └── ...
├── locale/ (13)
├── launderette/ (13)
│ └── ...
├── matmat/ (14)
├── locale/ (14)
│ └── ...
├── pedagogy/ (15)
├── matmat/ (15)
│ └── ...
├── rootplace/ (16)
├── pedagogy/ (16)
│ └── ...
├── sas/ (17)
├── rootplace/ (17)
│ └── ...
├── sith/ (18)
├── sas/ (18)
│ └── ...
├── subscription/ (19)
├── sith/ (19)
│ └── ...
├── trombi/ (20)
├── subscription/ (20)
│ └── ...
├── antispam/ (21)
├── trombi/ (21)
│ └── ...
├── staticfiles/ (22)
├── antispam/ (22)
│ └── ...
├── processes/ (23)
├── staticfiles/ (23)
│ └── ...
├── processes/ (24)
│ └── ...
├── .coveragerc (24)
├── .envrc (25)
├── .coveragerc (25)
├── .envrc (26)
├── .gitattributes
├── .gitignore
├── .mailmap
├── .env (26)
├── .env.example (27)
├── manage.py (28)
├── mkdocs.yml (29)
├── .env (27)
├── .env.example (28)
├── manage.py (29)
├── mkdocs.yml (30)
├── uv.lock
├── pyproject.toml (30)
├── .venv/ (31)
├── .python-version (32)
├── Procfile.static (33)
├── Procfile.service (34)
├── pyproject.toml (31)
├── .venv/ (32)
├── .python-version (33)
├── Procfile.static (34)
├── Procfile.service (35)
└── README.md
```
</div>
@ -94,54 +96,55 @@ 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 des clubs et de leurs membres.
4. Application contenant les fonctionnalités
3. Application de gestion de la comptabilité.
4. Application de gestion des clubs et de leurs membres.
5. Application contenant les fonctionnalités
destinées aux responsables communication de l'AE.
5. Application contenant la modélisation centrale du site.
6. Application contenant la modélisation centrale du site.
On en reparle plus loin sur cette page.
6. Application de gestion des comptoirs, des permanences
7. Application de gestion des comptoirs, des permanences
sur ces comptoirs et des transactions qui y sont effectuées.
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
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
est un graphe des niveaux de proximité entre les différents
étudiants.
12. Gestion des machines à laver de l'AE
13. Dossier contenant les fichiers de traduction.
14. Fonctionnalités de recherche d'utilisateurs.
15. Le guide des UEs du site, sur lequel les utilisateurs
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
peuvent également laisser leurs avis.
16. Fonctionnalités utiles aux utilisateurs root.
17. Le SAS, où l'on trouve toutes les photos de l'AE.
18. Application principale du projet, contenant sa configuration.
19. Gestion des cotisations des utilisateurs du site.
20. Outil pour faciliter la fabrication des trombinoscopes de promo.
21. Fonctionnalités pour gérer le spam.
22. Gestion des statics du site. Override le système de statics de Django.
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.
Ajoute l'intégration du scss et du bundler js
de manière transparente pour l'utilisateur.
23. Module de gestion des services externes.
24. Module de gestion des services externes.
Offre une API simple pour utiliser les fichiers `Procfile.*`.
24. Fichier de configuration de coverage.
25. Fichier de configuration de direnv.
26. Contient les variables d'environnement, qui sont susceptibles
25. Fichier de configuration de coverage.
26. Fichier de configuration de direnv.
27. Contient les variables d'environnement, qui sont susceptibles
de varier d'une machine à l'autre.
27. Contient des valeurs par défaut pour le `.env`
28. Contient des valeurs par défaut pour le `.env`
pouvant convenir à un environnment de développement local
28. Fichier généré automatiquement par Django. C'est lui
29. 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>`
29. Le fichier de configuration de la documentation,
30. Le fichier de configuration de la documentation,
avec ses plugins et sa table des matières.
30. Le fichier où sont déclarés les dépendances et la configuration
31. Le fichier où sont déclarés les dépendances et la configuration
de certaines d'entre elles.
31. Dossier d'environnement virtuel généré par uv
32. Fichier qui contrôle quelle version de python utiliser pour le projet
33. Fichier qui contrôle les commandes à lancer pour gérer la compilation
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
automatique des static et autres services nécessaires à la command runserver.
34. Fichier qui contrôle les services tiers nécessaires au fonctionnement
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
du Sith tel que redis.
## L'application principale

View File

@ -32,39 +32,48 @@ susnommés afin de comprendre comment celui-ci marche.
Cette application contient les vues suivantes :
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
- `eboutic_main` (GET) : 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.
Elle est en charge de récupérer le formulaire de création d'un panier et
redirige alors vers la vue de checkout.
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"``.
- ``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.
- ``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
- ``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
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_checkout.jinja`` : template contenant un résumé du panier et deux
- ``eboutic_makecommand.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 ``EbouticCheckout``
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
Elle permet également de mettre à jour ses coordonnées de paiement
Retourné par la vue ``EbouticCommand``
- ``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 le localStorage du navigateur.
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
(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.
# Les modèles

View File

@ -1,21 +1,38 @@
from django.shortcuts import get_object_or_404
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound
from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.permissions import IsAuthenticated
from pydantic import NonNegativeInt
from core.auth.api_permissions import CanView
from counter.models import BillingInfo
from core.models import User
from counter.models import BillingInfo, Customer
from eboutic.models import Basket
from eboutic.schemas import BillingInfoSchema
@api_controller("/etransaction", permissions=[CanView])
@api_controller("/etransaction", permissions=[IsAuthenticated])
class EtransactionInfoController(ControllerBase):
@route.get("/data/{basket_id}", url_name="etransaction_data")
def fetch_etransaction_data(self, basket_id: int):
@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):
"""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 = 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
basket = Basket.from_session(self.context.request.session)
if basket is None:
raise NotFound
return dict(basket.get_e_transaction_data())

128
eboutic/forms.py Normal file
View File

@ -0,0 +1,128 @@
#
# 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

View File

@ -16,7 +16,6 @@ from __future__ import annotations
import hmac
from datetime import datetime
from enum import Enum
from typing import Any, Self
from dict2xml import dict2xml
@ -28,56 +27,23 @@ 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,
get_eboutic,
)
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
Counter.objects.get(type="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."""
@ -93,9 +59,6 @@ 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(
@ -110,6 +73,13 @@ 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.
@ -144,7 +114,7 @@ class Basket(models.Model):
club=product.club,
product=product,
seller=seller,
customer=Customer.get_or_create(self.user)[0],
customer=self.user.customer,
unit_price=item.product_unit_price,
quantity=item.quantity,
payment_method=payment_method,
@ -157,11 +127,7 @@ class Basket(models.Model):
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
customer = user.customer
if (
not hasattr(user.customer, "billing_infos")
or BillingInfoState.from_model(user.customer.billing_infos)
!= BillingInfoState.VALID
):
if not hasattr(user.customer, "billing_infos"):
raise BillingInfo.DoesNotExist
cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}

View File

@ -1,22 +0,0 @@
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;
}
},
}));
});

View File

@ -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", (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();
},
Alpine.data("basket", () => ({
items: getStartingItems() as BasketItem[],
/**
* Get the total price of the basket
* @returns {number} The total price of the basket
*/
getTotal() {
return this.basket.reduce(
return this.items.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
0,
);
@ -68,6 +68,7 @@ document.addEventListener("alpine:init", () => {
*/
add(item: BasketItem) {
item.quantity++;
this.setCookies();
},
/**
@ -75,25 +76,39 @@ document.addEventListener("alpine:init", () => {
* @param itemId the id of the item to remove
*/
remove(itemId: number) {
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
if (index < 0) {
return;
}
this.basket[index].quantity -= 1;
this.items[index].quantity -= 1;
if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter(
(e: BasketItem) => e.id !== this.basket[index].id,
if (this.items[index].quantity === 0) {
this.items = this.items.filter(
(e: BasketItem) => e.id !== this.items[index].id,
);
}
this.setCookies();
},
/**
* Remove all the items from the basket & cleans the catalog CSS classes
*/
clearBasket() {
this.basket = [];
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`;
}
},
/**
@ -112,7 +127,7 @@ document.addEventListener("alpine:init", () => {
unit_price: price,
} as BasketItem;
this.basket.push(newItem);
this.items.push(newItem);
this.add(newItem);
return newItem;
@ -126,7 +141,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product
*/
addFromCatalog(id: number, name: string, price: number) {
let item = this.basket.find((e: BasketItem) => e.id === id);
let item = this.items.find((e: BasketItem) => e.id === id);
// if the item is not in the basket, we create it
// else we add + 1 to it

View File

@ -0,0 +1,91 @@
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 "";
},
}));
});

View File

@ -61,7 +61,6 @@
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
#eboutic .fa-plus,

View File

@ -1,42 +0,0 @@
<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>

View File

@ -1,91 +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/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 %}

View File

@ -21,90 +21,58 @@
{% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
{% 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="eboutic" x-data="basket">
<div id="basket">
<h3>Panier</h3>
<form method="post" action="">
{% csrf_token %}
<div x-ref="basketManagementForm">
{{ form.management_form }}
</div>
{% 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>
{% if errors %}
<div class="alert alert-red">
<div class="alert-main">
{% for error in errors %}
<p style="margin: 0">{{ error }}</p>
{% endfor %}
</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>
</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>
</div>
<span class="item-name" x-text="item.name"></span>
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
</li>
<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>
</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 %}
<button class="btn btn-blue">
<i class="fa fa-check"></i>
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
</button>
</div>
</form>
</form>
</div>
</div>
<div id="catalog">
{% if not request.user.date_of_birth %}
@ -128,36 +96,23 @@
<div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
{% if user.is_subscribed %}
<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>
<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>
{% else %}
<p>
{%- trans trimmed %}
@ -191,7 +146,7 @@
<button
id="{{ p.id }}"
class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
>
{% if p.icon %}

View File

@ -0,0 +1,158 @@
{% 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 %}

View File

@ -4,14 +4,6 @@
<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 %}

View File

@ -1,17 +0,0 @@
<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>

View File

@ -1,179 +0,0 @@
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
)

View File

@ -1,138 +0,0 @@
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()

View File

@ -1,269 +0,0 @@
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')"
)

254
eboutic/tests/tests.py Normal file
View File

@ -0,0 +1,254 @@
#
# 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

View File

@ -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 Software Foundation, Inc., 59 Temple
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
@ -26,12 +26,11 @@ from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter
from eboutic.views import (
BillingInfoFormFragment,
EbouticCheckout,
EbouticMainView,
EbouticPayWithSith,
EbouticCommand,
EtransactionAutoAnswer,
EurokPartnerFragment,
e_transaction_data,
eboutic_main,
pay_with_sith,
payment_result,
)
@ -39,14 +38,11 @@ register_converter(PaymentResultConverter, "res")
urlpatterns = [
# Subscription views
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("", eboutic_main, name="main"),
path("command/", EbouticCommand.as_view(), name="command"),
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
path("pay/<res:result>/", payment_result, name="payment_result"),
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
path("et_data/", e_transaction_data, name="et_data"),
path(
"et_autoanswer",
EtransactionAutoAnswer.as_view(),

View File

@ -13,11 +13,10 @@
#
#
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
@ -26,113 +25,60 @@ 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.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, transaction
from django.db.models.fields import forms
from django.db.utils import cached_property
from django.http import HttpResponse
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
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 django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, View
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 counter.forms import BillingInfoForm
from counter.models import Counter, Customer, Product
from eboutic.forms import BasketForm
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
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):
@login_required
@require_GET
def eboutic_main(request: HttpRequest) -> HttpResponse:
"""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.
"""
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
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)
@require_GET
@ -142,126 +88,123 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context)
class EurokPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurok_fragment.jinja"
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
MISSING_PHONE_NUMBER = 3
class BillingInfoFormFragment(
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
):
"""Update billing info"""
class EbouticCommand(LoginRequiredMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket
model = BillingInfo
form_class = BillingInfoForm
template_name = "eboutic/eboutic_billing_info.jinja"
success_message = _("Billing info registration success")
@method_decorator(login_required)
def post(self, request, *args, **kwargs):
return redirect("eboutic:main")
def get_initial(self):
if self.object is None:
return {
"country": Country(code="FR"),
}
return {}
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 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)
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 get_context_data(self, **kwargs):
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)
default_billing_info = None
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
kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data())
)
# 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)
return kwargs
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
model = Basket
pk_url_kwarg = "basket_id"
@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))
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"),
@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"
)
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")
res = redirect("eboutic:payment_result", "failure")
res.delete_cookie("basket_items", "/eboutic")
return res
class EtransactionAutoAnswer(View):

39
launderette/admin.py Normal file
View File

@ -0,0 +1,39 @@
#
# 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",)

View File

@ -1,14 +0,0 @@
# Generated by Django 5.2 on 2025-04-15 19:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("launderette", "0001_initial")]
operations = [
migrations.DeleteModel(name="Launderette"),
migrations.DeleteModel(name="Machine"),
migrations.DeleteModel(name="Slot"),
migrations.DeleteModel(name="Token"),
]

193
launderette/models.py Normal file
View File

@ -0,0 +1,193 @@
#
# 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

View File

@ -0,0 +1,69 @@
{% 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 %}

View File

@ -0,0 +1,58 @@
{% 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 %}

View File

@ -0,0 +1,21 @@
{% 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 %}

View File

@ -0,0 +1,16 @@
{% 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 %}

View File

@ -0,0 +1,25 @@
{% 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 %}

View File

@ -0,0 +1,21 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Launderette{% endtrans %}
{% endblock %}
{% block content %}
{% if request.user.can_edit(page) %}
<p><a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit presentation page{% endtrans %}</a></p>
{% endif %}
{% if request.user.is_subscribed %}
<p><a href="{{ url('launderette:book_main') }}">{% trans %}Book launderette slot{% endtrans %}</a></p>
{% endif %}
{{ page.revisions.last().content|markdown }}
{% endblock %}

16
launderette/tests.py Normal file
View File

@ -0,0 +1,16 @@
#
# 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"
#
#
# Create your tests here.

73
launderette/urls.py Normal file
View File

@ -0,0 +1,73 @@
#
# 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.urls import path
from launderette.views import (
LaunderetteAdminView,
LaunderetteBookMainView,
LaunderetteBookView,
LaunderetteClickView,
LaunderetteCreateView,
LaunderetteEditView,
LaunderetteListView,
LaunderetteMainClickView,
LaunderetteMainView,
MachineCreateView,
MachineDeleteView,
MachineEditView,
SlotDeleteView,
)
urlpatterns = [
# views
path("", LaunderetteMainView.as_view(), name="launderette_main"),
path("slot/<int:slot_id>/delete/", SlotDeleteView.as_view(), name="delete_slot"),
path("book/", LaunderetteBookMainView.as_view(), name="book_main"),
path("book/<int:launderette_id>/", LaunderetteBookView.as_view(), name="book_slot"),
path(
"<int:launderette_id>/click/",
LaunderetteMainClickView.as_view(),
name="main_click",
),
path(
"<int:launderette_id>/click/<int:user_id>/",
LaunderetteClickView.as_view(),
name="click",
),
path("admin/", LaunderetteListView.as_view(), name="launderette_list"),
path(
"admin/<int:launderette_id>/",
LaunderetteAdminView.as_view(),
name="launderette_admin",
),
path(
"admin/<int:launderette_id>/edit/",
LaunderetteEditView.as_view(),
name="launderette_edit",
),
path("admin/new/", LaunderetteCreateView.as_view(), name="launderette_new"),
path("admin/machine/new/", MachineCreateView.as_view(), name="machine_new"),
path(
"admin/machine/<int:machine_id>/edit/",
MachineEditView.as_view(),
name="machine_edit",
),
path(
"admin/machine/<int:machine_id>/delete/",
MachineDeleteView.as_view(),
name="machine_delete",
),
]

511
launderette/views.py Normal file
View File

@ -0,0 +1,511 @@
#
# 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 collections import OrderedDict
from datetime import datetime, timedelta
from datetime import timezone as tz
from django import forms
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.template import defaultfilters
from django.urls import reverse_lazy
from django.utils import dateparse, timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import BaseFormView, CreateView, DeleteView, UpdateView
from club.models import Club
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Page, User
from counter.forms import GetUserForm
from counter.models import Counter, Customer, Selling
from launderette.models import Launderette, Machine, Slot, Token
# For users
class LaunderetteMainView(TemplateView):
"""Main presentation view."""
template_name = "launderette/launderette_main.jinja"
def get_context_data(self, **kwargs):
"""Add page to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["page"] = Page.objects.filter(name="launderette").first()
return kwargs
class LaunderetteBookMainView(CanViewMixin, ListView):
"""Choose which launderette to book."""
model = Launderette
template_name = "launderette/launderette_book_choose.jinja"
class LaunderetteBookView(CanViewMixin, DetailView):
"""Display the launderette schedule."""
model = Launderette
pk_url_kwarg = "launderette_id"
template_name = "launderette/launderette_book.jinja"
def get(self, request, *args, **kwargs):
self.slot_type = "BOTH"
self.machines = {}
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.slot_type = "BOTH"
self.machines = {}
with transaction.atomic():
self.object = self.get_object()
if "slot_type" in request.POST:
self.slot_type = request.POST["slot_type"]
if "slot" in request.POST and request.user.is_authenticated:
self.subscriber = request.user
if self.subscriber.is_subscribed:
self.date = dateparse.parse_datetime(request.POST["slot"]).replace(
tzinfo=tz.utc
)
if self.slot_type in ["WASHING", "DRYING"]:
if self.check_slot(self.slot_type):
Slot(
user=self.subscriber,
start_date=self.date,
machine=self.machines[self.slot_type],
type=self.slot_type,
).save()
elif self.check_slot("WASHING") and self.check_slot(
"DRYING", self.date + timedelta(hours=1)
):
Slot(
user=self.subscriber,
start_date=self.date,
machine=self.machines["WASHING"],
type="WASHING",
).save()
Slot(
user=self.subscriber,
start_date=self.date + timedelta(hours=1),
machine=self.machines["DRYING"],
type="DRYING",
).save()
return super().get(request, *args, **kwargs)
def check_slot(self, machine_type, date=None):
if date is None:
date = self.date
for m in self.object.machines.filter(is_working=True, type=machine_type):
slot = Slot.objects.filter(start_date=date, machine=m).first()
if slot is None:
self.machines[machine_type] = m
return True
return False
@staticmethod
def date_iterator(startDate, endDate, delta=timedelta(days=1)):
currentDate = startDate
while currentDate < endDate:
yield currentDate
currentDate += delta
def get_context_data(self, **kwargs):
"""Add page to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["planning"] = OrderedDict()
kwargs["slot_type"] = self.slot_type
start_date = datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0, tzinfo=tz.utc
)
for date in LaunderetteBookView.date_iterator(
start_date, start_date + timedelta(days=6), timedelta(days=1)
):
kwargs["planning"][date] = []
for h in LaunderetteBookView.date_iterator(
date, date + timedelta(days=1), timedelta(hours=1)
):
free = False
if (
(
self.slot_type == "BOTH"
and self.check_slot("WASHING", h)
and self.check_slot("DRYING", h + timedelta(hours=1))
)
or (self.slot_type == "WASHING" and self.check_slot("WASHING", h))
or (self.slot_type == "DRYING" and self.check_slot("DRYING", h))
):
free = True
if free and datetime.now().replace(tzinfo=tz.utc) < h:
kwargs["planning"][date].append(h)
else:
kwargs["planning"][date].append(None)
return kwargs
class SlotDeleteView(CanEditPropMixin, DeleteView):
"""Delete a slot."""
model = Slot
pk_url_kwarg = "slot_id"
template_name = "core/delete_confirm.jinja"
def get_success_url(self):
return self.request.user.get_absolute_url()
# For admins
class LaunderetteListView(CanEditPropMixin, ListView):
"""Choose which launderette to administer."""
model = Launderette
template_name = "launderette/launderette_list.jinja"
class LaunderetteEditView(CanEditPropMixin, UpdateView):
"""Edit a launderette."""
model = Launderette
pk_url_kwarg = "launderette_id"
fields = ["name"]
template_name = "core/edit.jinja"
class LaunderetteCreateView(PermissionRequiredMixin, CreateView):
"""Create a new launderette."""
model = Launderette
fields = ["name"]
template_name = "core/create.jinja"
permission_required = "launderette.add_launderette"
def form_valid(self, form):
club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
c = Counter(name=form.instance.name, club=club, type="OFFICE")
c.save()
form.instance.counter = c
return super().form_valid(form)
class ManageTokenForm(forms.Form):
action = forms.ChoiceField(
choices=[("BACK", _("Back")), ("ADD", _("Add")), ("DEL", _("Delete"))],
initial="BACK",
label=_("Action"),
widget=forms.RadioSelect,
)
token_type = forms.ChoiceField(
choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES,
label=_("Type"),
initial="WASHING",
widget=forms.RadioSelect,
)
tokens = forms.CharField(
max_length=512,
widget=forms.widgets.Textarea,
label=_("Tokens, separated by spaces"),
)
def process(self, launderette):
cleaned_data = self.cleaned_data
token_list = cleaned_data["tokens"].strip(" \n\r").split(" ")
token_type = cleaned_data["token_type"]
self.data = {}
if cleaned_data["action"] not in ["BACK", "ADD", "DEL"]:
return
tokens = list(
Token.objects.filter(
launderette=launderette, type=token_type, name__in=token_list
)
)
existing_names = {t.name for t in tokens}
if cleaned_data["action"] in ["BACK", "DEL"]:
for t in set(token_list) - existing_names:
self.add_error(
None,
_("Token %(token_name)s does not exists") % {"token_name": t},
)
if cleaned_data["action"] == "BACK":
Token.objects.filter(id__in=[t.id for t in tokens]).update(
borrow_date=None, user=None
)
elif cleaned_data["action"] == "DEL":
Token.objects.filter(id__in=[t.id for t in tokens]).delete()
elif cleaned_data["action"] == "ADD":
for name in existing_names:
self.add_error(
None,
_("Token %(token_name)s already exists") % {"token_name": name},
)
for t in token_list:
if t == "":
self.add_error(None, _("Token name can not be blank"))
else:
Token(launderette=launderette, type=token_type, name=t).save()
class LaunderetteAdminView(CanEditPropMixin, BaseFormView, DetailView):
"""The admin page of the launderette."""
model = Launderette
pk_url_kwarg = "launderette_id"
template_name = "launderette/launderette_admin.jinja"
form_class = ManageTokenForm
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer."""
form.process(self.object)
if form.is_valid():
return super().form_valid(form)
else:
return super().form_invalid(form)
def get_context_data(self, **kwargs):
"""We handle here the login form for the barman."""
kwargs = super().get_context_data(**kwargs)
if self.request.method == "GET":
kwargs["form"] = self.get_form()
return kwargs
def get_success_url(self):
return reverse_lazy(
"launderette:launderette_admin", args=self.args, kwargs=self.kwargs
)
class GetLaunderetteUserForm(GetUserForm):
def clean(self):
cleaned_data = super().clean()
sub = cleaned_data["user"]
if sub.slots.all().count() <= 0:
raise forms.ValidationError(_("User has booked no slot"))
return cleaned_data
class LaunderetteMainClickView(CanEditMixin, BaseFormView, DetailView):
"""The click page of the launderette."""
model = Launderette
pk_url_kwarg = "launderette_id"
template_name = "counter/counter_main.jinja"
form_class = GetLaunderetteUserForm # Form to enter a client code and get the corresponding user id
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer."""
self.kwargs["user_id"] = form.cleaned_data["user_id"]
return super().form_valid(form)
def get_context_data(self, **kwargs):
"""We handle here the login form for the barman."""
kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.object.counter
kwargs["form"] = self.get_form()
kwargs["barmen"] = [self.request.user]
if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket", None)
kwargs["last_customer"] = self.request.session.pop("last_customer", None)
kwargs["last_total"] = self.request.session.pop("last_total", None)
kwargs["new_customer_amount"] = self.request.session.pop(
"new_customer_amount", None
)
return kwargs
def get_success_url(self):
return reverse_lazy("launderette:click", args=self.args, kwargs=self.kwargs)
class ClickTokenForm(forms.BaseForm):
def clean(self):
with transaction.atomic():
operator = User.objects.filter(id=self.operator_id).first()
customer = Customer.objects.filter(user__id=self.subscriber_id).first()
counter = Counter.objects.filter(id=self.counter_id).first()
subscriber = customer.user
self.last_basket = {
"last_basket": [],
"last_customer": customer.user.get_display_name(),
}
total = 0
for k, t in self.cleaned_data.items():
if t is not None:
slot_id = int(k[5:])
slot = Slot.objects.filter(id=slot_id).first()
slot.token = t
slot.save()
t.user = subscriber
t.borrow_date = datetime.now().replace(tzinfo=tz.utc)
t.save()
price = settings.SITH_LAUNDERETTE_PRICES[t.type]
s = Selling(
label="Jeton " + t.get_type_display() + "" + t.name,
club=counter.club,
product=None,
counter=counter,
unit_price=price,
quantity=1,
seller=operator,
customer=customer,
)
s.save()
total += price
self.last_basket["last_basket"].append(
"Jeton " + t.get_type_display() + "" + t.name
)
self.last_basket["new_customer_amount"] = str(customer.amount)
self.last_basket["last_total"] = str(total)
return self.cleaned_data
class LaunderetteClickView(CanEditMixin, DetailView, BaseFormView):
"""The click page of the launderette."""
model = Launderette
pk_url_kwarg = "launderette_id"
template_name = "launderette/launderette_click.jinja"
def get_form_class(self):
fields = OrderedDict()
kwargs = {}
def clean_field_factory(field_name, slot):
def clean_field(self2):
t_name = str(self2.data[field_name])
if t_name != "":
t = Token.objects.filter(
name=str(self2.data[field_name]),
type=slot.type,
launderette=self.object,
user=None,
).first()
if t is None:
raise forms.ValidationError(_("Token not found"))
return t
return clean_field
for s in self.subscriber.slots.filter(
token=None, start_date__gte=timezone.now().replace(tzinfo=None)
).all():
field_name = "slot-%s" % (str(s.id))
fields[field_name] = forms.CharField(
max_length=5,
required=False,
label="%s - %s"
% (
s.get_type_display(),
defaultfilters.date(s.start_date, "j N Y H:i"),
),
)
# XXX l10n settings.DATETIME_FORMAT didn't work here :/
kwargs["clean_" + field_name] = clean_field_factory(field_name, s)
kwargs["subscriber_id"] = self.subscriber.id
kwargs["counter_id"] = self.object.counter.id
kwargs["operator_id"] = self.operator.id
kwargs["base_fields"] = fields
return type("ClickForm", (ClickTokenForm,), kwargs)
def get(self, request, *args, **kwargs):
"""Simple get view."""
self.customer = Customer.objects.filter(user__id=self.kwargs["user_id"]).first()
self.subscriber = self.customer.user
self.operator = request.user
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Handle the many possibilities of the post request."""
self.object = self.get_object()
self.customer = Customer.objects.filter(user__id=self.kwargs["user_id"]).first()
self.subscriber = self.customer.user
self.operator = request.user
return super().post(request, *args, **kwargs)
def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer."""
self.request.session.update(form.last_basket)
return super().form_valid(form)
def get_context_data(self, **kwargs):
"""We handle here the login form for the barman."""
kwargs = super().get_context_data(**kwargs)
if "form" not in kwargs:
kwargs["form"] = self.get_form()
kwargs["counter"] = self.object.counter
kwargs["customer"] = self.customer
return kwargs
def get_success_url(self):
self.kwargs.pop("user_id", None)
return reverse_lazy(
"launderette:main_click", args=self.args, kwargs=self.kwargs
)
class MachineEditView(CanEditPropMixin, UpdateView):
"""Edit a machine."""
model = Machine
pk_url_kwarg = "machine_id"
fields = ["name", "launderette", "type", "is_working"]
template_name = "core/edit.jinja"
class MachineDeleteView(CanEditPropMixin, DeleteView):
"""Edit a machine."""
model = Machine
pk_url_kwarg = "machine_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("launderette:launderette_list")
class MachineCreateView(PermissionRequiredMixin, CreateView):
"""Create a new machine."""
model = Machine
fields = ["name", "launderette", "type"]
template_name = "core/create.jinja"
permission_required = "launderette.add_machine"
def get_initial(self):
ret = super().get_initial()
if "launderette" in self.request.GET:
obj = Launderette.objects.filter(
id=int(self.request.GET["launderette"])
).first()
if obj is not None:
ret["launderette"] = obj.id
return ret

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 23:39+0200\n"
"POT-Creation-Date: 2025-04-08 16:20+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -1764,8 +1764,8 @@ msgstr "Photos"
#: core/templates/core/base/navbar.jinja counter/models.py
#: counter/templates/counter/counter_list.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
msgid "Eboutic"
msgstr "Eboutic"
@ -2882,30 +2882,6 @@ msgstr ""
msgid "Refound this account"
msgstr "Rembourser ce compte"
#: counter/forms.py
msgid "The selected product isn't available for this user"
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/forms.py
msgid "Submitted basket is invalid"
msgstr "Le panier envoyé est invalide"
#: counter/forms.py
msgid "Duplicated product entries."
msgstr "Saisie de produit dupliquée"
#: counter/forms.py counter/models.py
msgid "Not enough money"
msgstr "Solde insuffisant"
#: counter/forms.py
#, python-format
msgid ""
"This user have reached his recording limit for the following products : %s"
msgstr ""
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
"suivants : %s"
#: counter/management/commands/dump_accounts.py
msgid "Your AE account has been emptied"
msgstr "Votre compte AE a été vidé"
@ -2930,6 +2906,10 @@ msgstr "client"
msgid "customers"
msgstr "clients"
#: counter/models.py counter/views/click.py
msgid "Not enough money"
msgstr "Solde insuffisant"
#: counter/models.py
msgid "First name"
msgstr "Prénom"
@ -3298,7 +3278,7 @@ msgid "Go"
msgstr "Valider"
#: counter/templates/counter/counter_click.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Basket: "
msgstr "Panier : "
@ -3692,6 +3672,26 @@ msgstr "Montant du chèque"
msgid "Check quantity"
msgstr "Nombre de chèque"
#: counter/views/click.py
msgid "The selected product isn't available for this user"
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
#: counter/views/click.py
msgid "Submitted basket is invalid"
msgstr "Le panier envoyé est invalide"
#: counter/views/click.py
msgid "Duplicated product entries."
msgstr "Saisie de produit dupliquée"
#: counter/views/click.py
#, python-format
msgid ""
"This user have reached his recording limit for the following products : %s"
msgstr ""
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
"suivants : %s"
#: counter/views/eticket.py
msgid "people(s)"
msgstr "personne(s)"
@ -3729,6 +3729,19 @@ msgstr "Types de produit"
msgid "%(name)s has no registered student card"
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
#: eboutic/forms.py
msgid "The request was badly formatted."
msgstr "La requête a été mal formatée."
#: eboutic/forms.py
msgid "Your basket is empty."
msgstr "Votre panier est vide"
#: eboutic/forms.py
#, python-format
msgid "%(name)s : this product does not exist or may no longer be available."
msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible."
#: eboutic/models.py
msgid "validated"
msgstr "validé"
@ -3757,58 +3770,25 @@ msgstr "panier"
msgid "invoice"
msgstr "facture"
#: eboutic/templates/eboutic/eboutic_billing_info.jinja
msgid "Billing information"
msgstr "Informations de facturation"
#: eboutic/templates/eboutic/eboutic_billing_info.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Basket state"
msgstr "État du panier"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Basket amount: "
msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Current account amount: "
msgstr "Solde actuel : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Remaining account amount: "
msgstr "Solde restant : "
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"AE account payment disabled because your basket contains refilling items."
msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement."
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"AE account payment disabled because you do not have enough money remaining."
msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Pay with Sith account"
msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Basket amount: "
msgstr "Valeur du panier : "
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear"
msgstr "Vider"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Validate"
msgstr "Valider"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You have not filled in your date of birth. As a result, you may not have "
@ -3828,26 +3808,6 @@ msgstr "cette page"
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"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."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
@ -3869,34 +3829,26 @@ msgstr ""
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Payment successful"
msgstr "Le paiement a été effectué"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Basket state"
msgstr "État du panier"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Payment failed"
msgstr "Le paiement a échoué"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Remaining account amount: "
msgstr "Solde restant : "
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Return to eboutic"
msgstr "Retourner à l'eboutic"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Billing information"
msgstr "Informations de facturation"
#: eboutic/views.py
msgid "Your basket is empty"
msgstr "Votre panier est vide"
#: eboutic/views.py
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
#: eboutic/views.py
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"You must fill your billing infos if you want to pay with your credit card"
msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire"
#: eboutic/views.py
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"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 "
@ -3908,9 +3860,38 @@ msgstr ""
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: eboutic/views.py
msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"AE account payment disabled because your basket contains refilling items."
msgstr ""
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
"rechargement."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid ""
"AE account payment disabled because you do not have enough money remaining."
msgstr ""
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
msgid "Pay with Sith account"
msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Payment successful"
msgstr "Le paiement a été effectué"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Payment failed"
msgstr "Le paiement a échoué"
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
msgid "Return to eboutic"
msgstr "Retourner à l'eboutic"
#: election/models.py
msgid "start candidature"
@ -5401,6 +5382,10 @@ msgstr "fin"
msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject"
msgstr "Refuser"

View File

@ -7,7 +7,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
"POT-Creation-Date: 2025-04-09 23:23+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@ -37,10 +37,6 @@ msgstr "Supprimer"
msgid "Copy calendar link"
msgstr "Copier le lien du calendrier"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "How to use calendar link"
msgstr "Comment utiliser le lien du calendrier"
#: com/static/bundled/com/components/ics-calendar-index.ts
msgid "Link copied"
msgstr "Lien copié"
@ -251,6 +247,18 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Incorrect value"
msgstr "Valeur incorrecte"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Billing info registration success"
msgstr "Informations de facturation enregistrées"
#: eboutic/static/bundled/eboutic/makecommand-index.ts
msgid "Billing info registration failure"
msgstr "Echec de l'enregistrement des informations de facturation."
#: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s"

View File

@ -18,10 +18,10 @@ import logging
import os
import sys
from django.conf import settings
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
from sith.composer import start_composer, stop_composer
from sith.settings import PROCFILE_SERVICE
if __name__ == "__main__":
logging.basicConfig(encoding="utf-8", level=logging.INFO)
@ -30,11 +30,8 @@ if __name__ == "__main__":
from django.core.management import execute_from_command_line
if (
os.environ.get(DJANGO_AUTORELOAD_ENV) is None
and settings.PROCFILE_SERVICE is not None
):
start_composer(settings.PROCFILE_SERVICE)
_ = atexit.register(stop_composer, procfile=settings.PROCFILE_SERVICE)
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and PROCFILE_SERVICE is not None:
start_composer(PROCFILE_SERVICE)
_ = atexit.register(stop_composer, procfile=PROCFILE_SERVICE)
execute_from_command_line(sys.argv)

View File

@ -113,6 +113,9 @@ nav:
- galaxy:
- reference/galaxy/models.md
- reference/galaxy/views.md
- launderette:
- reference/launderette/models.md
- reference/launderette/views.md
- matmat:
- reference/matmat/models.md
- reference/matmat/views.md

34
package-lock.json generated
View File

@ -11,7 +11,6 @@
"dependencies": {
"@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
@ -47,7 +46,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"vite": "^6.2.5",
"vite": "^6.2.6",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^2.1.0"
}
@ -2163,31 +2162,6 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
@ -5731,9 +5705,9 @@
}
},
"node_modules/vite": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -31,14 +31,13 @@
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"vite": "^6.2.5",
"vite": "^6.2.6",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^2.1.0"
},
"dependencies": {
"@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",

View File

@ -49,9 +49,6 @@ dependencies = [
"requests>=2.32.3",
"honcho>=2.0.0",
"psutil>=7.0.0",
"celery[redis]>=5.5.1",
"django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0",
]
[project.urls]

View File

@ -46,9 +46,7 @@ class AlbumController(ControllerBase):
@paginate(PageNumberPaginationExtra, page_size=50)
def fetch_album(self, filters: Query[AlbumFilterSchema]):
"""General-purpose album search."""
return filters.filter(
Album.objects.viewable_by(self.context.request.user).order_by("-date")
)
return filters.filter(Album.objects.viewable_by(self.context.request.user))
@route.get(
"/autocomplete-search",
@ -65,9 +63,7 @@ class AlbumController(ControllerBase):
If you don't need the path of the albums,
do NOT use this route.
"""
return filters.filter(
Album.objects.viewable_by(self.context.request.user).order_by("-date")
)
return filters.filter(Album.objects.viewable_by(self.context.request.user))
@api_controller("/sas/picture")

View File

@ -27,9 +27,7 @@ class AlbumCreateForm(forms.ModelForm):
self.instance.moderator = owner
def clean(self):
parent = self.cleaned_data["parent"]
parent.__class__ = Album # by default, parent is a SithFile
if not self.instance.owner.can_edit(parent):
if not self.instance.owner.can_edit(self.instance.parent):
raise ValidationError(_("You do not have the permission to do that"))
return super().clean()

View File

@ -59,7 +59,7 @@
{% endfor %}
</div>
{% if album_create_fragment %}
{% if is_sas_admin %}
</form>
<br>
{{ album_create_fragment }}

View File

@ -15,13 +15,12 @@
from typing import Callable
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.core.cache import cache
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
from pytest_django.asserts import assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Group, User
@ -42,37 +41,16 @@ from sas.models import Album, Picture
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(User),
lambda: None,
],
)
def test_load_main_page(client: Client, user_factory: Callable[[], User]):
"""Just check that the SAS doesn't crash."""
user = user_factory()
if user is not None:
client.force_login(user)
client.force_login(user)
res = client.get(reverse("sas:main"))
assert res.status_code == 200
@pytest.mark.django_db
def test_main_page_no_form_for_regular_users(client: Client):
"""Test that subscribed users see no form on the sas main page"""
client.force_login(subscriber_user.make())
res = client.get(reverse("sas:main"))
soup = BeautifulSoup(res.text, "lxml")
forms = soup.find("main").find_all("form")
assert len(forms) == 0
@pytest.mark.django_db
def test_main_page_content_anonymous(client: Client):
"""Test that public users see only an incentive to login"""
res = client.get(reverse("sas:main"))
soup = BeautifulSoup(res.text, "lxml")
expected = "<h3>SAS</h3><p>Vous devez être connecté pour voir les photos.</p>"
assertHTMLEqual(soup.find("main").decode_contents(), expected)
@pytest.mark.django_db
def test_album_access_non_subscriber(client: Client):
"""Test that non-subscribers can only access albums where they are identified."""
@ -89,50 +67,6 @@ def test_album_access_non_subscriber(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
class TestAlbumUpload:
@staticmethod
def assert_album_created(response, name, parent):
assert response.headers.get("HX-Redirect", "") == parent.get_absolute_url()
children = list(Album.objects.filter(parent=parent))
assert len(children) == 1
assert children[0].name == name
def test_sas_admin(self, client: Client):
user = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
client.force_login(user)
response = client.post(
reverse("sas:album_create"), {"name": "new", "parent": album.id}
)
self.assert_album_created(response, "new", album)
def test_non_admin_user_with_edit_rights_on_parent(self, client: Client):
group = baker.make(Group)
user = subscriber_user.make(groups=[group])
album = baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, edit_groups=[group]
)
client.force_login(user)
response = client.post(
reverse("sas:album_create"), {"name": "new", "parent": album.id}
)
self.assert_album_created(response, "new", album)
def test_permission_denied(self, client: Client):
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
client.force_login(subscriber_user.make())
response = client.post(
reverse("sas:album_create"), {"name": "new", "parent": album.id}
)
errors = BeautifulSoup(response.text, "lxml").find_all(class_="errorlist")
assert len(errors) == 1
assert errors[0].text == "Vous n'avez pas la permission de faire cela"
assert not album.children.exists()
class TestSasModeration(TestCase):
@classmethod
def setUpTestData(cls):

View File

@ -65,16 +65,12 @@ class SASMainView(UseFragmentsMixin, TemplateView):
template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]:
if not self.request.user.has_perm("sas.add_album"):
return {}
form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
return {
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
}
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
if not self.request.user.has_perm("sas.add_album"):
return {}
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}}

View File

@ -12,9 +12,3 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)

Some files were not shown because too many files have changed in this diff Show More