mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-07 19:55:20 +00:00
Merge pull request #1107 from ae-utbm/taiste
Eboutic refactor, Celery, better tooltips, Python 3.13, bugfixes and other
This commit is contained in:
commit
61e370cf73
@ -10,6 +10,7 @@ DATABASE_URL=sqlite:///db.sqlite3
|
|||||||
|
|
||||||
REDIS_PORT=7963
|
REDIS_PORT=7963
|
||||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
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
|
# Used to select which other services to run alongside
|
||||||
# manage.py, pytest and runserver
|
# manage.py, pytest and runserver
|
||||||
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -11,6 +11,7 @@ env:
|
|||||||
SECRET_KEY: notTheRealOne
|
SECRET_KEY: notTheRealOne
|
||||||
DATABASE_URL: sqlite:///db.sqlite3
|
DATABASE_URL: sqlite:///db.sqlite3
|
||||||
CACHE_URL: redis://127.0.0.1:6379/0
|
CACHE_URL: redis://127.0.0.1:6379/0
|
||||||
|
TASK_BROKER_URL: redis://127.0.0.1:6379/1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.11.4
|
rev: v0.11.11
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff # just check the code, and print the errors
|
- id: ruff-check # just check the code, and print the errors
|
||||||
- id: ruff # actually fix the fixable errors, but print nothing
|
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||||
args: ["--fix", "--silent"]
|
args: ["--fix", "--silent"]
|
||||||
# Run the formatter.
|
# Run the formatter.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
@ -1 +1 @@
|
|||||||
3.12
|
3.13
|
@ -1 +1,2 @@
|
|||||||
redis: redis-server --port $REDIS_PORT
|
redis: redis-server --port $REDIS_PORT
|
||||||
|
celery: uv run celery -A sith worker --beat -l INFO
|
@ -24,13 +24,15 @@
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
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 django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDate, SelectDateTime
|
from core.views.forms import SelectDate, SelectDateTime
|
||||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
||||||
from counter.models import Counter
|
from counter.models import Counter, Selling
|
||||||
|
|
||||||
|
|
||||||
class ClubEditForm(forms.ModelForm):
|
class ClubEditForm(forms.ModelForm):
|
||||||
@ -159,12 +161,20 @@ class SellingsForm(forms.Form):
|
|||||||
label=_("End date"), widget=SelectDateTime, required=False
|
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):
|
def __init__(self, club, *args, **kwargs):
|
||||||
super().__init__(*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(
|
self.fields["products"] = forms.ModelMultipleChoiceField(
|
||||||
club.products.order_by("name").filter(archived=False).all(),
|
club.products.order_by("name").filter(archived=False).all(),
|
||||||
label=_("Products"),
|
label=_("Products"),
|
||||||
|
@ -16,22 +16,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
|
||||||
{% for l in Launderette.objects.all() %}
|
|
||||||
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
|
|
||||||
{% for c in object.counters.filter(type="OFFICE") %}
|
{% for c in object.counters.filter(type="OFFICE") %}
|
||||||
<li>{{ c }}:
|
<li>{{ c }}:
|
||||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
|
||||||
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -3,8 +3,11 @@ from django.test import Client
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from club.forms import SellingsForm
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
|
from counter.models import Counter, Customer
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@ -14,3 +17,22 @@ def test_sales_page_doesnt_crash(client: Client):
|
|||||||
client.force_login(admin)
|
client.force_login(admin)
|
||||||
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
|
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
|
||||||
assert response.status_code == 200
|
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]]
|
||||||
|
@ -17,11 +17,12 @@ import {
|
|||||||
|
|
||||||
@registerComponent("ics-calendar")
|
@registerComponent("ics-calendar")
|
||||||
export class IcsCalendar extends inheritHtmlElement("div") {
|
export class IcsCalendar extends inheritHtmlElement("div") {
|
||||||
static observedAttributes = ["locale", "can_moderate", "can_delete"];
|
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
|
||||||
private calendar: Calendar;
|
private calendar: Calendar;
|
||||||
private locale = "en";
|
private locale = "en";
|
||||||
private canModerate = false;
|
private canModerate = false;
|
||||||
private canDelete = false;
|
private canDelete = false;
|
||||||
|
private helpUrl = "";
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||||
if (name === "locale") {
|
if (name === "locale") {
|
||||||
@ -33,6 +34,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
if (name === "can_delete") {
|
if (name === "can_delete") {
|
||||||
this.canDelete = newValue.toLowerCase() === "true";
|
this.canDelete = newValue.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === "ics-help-url") {
|
||||||
|
this.helpUrl = newValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMobile() {
|
isMobile() {
|
||||||
@ -48,11 +53,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
return {
|
return {
|
||||||
start: "",
|
start: "",
|
||||||
center: "getCalendarLink",
|
center: "getCalendarLink helpButton",
|
||||||
end: "",
|
end: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { start: "getCalendarLink", center: "", end: "" };
|
return { start: "getCalendarLink helpButton", center: "", end: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
currentHeaderToolbar() {
|
currentHeaderToolbar() {
|
||||||
@ -87,15 +92,8 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshEvents() {
|
refreshEvents() {
|
||||||
this.click(); // Remove focus from popup
|
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();
|
this.calendar.refetchEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +112,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.refreshEvents();
|
this.refreshEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async unpublishNews(id: number) {
|
async unpublishNews(id: number) {
|
||||||
@ -132,7 +130,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.refreshEvents();
|
this.refreshEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteNews(id: number) {
|
async deleteNews(id: number) {
|
||||||
@ -150,22 +148,23 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.refreshEvents();
|
this.refreshEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEventSources() {
|
async getEventSources() {
|
||||||
const cacheInvalidate = `?invalidate=${Date.now()}`;
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
|
url: `${await makeUrl(calendarCalendarInternal)}`,
|
||||||
format: "ics",
|
format: "ics",
|
||||||
className: "internal",
|
className: "internal",
|
||||||
|
cache: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
|
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
||||||
format: "ics",
|
format: "ics",
|
||||||
color: "red",
|
color: "red",
|
||||||
className: "unpublished",
|
className: "unpublished",
|
||||||
|
cache: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -320,14 +319,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
click: async (event: Event) => {
|
click: async (event: Event) => {
|
||||||
const button = event.target as HTMLButtonElement;
|
const button = event.target as HTMLButtonElement;
|
||||||
button.classList.add("text-copy");
|
button.classList.add("text-copy");
|
||||||
if (!button.hasAttribute("position")) {
|
button.setAttribute("tooltip-class", "calendar-copy-tooltip");
|
||||||
button.setAttribute("tooltip", gettext("Link copied"));
|
if (!button.hasAttribute("tooltip-position")) {
|
||||||
button.setAttribute("position", "top");
|
button.setAttribute("tooltip-position", "top");
|
||||||
button.setAttribute("no-hover", "");
|
|
||||||
}
|
}
|
||||||
if (button.classList.contains("text-copied")) {
|
if (button.classList.contains("text-copied")) {
|
||||||
button.classList.remove("text-copied");
|
button.classList.remove("text-copied");
|
||||||
}
|
}
|
||||||
|
button.setAttribute("tooltip", gettext("Link copied"));
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
new URL(
|
new URL(
|
||||||
await makeUrl(calendarCalendarInternal),
|
await makeUrl(calendarCalendarInternal),
|
||||||
@ -335,12 +334,22 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
).toString(),
|
).toString(),
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied");
|
||||||
button.classList.remove("text-copied");
|
button.classList.remove("text-copied");
|
||||||
button.classList.add("text-copied");
|
button.classList.add("text-copied");
|
||||||
button.classList.remove("text-copy");
|
button.classList.remove("text-copy");
|
||||||
}, 1500);
|
}, 1500);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
helpButton: {
|
||||||
|
text: "?",
|
||||||
|
hint: gettext("How to use calendar link"),
|
||||||
|
click: () => {
|
||||||
|
if (this.helpUrl) {
|
||||||
|
window.open(this.helpUrl, "_blank");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
height: "auto",
|
height: "auto",
|
||||||
locale: this.locale,
|
locale: this.locale,
|
||||||
@ -348,6 +357,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
|||||||
headerToolbar: this.currentHeaderToolbar(),
|
headerToolbar: this.currentHeaderToolbar(),
|
||||||
footerToolbar: this.currentFooterToolbar(),
|
footerToolbar: this.currentFooterToolbar(),
|
||||||
eventSources: await this.getEventSources(),
|
eventSources: await this.getEventSources(),
|
||||||
|
lazyFetching: false,
|
||||||
windowResize: () => {
|
windowResize: () => {
|
||||||
this.calendar.changeView(this.currentView());
|
this.calendar.changeView(this.currentView());
|
||||||
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
|
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
@import "core/static/core/colors";
|
@import "core/static/core/colors";
|
||||||
|
@import "core/static/core/tooltips";
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@ -116,8 +117,33 @@ ics-calendar {
|
|||||||
transition: 500ms ease-out;
|
transition: 500ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.text-copied[tooltip]::before {
|
.fc .fc-getCalendarLink-button {
|
||||||
opacity: 0;
|
margin-right: 0.5rem;
|
||||||
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;
|
||||||
|
}
|
@ -192,6 +192,7 @@
|
|||||||
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
|
@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-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
|
||||||
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
|
@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() }}"
|
locale="{{ get_language() }}"
|
||||||
can_moderate="{{ user.has_perm("com.moderate_news") }}"
|
can_moderate="{{ user.has_perm("com.moderate_news") }}"
|
||||||
can_delete="{{ user.has_perm("com.delete_news") }}"
|
can_delete="{{ user.has_perm("com.delete_news") }}"
|
||||||
|
@ -32,7 +32,6 @@ class SithConfig(AppConfig):
|
|||||||
verbose_name = "Core app of the Sith"
|
verbose_name = "Core app of the Sith"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import core.signals # noqa F401
|
|
||||||
from forum.models import Forum
|
from forum.models import Forum
|
||||||
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
@ -123,11 +123,6 @@ class Command(BaseCommand):
|
|||||||
name="PdF",
|
name="PdF",
|
||||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
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")
|
self.reset_index("club")
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
@ -291,13 +286,7 @@ class Command(BaseCommand):
|
|||||||
page=services_page,
|
page=services_page,
|
||||||
title="Services",
|
title="Services",
|
||||||
author=skia,
|
author=skia,
|
||||||
content="""
|
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
|
||||||
| | | |
|
|
||||||
| :---: | :---: | :---: |
|
|
||||||
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
|
|
||||||
| SAS | Weekmail | Forum|
|
|
||||||
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
index_page = Page(name="Index")
|
index_page = Page(name="Index")
|
||||||
@ -306,23 +295,10 @@ class Command(BaseCommand):
|
|||||||
page=index_page,
|
page=index_page,
|
||||||
title="Wiki index",
|
title="Wiki index",
|
||||||
author=root,
|
author=root,
|
||||||
content="""
|
content="Welcome to the wiki page!",
|
||||||
Welcome to the wiki page!
|
|
||||||
""",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
laundry_page = Page(name="launderette")
|
groups.public.viewable_page.set([syntax_page, services_page, index_page])
|
||||||
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(root)
|
||||||
self._create_subscription(skia)
|
self._create_subscription(skia)
|
||||||
@ -868,7 +844,7 @@ Welcome to the wiki page!
|
|||||||
counter_admin.permissions.add(
|
counter_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
perms.filter(
|
perms.filter(
|
||||||
Q(content_type__app_label__in=["counter", "launderette"])
|
Q(content_type__app_label__in=["counter"])
|
||||||
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -396,19 +396,10 @@ class User(AbstractUser):
|
|||||||
return self.is_root
|
return self.is_root
|
||||||
return group in self.cached_groups
|
return group in self.cached_groups
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def cached_groups(self) -> list[Group]:
|
def cached_groups(self) -> list[Group]:
|
||||||
"""Get the list of groups this user is in.
|
"""Get the list of groups this user is in."""
|
||||||
|
return list(self.groups.all())
|
||||||
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
|
@cached_property
|
||||||
def is_root(self) -> bool:
|
def is_root(self) -> bool:
|
||||||
@ -421,14 +412,6 @@ class User(AbstractUser):
|
|||||||
def is_board_member(self) -> bool:
|
def is_board_member(self) -> bool:
|
||||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
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
|
@cached_property
|
||||||
def is_banned_alcohol(self) -> bool:
|
def is_banned_alcohol(self) -> bool:
|
||||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||||
@ -676,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
|
|||||||
def is_board_member(self):
|
def is_board_member(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
|
||||||
def is_launderette_manager(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_banned_alcohol(self):
|
def is_banned_alcohol(self):
|
||||||
return False
|
return False
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
from django.core.cache import cache
|
|
||||||
from django.db.models.signals import m2m_changed
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=User.groups.through, dispatch_uid="user_groups_changed")
|
|
||||||
def user_groups_changed(sender, instance: User, **kwargs):
|
|
||||||
"""Clear the cached groups of the user."""
|
|
||||||
# As a m2m relationship doesn't live within the model
|
|
||||||
# but rather on an intermediary table, there is no
|
|
||||||
# model method to override, meaning we must use
|
|
||||||
# a signal to invalidate the cache when a user is removed from a group
|
|
||||||
cache.delete(f"user_{instance.pk}_groups")
|
|
174
core/static/bundled/core/tooltips-index.ts
Normal file
174
core/static/bundled/core/tooltips-index.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
type Placement,
|
||||||
|
autoPlacement,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library usage:
|
||||||
|
*
|
||||||
|
* Add a `tooltip` attribute to any html element with it's tooltip text
|
||||||
|
* You can control the position of the tooltp with the `tooltip-position` attribute
|
||||||
|
* Allowed placements are `auto`, `top`, `right`, `bottom`, `left`
|
||||||
|
* You can add `-start` and `-end` to all allowed placement values except for `auto`
|
||||||
|
* Default placement is `auto`
|
||||||
|
* Note: placement are suggestions and the position could change if the popup gets
|
||||||
|
* outside of the screen.
|
||||||
|
*
|
||||||
|
* You can customize your tooltip by passing additional classes or ids to it
|
||||||
|
* You can use `tooltip-class` and `tooltip-id` to add additional elements to the
|
||||||
|
* `class` and `id` attribute of the generated tooltip
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <p tooltip="tooltip text"></p>
|
||||||
|
* <p tooltip="tooltip left" tooltip-position="left"></p>
|
||||||
|
* <div tooltip="tooltip custom class" tooltip-class="custom custom-class"></div>
|
||||||
|
* ```
|
||||||
|
**/
|
||||||
|
|
||||||
|
type Status = "open" | "close";
|
||||||
|
|
||||||
|
const tooltips: Map<HTMLElement, HTMLElement> = new Map();
|
||||||
|
|
||||||
|
function getPosition(element: HTMLElement): Placement | "auto" {
|
||||||
|
const position = element.getAttribute("tooltip-position");
|
||||||
|
if (position) {
|
||||||
|
return position as Placement | "auto";
|
||||||
|
}
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMiddleware(element: HTMLElement) {
|
||||||
|
const middleware = [offset(6), size()];
|
||||||
|
if (getPosition(element) === "auto") {
|
||||||
|
middleware.push(autoPlacement());
|
||||||
|
} else {
|
||||||
|
middleware.push(flip());
|
||||||
|
}
|
||||||
|
return { middleware: middleware };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlacement(element: HTMLElement) {
|
||||||
|
const position = getPosition(element);
|
||||||
|
if (position !== "auto") {
|
||||||
|
return { placement: position };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTooltip(element: HTMLElement) {
|
||||||
|
const tooltip = document.createElement("div");
|
||||||
|
document.body.append(tooltip);
|
||||||
|
tooltips.set(element, tooltip);
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
|
||||||
|
// Update tooltip status and set it's attributes and content
|
||||||
|
tooltip.setAttribute("tooltip-status", status);
|
||||||
|
tooltip.innerText = element.getAttribute("tooltip");
|
||||||
|
|
||||||
|
for (const attributes of [
|
||||||
|
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
|
||||||
|
{ src: "tooltip-id", dst: "id", default: [] },
|
||||||
|
]) {
|
||||||
|
const populated = attributes.default;
|
||||||
|
if (element.hasAttribute(attributes.src)) {
|
||||||
|
populated.push(...element.getAttribute(attributes.src).split(" "));
|
||||||
|
}
|
||||||
|
tooltip.setAttribute(attributes.dst, populated.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTooltip(element: HTMLElement) {
|
||||||
|
const tooltip = tooltips.get(element);
|
||||||
|
if (tooltip === undefined) {
|
||||||
|
return createTooltip(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipMouseover(event: MouseEvent) {
|
||||||
|
// We get the closest tooltip to have a consistent behavior
|
||||||
|
// when hovering over a child element of a tooltip marked element
|
||||||
|
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||||
|
const tooltip = getTooltip(target);
|
||||||
|
updateTooltip(target, tooltip, "open");
|
||||||
|
|
||||||
|
computePosition(target, tooltip, {
|
||||||
|
...getPlacement(target),
|
||||||
|
...getMiddleware(target),
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(tooltip.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tooltipMouseout(event: MouseEvent) {
|
||||||
|
// We get the closest tooltip to have a consistent behavior
|
||||||
|
// when hovering over a child element of a tooltip marked element
|
||||||
|
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
|
||||||
|
updateTooltip(target, getTooltip(target), "close");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
for (const el of document.querySelectorAll("[tooltip]")) {
|
||||||
|
el.addEventListener("mouseover", tooltipMouseover);
|
||||||
|
el.addEventListener("mouseout", tooltipMouseout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add / remove callback when tooltip attribute is added / removed
|
||||||
|
new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const target = mutation.target as HTMLElement;
|
||||||
|
target.removeEventListener("mouseover", tooltipMouseover);
|
||||||
|
target.removeEventListener("mouseout", tooltipMouseout);
|
||||||
|
if (target.hasAttribute("tooltip")) {
|
||||||
|
target.addEventListener("mouseover", tooltipMouseover);
|
||||||
|
target.addEventListener("mouseout", tooltipMouseout);
|
||||||
|
if (target.matches(":hover")) {
|
||||||
|
target.dispatchEvent(new Event("mouseover", { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
target.dispatchEvent(new Event("mouseout", { bubbles: true }));
|
||||||
|
}
|
||||||
|
} else if (tooltips.has(target)) {
|
||||||
|
// Remove corresponding tooltip
|
||||||
|
tooltips.get(target).remove();
|
||||||
|
tooltips.delete(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["tooltip", "tooltip-class", "toolitp-position", "tooltip-id"],
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove orphan tooltips
|
||||||
|
new MutationObserver((mutations: MutationRecord[]) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.removedNodes) {
|
||||||
|
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const target = node as HTMLElement;
|
||||||
|
if (!target.hasAttribute("tooltip")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tooltips.has(target)) {
|
||||||
|
tooltips.get(target).remove();
|
||||||
|
tooltips.delete(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
});
|
@ -45,63 +45,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[tooltip] {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[tooltip]::before {
|
|
||||||
@include shadow;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
content: attr(tooltip);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
border: 0.5px solid hsl(0, 0%, 50%);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
position: absolute;
|
|
||||||
white-space: nowrap;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 500ms ease-out;
|
|
||||||
top: 120%; // Put the tooltip under the element
|
|
||||||
}
|
|
||||||
|
|
||||||
[tooltip]:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 500ms ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
[no-hover][tooltip]::before {
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 500ms ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
[position="top"][tooltip]::before {
|
|
||||||
top: initial;
|
|
||||||
bottom: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[position="bottom"][tooltip]::before {
|
|
||||||
top: 120%;
|
|
||||||
bottom: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
[position="left"][tooltip]::before {
|
|
||||||
top: initial;
|
|
||||||
bottom: 0%;
|
|
||||||
left: initial;
|
|
||||||
right: 65%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[position="right"][tooltip]::before {
|
|
||||||
top: initial;
|
|
||||||
bottom: 0%;
|
|
||||||
left: 150%;
|
|
||||||
right: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ib {
|
.ib {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@ -822,12 +765,6 @@ textarea {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*---------------------------LAUNDERETTE-------------------------------*/
|
|
||||||
|
|
||||||
#token_form label {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*--------------------------------FOOTER-------------------------------*/
|
/*--------------------------------FOOTER-------------------------------*/
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
26
core/static/core/tooltips.scss
Normal file
26
core/static/core/tooltips.scss
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
@import "colors";
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
@include shadow;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 0.5px solid hsl(0, 0%, 50%);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 500ms ease-out;
|
||||||
|
|
||||||
|
white-space: normal;
|
||||||
|
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip[tooltip-status=open] {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 500ms ease-in;
|
||||||
|
}
|
@ -24,13 +24,21 @@ body {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
> .title {
|
.alert {
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div,
|
>div,
|
||||||
> form {
|
>form {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -41,8 +49,8 @@ body {
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|
||||||
> p,
|
>p,
|
||||||
> div {
|
>div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -50,7 +58,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
> label {
|
>label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@media (min-width: 500px) {
|
@media (min-width: 500px) {
|
||||||
@ -59,9 +67,9 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> input,
|
>input,
|
||||||
> p > input,
|
>p>input,
|
||||||
> div > input {
|
>div>input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
@ -71,35 +79,35 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .errorlist {
|
>.errorlist {
|
||||||
color: red;
|
color: red;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 10px 0 0 0;
|
margin: 10px 0 0 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .required > .helptext {
|
>.required>.helptext {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .required:last-of-type {
|
>.required:last-of-type {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
> label {
|
>label {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> img {
|
>img {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
> input {
|
>input {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
<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/markdown.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
||||||
@ -24,6 +25,7 @@
|
|||||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
<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/htmx-index.js') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></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 -->
|
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||||
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||||
|
@ -24,7 +24,6 @@
|
|||||||
<span class="head">{% trans %}Services{% endtrans %}</span>
|
<span class="head">{% trans %}Services{% endtrans %}</span>
|
||||||
<ul class="content">
|
<ul class="content">
|
||||||
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
<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('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -29,14 +29,13 @@
|
|||||||
<form method="post" action="{{ url('core:login') }}">
|
<form method="post" action="{{ url('core:login') }}">
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||||
<br>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
||||||
<input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
|
{{ form.username }}
|
||||||
{{ form.username.errors }}
|
{{ form.username.errors }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -86,19 +86,6 @@
|
|||||||
{% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
|
{% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
|
||||||
{%- endmacro %}
|
{%- 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) %}
|
{% macro show_tokens(user) %}
|
||||||
{% if user.tokens.exists() %}
|
{% if user.tokens.exists() %}
|
||||||
<h5>{% trans %}Tokens{% endtrans %}</h5>
|
<h5>{% trans %}Tokens{% endtrans %}</h5>
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
<form action="{{ url('core:register') }}" method="post">
|
<form action="{{ url('core:register') }}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% render_honeypot_field %}
|
{% render_honeypot_field %}
|
||||||
{{ form }}
|
{{ form.as_p() }}
|
||||||
<input type="submit" value="{% trans %}Register{% endtrans %}" />
|
<input type="submit" value="{% trans %}Register{% endtrans %}" />
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -141,12 +141,6 @@
|
|||||||
{{ user_subscription(profile) }}
|
{{ user_subscription(profile) }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
<div>
|
<div>
|
||||||
{% trans %}Not subscribed{% endtrans %}
|
{% trans %}Not subscribed{% endtrans %}
|
||||||
|
@ -411,10 +411,11 @@ class TestUserIsInGroup(TestCase):
|
|||||||
"""Test that the number of db queries is stable
|
"""Test that the number of db queries is stable
|
||||||
and that less queries are made when making a new call.
|
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)
|
group_in = baker.make(Group)
|
||||||
self.public_user.groups.add(group_in)
|
self.public_user.groups.add(group_in)
|
||||||
|
|
||||||
|
# clear the cached property `User.cached_groups`
|
||||||
|
self.public_user.__dict__.pop("cached_groups", None)
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is in the group
|
# Test when the user is in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
@ -423,6 +424,7 @@ class TestUserIsInGroup(TestCase):
|
|||||||
self.public_user.is_in_group(pk=group_in.id)
|
self.public_user.is_in_group(pk=group_in.id)
|
||||||
|
|
||||||
group_not_in = baker.make(Group)
|
group_not_in = baker.make(Group)
|
||||||
|
self.public_user.__dict__.pop("cached_groups", None)
|
||||||
cache.clear()
|
cache.clear()
|
||||||
# Test when the user is not in the group
|
# Test when the user is not in the group
|
||||||
with self.assertNumQueries(2):
|
with self.assertNumQueries(2):
|
||||||
@ -447,24 +449,6 @@ class TestUserIsInGroup(TestCase):
|
|||||||
)
|
)
|
||||||
assert cached_membership == "not_member"
|
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):
|
def test_not_existing_group(self):
|
||||||
"""Test that searching for a not existing group
|
"""Test that searching for a not existing group
|
||||||
returns False.
|
returns False.
|
||||||
|
@ -318,3 +318,20 @@ def test_displayed_other_user_tabs(user_factory, expected_tabs: list[str]):
|
|||||||
view.object = subscriber_user.make() # user whose page is being seen
|
view.object = subscriber_user.make() # user whose page is being seen
|
||||||
tabs = [tab["slug"] for tab in view.get_list_of_tabs()]
|
tabs = [tab["slug"] for tab in view.get_list_of_tabs()]
|
||||||
assert tabs == expected_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}))
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.urls import path, re_path, register_converter
|
from django.urls import path, re_path, register_converter
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
@ -68,6 +67,7 @@ from core.views import (
|
|||||||
UserGodfathersTreeView,
|
UserGodfathersTreeView,
|
||||||
UserGodfathersView,
|
UserGodfathersView,
|
||||||
UserListView,
|
UserListView,
|
||||||
|
UserMeRedirect,
|
||||||
UserMiniView,
|
UserMiniView,
|
||||||
UserPreferencesView,
|
UserPreferencesView,
|
||||||
UserStatsView,
|
UserStatsView,
|
||||||
@ -141,6 +141,12 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
# User views
|
# User views
|
||||||
path("user/", UserListView.as_view(), name="user_list"),
|
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>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
|
||||||
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
|
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
|
||||||
path(
|
path(
|
||||||
|
@ -48,6 +48,7 @@ from django.views.generic import (
|
|||||||
DeleteView,
|
DeleteView,
|
||||||
DetailView,
|
DetailView,
|
||||||
ListView,
|
ListView,
|
||||||
|
RedirectView,
|
||||||
TemplateView,
|
TemplateView,
|
||||||
)
|
)
|
||||||
from django.views.generic.dates import MonthMixin, YearMixin
|
from django.views.generic.dates import MonthMixin, YearMixin
|
||||||
@ -182,6 +183,13 @@ class UserCreationView(FormView):
|
|||||||
return super().form_valid(form)
|
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):
|
class UserTabsMixin(TabedViewMixin):
|
||||||
def get_tabs_title(self):
|
def get_tabs_title(self):
|
||||||
return self.object.get_display_name()
|
return self.object.get_display_name()
|
||||||
@ -564,10 +572,8 @@ class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, Template
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
self.object = self.request.user
|
self.object = self.request.user
|
||||||
from launderette.models import Launderette
|
|
||||||
|
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["launderettes"] = Launderette.objects.all()
|
|
||||||
kwargs["profile"] = self.request.user
|
kwargs["profile"] = self.request.user
|
||||||
kwargs["object"] = self.request.user
|
kwargs["object"] = self.request.user
|
||||||
return kwargs
|
return kwargs
|
||||||
|
112
counter/forms.py
112
counter/forms.py
@ -1,4 +1,7 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
|
|
||||||
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
|
|||||||
widget=AutoCompleteSelectUser,
|
widget=AutoCompleteSelectUser,
|
||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductForm(forms.Form):
|
||||||
|
quantity = forms.IntegerField(min_value=1, required=True)
|
||||||
|
id = forms.IntegerField(min_value=0, required=True)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
customer: Customer,
|
||||||
|
counter: Counter,
|
||||||
|
allowed_products: dict[int, Product],
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.customer = customer # Used by formset
|
||||||
|
self.counter = counter # Used by formset
|
||||||
|
self.allowed_products = allowed_products
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean_id(self):
|
||||||
|
data = self.cleaned_data["id"]
|
||||||
|
|
||||||
|
# We store self.product so we can use it later on the formset validation
|
||||||
|
# And also in the global clean
|
||||||
|
self.product = self.allowed_products.get(data, None)
|
||||||
|
if self.product is None:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_("The selected product isn't available for this user")
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
if len(self.errors) > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Compute prices
|
||||||
|
cleaned_data["bonus_quantity"] = 0
|
||||||
|
if self.product.tray:
|
||||||
|
cleaned_data["bonus_quantity"] = math.floor(
|
||||||
|
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||||
|
)
|
||||||
|
cleaned_data["total_price"] = self.product.price * (
|
||||||
|
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBasketForm(forms.BaseFormSet):
|
||||||
|
def clean(self):
|
||||||
|
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||||
|
|
||||||
|
if len(self.forms) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._check_forms_have_errors()
|
||||||
|
self._check_product_are_unique()
|
||||||
|
self._check_recorded_products(self[0].customer)
|
||||||
|
self._check_enough_money(self[0].counter, self[0].customer)
|
||||||
|
|
||||||
|
def _check_forms_have_errors(self):
|
||||||
|
if any(len(form.errors) > 0 for form in self):
|
||||||
|
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||||
|
|
||||||
|
def _check_product_are_unique(self):
|
||||||
|
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||||
|
if len(product_ids) != len(self.forms):
|
||||||
|
raise forms.ValidationError(_("Duplicated product entries."))
|
||||||
|
|
||||||
|
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||||
|
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||||
|
if self.total_price > customer.amount:
|
||||||
|
raise forms.ValidationError(_("Not enough money"))
|
||||||
|
|
||||||
|
def _check_recorded_products(self, customer: Customer):
|
||||||
|
"""Check for, among other things, ecocups and pitchers"""
|
||||||
|
items = {
|
||||||
|
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||||
|
for form in self.forms
|
||||||
|
}
|
||||||
|
ids = list(items.keys())
|
||||||
|
returnables = list(
|
||||||
|
ReturnableProduct.objects.filter(
|
||||||
|
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||||
|
).annotate_balance_for(customer)
|
||||||
|
)
|
||||||
|
limit_reached = []
|
||||||
|
for returnable in returnables:
|
||||||
|
returnable.balance += items.get(returnable.product_id, 0)
|
||||||
|
for returnable in returnables:
|
||||||
|
dcons = items.get(returnable.returned_product_id, 0)
|
||||||
|
returnable.balance -= dcons
|
||||||
|
if dcons and returnable.balance < -returnable.max_return:
|
||||||
|
limit_reached.append(returnable.returned_product)
|
||||||
|
if limit_reached:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_(
|
||||||
|
"This user have reached his recording limit "
|
||||||
|
"for the following products : %s"
|
||||||
|
)
|
||||||
|
% ", ".join([str(p) for p in limit_reached])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BasketForm = forms.formset_factory(
|
||||||
|
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||||
|
)
|
||||||
|
@ -47,6 +47,10 @@ from counter.fields import CurrencyField
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
def get_eboutic() -> Counter:
|
||||||
|
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||||
|
|
||||||
|
|
||||||
class CustomerQuerySet(models.QuerySet):
|
class CustomerQuerySet(models.QuerySet):
|
||||||
def update_amount(self) -> int:
|
def update_amount(self) -> int:
|
||||||
"""Update the amount of all customers selected by this queryset.
|
"""Update the amount of all customers selected by this queryset.
|
||||||
|
@ -46,6 +46,15 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_age(user: User, age: int):
|
||||||
|
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
|
||||||
|
def force_refill_user(user: User, amount: Decimal | int):
|
||||||
|
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
|
||||||
|
|
||||||
|
|
||||||
class TestFullClickBase(TestCase):
|
class TestFullClickBase(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
cls.banned_counter_customer = subscriber_user.make()
|
cls.banned_counter_customer = subscriber_user.make()
|
||||||
cls.banned_alcohol_customer = subscriber_user.make()
|
cls.banned_alcohol_customer = subscriber_user.make()
|
||||||
|
|
||||||
cls.set_age(cls.customer, 20)
|
set_age(cls.customer, 20)
|
||||||
cls.set_age(cls.barmen, 20)
|
set_age(cls.barmen, 20)
|
||||||
cls.set_age(cls.club_admin, 20)
|
set_age(cls.club_admin, 20)
|
||||||
cls.set_age(cls.banned_alcohol_customer, 20)
|
set_age(cls.banned_alcohol_customer, 20)
|
||||||
cls.set_age(cls.underage_customer, 17)
|
set_age(cls.underage_customer, 17)
|
||||||
|
|
||||||
cls.banned_alcohol_customer.ban_groups.add(
|
cls.banned_alcohol_customer.ban_groups.add(
|
||||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||||
@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
{"username": used_barman.username, "password": "plop"},
|
{"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(
|
def submit_basket(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
data,
|
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):
|
def test_click_eboutic_failure(self):
|
||||||
eboutic = baker.make(Counter, type="EBOUTIC")
|
eboutic = baker.make(Counter, type="EBOUTIC")
|
||||||
self.client.force_login(self.club_admin)
|
self.client.force_login(self.club_admin)
|
||||||
@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
def test_click_office_success(self):
|
def test_click_office_success(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.client.force_login(self.club_admin)
|
self.client.force_login(self.club_admin)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
||||||
@ -327,7 +328,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == Decimal("2.5")
|
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||||
|
|
||||||
# Test no special price on office counter
|
# Test no special price on office counter
|
||||||
self.refill_user(self.club_admin, 10)
|
force_refill_user(self.club_admin, 10)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
||||||
)
|
)
|
||||||
@ -336,7 +337,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||||
|
|
||||||
def test_click_bar_success(self):
|
def test_click_bar_success(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||||
@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
# Test barmen special price
|
# Test barmen special price
|
||||||
|
|
||||||
self.refill_user(self.barmen, 10)
|
force_refill_user(self.barmen, 10)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
||||||
@ -356,7 +357,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.barmen) == Decimal("9")
|
assert self.updated_amount(self.barmen) == Decimal("9")
|
||||||
|
|
||||||
def test_click_tray_price(self):
|
def test_click_tray_price(self):
|
||||||
self.refill_user(self.customer, 20)
|
force_refill_user(self.customer, 20)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
|
|
||||||
# Not applying tray price
|
# Not applying tray price
|
||||||
@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
||||||
self.refill_user(user, 10)
|
force_refill_user(user, 10)
|
||||||
|
|
||||||
# Buy product without age limit
|
# Buy product without age limit
|
||||||
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||||
@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
self.banned_counter_customer,
|
self.banned_counter_customer,
|
||||||
self.customer_old_can_not_buy,
|
self.customer_old_can_not_buy,
|
||||||
]:
|
]:
|
||||||
self.refill_user(user, 10)
|
force_refill_user(user, 10)
|
||||||
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert resp.url == resolve_url(self.counter)
|
assert resp.url == resolve_url(self.counter)
|
||||||
@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
def test_click_allowed_old_subscriber(self):
|
def test_click_allowed_old_subscriber(self):
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
self.refill_user(self.customer_old_can_buy, 10)
|
force_refill_user(self.customer_old_can_buy, 10)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
||||||
)
|
)
|
||||||
@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
|
|
||||||
def test_click_wrong_counter(self):
|
def test_click_wrong_counter(self):
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
||||||
)
|
)
|
||||||
@ -444,7 +445,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_not_connected(self):
|
def test_click_not_connected(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||||
assertRedirects(res, self.counter.get_absolute_url())
|
assertRedirects(res, self.counter.get_absolute_url())
|
||||||
|
|
||||||
@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_product_not_in_counter(self):
|
def test_click_product_not_in_counter(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
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):
|
def test_click_product_invalid(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
|
|
||||||
for item in [
|
for item in [
|
||||||
@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
BasketItem(self.beer.id, -1),
|
BasketItem(self.beer.id, -1),
|
||||||
BasketItem(None, 1),
|
BasketItem(None, 1),
|
||||||
BasketItem(self.beer.id, None),
|
BasketItem(self.beer.id, None),
|
||||||
BasketItem(None, None),
|
|
||||||
]:
|
]:
|
||||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||||
|
|
||||||
assert self.updated_amount(self.customer) == Decimal("10")
|
assert self.updated_amount(self.customer) == Decimal("10")
|
||||||
|
|
||||||
def test_click_not_enough_money(self):
|
def test_click_not_enough_money(self):
|
||||||
self.refill_user(self.customer, 10)
|
force_refill_user(self.customer, 10)
|
||||||
self.login_in_bar()
|
self.login_in_bar()
|
||||||
res = self.submit_basket(
|
res = self.submit_basket(
|
||||||
self.customer,
|
self.customer,
|
||||||
@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
|
|||||||
assert self.updated_amount(self.customer) == 0
|
assert self.updated_amount(self.customer) == 0
|
||||||
|
|
||||||
def test_recordings(self):
|
def test_recordings(self):
|
||||||
self.refill_user(self.customer, self.cons.selling_price * 3)
|
force_refill_user(self.customer, self.cons.selling_price * 3)
|
||||||
self.login_in_bar(self.barmen)
|
self.login_in_bar(self.barmen)
|
||||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||||
assert res.status_code == 302
|
assert res.status_code == 302
|
||||||
|
@ -12,18 +12,17 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timezone as tz
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponseRedirect
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
|
from django.http import Http404
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.views.generic.edit import UpdateView
|
from django.views.generic.edit import BaseFormView, UpdateView
|
||||||
|
|
||||||
from core.auth.mixins import CanViewMixin
|
from core.models import User
|
||||||
from counter.forms import CashSummaryFormBase
|
from counter.forms import CashSummaryFormBase
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
CashRegisterSummary,
|
CashRegisterSummary,
|
||||||
@ -31,9 +30,7 @@ from counter.models import (
|
|||||||
Counter,
|
Counter,
|
||||||
Refilling,
|
Refilling,
|
||||||
)
|
)
|
||||||
from counter.utils import is_logged_in_counter
|
|
||||||
from counter.views.mixins import (
|
from counter.views.mixins import (
|
||||||
CounterAdminMixin,
|
|
||||||
CounterAdminTabsMixin,
|
CounterAdminTabsMixin,
|
||||||
CounterTabsMixin,
|
CounterTabsMixin,
|
||||||
)
|
)
|
||||||
@ -157,11 +154,9 @@ class CashRegisterSummaryForm(forms.Form):
|
|||||||
else:
|
else:
|
||||||
self.instance = None
|
self.instance = None
|
||||||
|
|
||||||
def save(self, counter=None):
|
def save(self, counter: Counter | None = None, user: User | None = None):
|
||||||
cd = self.cleaned_data
|
cd = self.cleaned_data
|
||||||
summary = self.instance or CashRegisterSummary(
|
summary = self.instance or CashRegisterSummary(counter=counter, user=user)
|
||||||
counter=counter, user=counter.get_random_barman()
|
|
||||||
)
|
|
||||||
summary.comment = cd["comment"]
|
summary.comment = cd["comment"]
|
||||||
summary.emptied = cd["emptied"]
|
summary.emptied = cd["emptied"]
|
||||||
summary.save()
|
summary.save()
|
||||||
@ -247,48 +242,33 @@ class CashRegisterSummaryForm(forms.Form):
|
|||||||
summary.delete()
|
summary.delete()
|
||||||
|
|
||||||
|
|
||||||
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
|
class CounterCashSummaryView(
|
||||||
|
CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView
|
||||||
|
):
|
||||||
"""Provide the cash summary form."""
|
"""Provide the cash summary form."""
|
||||||
|
|
||||||
model = Counter
|
model = Counter
|
||||||
pk_url_kwarg = "counter_id"
|
pk_url_kwarg = "counter_id"
|
||||||
template_name = "counter/cash_register_summary.jinja"
|
template_name = "counter/cash_register_summary.jinja"
|
||||||
current_tab = "cash_summary"
|
current_tab = "cash_summary"
|
||||||
|
permission_required = "counter.add_cashregistersummary"
|
||||||
|
form_class = CashRegisterSummaryForm
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
"""We have here again a very particular right handling."""
|
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
if is_logged_in_counter(request) and self.object.barmen_list:
|
if self.object.type != "BAR":
|
||||||
|
raise Http404
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
return HttpResponseRedirect(
|
|
||||||
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
|
||||||
+ "?bad_location"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def form_valid(self, form):
|
||||||
self.object = self.get_object()
|
form.save(counter=self.object, user=self.request.user)
|
||||||
self.form = CashRegisterSummaryForm()
|
return super().form_valid(form)
|
||||||
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):
|
def get_success_url(self):
|
||||||
return reverse_lazy("counter:details", kwargs={"counter_id": self.object.id})
|
return reverse("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, CounterAdminMixin, UpdateView):
|
class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||||
"""Edit cash summaries."""
|
"""Edit cash summaries."""
|
||||||
|
|
||||||
model = CashRegisterSummary
|
model = CashRegisterSummary
|
||||||
@ -297,12 +277,11 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
|||||||
pk_url_kwarg = "cashsummary_id"
|
pk_url_kwarg = "cashsummary_id"
|
||||||
form_class = CashRegisterSummaryForm
|
form_class = CashRegisterSummaryForm
|
||||||
current_tab = "cash_summary"
|
current_tab = "cash_summary"
|
||||||
|
permission_required = "counter.change_cashregistersummary"
|
||||||
def get_success_url(self):
|
success_url = reverse_lazy("counter:cash_summary_list")
|
||||||
return reverse("counter:cash_summary_list")
|
|
||||||
|
|
||||||
|
|
||||||
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||||
"""Display a list of cash summaries."""
|
"""Display a list of cash summaries."""
|
||||||
|
|
||||||
model = CashRegisterSummary
|
model = CashRegisterSummary
|
||||||
@ -311,6 +290,7 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
|||||||
current_tab = "cash_summary"
|
current_tab = "cash_summary"
|
||||||
queryset = CashRegisterSummary.objects.all().order_by("-date")
|
queryset = CashRegisterSummary.objects.all().order_by("-date")
|
||||||
paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
|
paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
|
||||||
|
permission_required = "counter.view_cashregistersummary"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
"""Add sums to the context."""
|
"""Add sums to the context."""
|
||||||
@ -321,12 +301,12 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
|||||||
kwargs["refilling_sums"] = {}
|
kwargs["refilling_sums"] = {}
|
||||||
for c in Counter.objects.filter(type="BAR").all():
|
for c in Counter.objects.filter(type="BAR").all():
|
||||||
refillings = Refilling.objects.filter(counter=c)
|
refillings = Refilling.objects.filter(counter=c)
|
||||||
cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)
|
cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
|
||||||
if form.is_valid() and form.cleaned_data["begin_date"]:
|
if form.is_valid() and form.cleaned_data["begin_date"]:
|
||||||
refillings = refillings.filter(
|
refillings = refillings.filter(
|
||||||
date__gte=form.cleaned_data["begin_date"]
|
date__gte=form.cleaned_data["begin_date"]
|
||||||
)
|
)
|
||||||
cashredistersummaries = cashredistersummaries.filter(
|
cash_register_summaries = cash_register_summaries.filter(
|
||||||
date__gte=form.cleaned_data["begin_date"]
|
date__gte=form.cleaned_data["begin_date"]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -337,23 +317,16 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
|||||||
)
|
)
|
||||||
if last_summary:
|
if last_summary:
|
||||||
refillings = refillings.filter(date__gt=last_summary.date)
|
refillings = refillings.filter(date__gt=last_summary.date)
|
||||||
cashredistersummaries = cashredistersummaries.filter(
|
cash_register_summaries = cash_register_summaries.filter(
|
||||||
date__gt=last_summary.date
|
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"]:
|
if form.is_valid() and form.cleaned_data["end_date"]:
|
||||||
refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
|
refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
|
||||||
cashredistersummaries = cashredistersummaries.filter(
|
cash_register_summaries = cash_register_summaries.filter(
|
||||||
date__lte=form.cleaned_data["end_date"]
|
date__lte=form.cleaned_data["end_date"]
|
||||||
)
|
)
|
||||||
kwargs["summaries_sums"][c.name] = sum(
|
kwargs["summaries_sums"][c.name] = sum(
|
||||||
[s.get_total() for s in cashredistersummaries.all()]
|
[s.get_total() for s in cash_register_summaries.all()]
|
||||||
)
|
)
|
||||||
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
|
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
|
||||||
return kwargs
|
return kwargs
|
||||||
|
@ -12,23 +12,14 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import math
|
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import (
|
|
||||||
BaseFormSet,
|
|
||||||
Form,
|
|
||||||
IntegerField,
|
|
||||||
ValidationError,
|
|
||||||
formset_factory,
|
|
||||||
)
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from ninja.main import HttpRequest
|
from ninja.main import HttpRequest
|
||||||
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
|
|||||||
from core.auth.mixins import CanViewMixin
|
from core.auth.mixins import CanViewMixin
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import RefillForm
|
from counter.forms import BasketForm, RefillForm
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Product,
|
|
||||||
ReturnableProduct,
|
ReturnableProduct,
|
||||||
Selling,
|
Selling,
|
||||||
)
|
)
|
||||||
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
|||||||
return counter.get_random_barman()
|
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(
|
class CounterClick(
|
||||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||||
):
|
):
|
||||||
|
@ -15,11 +15,12 @@
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import View
|
||||||
|
|
||||||
from core.views.mixins import TabedViewMixin
|
from core.views.mixins import TabedViewMixin
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
class CounterAdminMixin(View):
|
class CounterAdminMixin(View):
|
||||||
@ -49,34 +50,40 @@ class CounterTabsMixin(TabedViewMixin):
|
|||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
def get_list_of_tabs(self):
|
def get_list_of_tabs(self):
|
||||||
|
if self.object.type != "BAR":
|
||||||
|
return []
|
||||||
tab_list = [
|
tab_list = [
|
||||||
{
|
{
|
||||||
"url": reverse_lazy(
|
"url": reverse(
|
||||||
"counter:details", kwargs={"counter_id": self.object.id}
|
"counter:details", kwargs={"counter_id": self.object.id}
|
||||||
),
|
),
|
||||||
"slug": "counter",
|
"slug": "counter",
|
||||||
"name": _("Counter"),
|
"name": _("Counter"),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
if self.object.type == "BAR":
|
if self.request.user.has_perm("counter.add_cashregistersummary"):
|
||||||
tab_list.append(
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse_lazy(
|
"url": reverse(
|
||||||
"counter:cash_summary", kwargs={"counter_id": self.object.id}
|
"counter:cash_summary", kwargs={"counter_id": self.object.id}
|
||||||
),
|
),
|
||||||
"slug": "cash_summary",
|
"slug": "cash_summary",
|
||||||
"name": _("Cash summary"),
|
"name": _("Cash summary"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if is_logged_in_counter(self.request):
|
||||||
tab_list.append(
|
tab_list.append(
|
||||||
{
|
{
|
||||||
"url": reverse_lazy(
|
"url": reverse(
|
||||||
"counter:last_ops", kwargs={"counter_id": self.object.id}
|
"counter:last_ops", kwargs={"counter_id": self.object.id}
|
||||||
),
|
),
|
||||||
"slug": "last_ops",
|
"slug": "last_ops",
|
||||||
"name": _("Last operations"),
|
"name": _("Last operations"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if len(tab_list) <= 1:
|
||||||
|
# It would be strange to show only one tab
|
||||||
|
return []
|
||||||
return tab_list
|
return tab_list
|
||||||
|
|
||||||
|
|
||||||
|
@ -131,6 +131,31 @@ de données fonctionnent avec l'un comme avec l'autre.
|
|||||||
Heureusement, et grâce à l'ORM de Django, cette
|
Heureusement, et grâce à l'ORM de Django, cette
|
||||||
double compatibilité est presque toujours possible.
|
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
|
## Frontend
|
||||||
|
|
||||||
### Jinja2
|
### Jinja2
|
||||||
|
@ -1 +0,0 @@
|
|||||||
::: launderette.models
|
|
@ -1 +0,0 @@
|
|||||||
::: launderette.views
|
|
@ -120,7 +120,7 @@ les conflits avec les instances de redis déjà en fonctionnement.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
CACHE_URL=redis://127.0.0.1:6379/0
|
||||||
```
|
```
|
||||||
|
|
||||||
Si on souhaite configurer redis pour communiquer via un socket :
|
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 default_transaction_isolation TO 'read committed';
|
||||||
ALTER ROLE sith SET timezone TO 'UTC';
|
ALTER ROLE sith SET timezone TO 'UTC';
|
||||||
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
|
GRANT ALL PRIVILEGES ON DATABASE sith TO sith;
|
||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -279,6 +279,26 @@ Toutes les requêtes vers des fichiers statiques et les medias publiques
|
|||||||
seront servies directement par nginx.
|
seront servies directement par nginx.
|
||||||
Toutes les autres requêtes seront transmises au serveur django.
|
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
|
## Mettre à jour la base de données antispam
|
||||||
|
|
||||||
|
@ -100,7 +100,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
|||||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||||
installé par uv.
|
installé par uv.
|
||||||
|
|
||||||
|
|
||||||
## Finaliser l'installation
|
## Finaliser l'installation
|
||||||
|
|
||||||
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
|
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
|
||||||
@ -113,6 +112,7 @@ cd sith
|
|||||||
# Création de l'environnement et installation des dépendances
|
# Création de l'environnement et installation des dépendances
|
||||||
uv sync
|
uv sync
|
||||||
npm install # Dépendances frontend
|
npm install # Dépendances frontend
|
||||||
|
cp .env.example .env # Configuration par défaut
|
||||||
uv run ./manage.py install_xapian
|
uv run ./manage.py install_xapian
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -122,17 +122,12 @@ uv run ./manage.py install_xapian
|
|||||||
de texte à l'écran.
|
de texte à l'écran.
|
||||||
C'est normal, il ne faut pas avoir peur.
|
C'est normal, il ne faut pas avoir peur.
|
||||||
|
|
||||||
Une fois les dépendances installées, il faut encore
|
|
||||||
mettre en place quelques éléments de configuration,
|
La modification de la configuration passe par la modification de variables
|
||||||
qui peuvent varier d'un environnement à l'autre.
|
d'environnement.
|
||||||
Ces variables sont stockées dans un fichier `.env`.
|
Ces variables sont stockées dans un fichier `.env`.
|
||||||
Pour le créer, vous pouvez copier le fichier `.env.example` :
|
|
||||||
|
|
||||||
```bash
|
Les variables par défaut contenues dans le fichier `.env.example`
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Les variables par défaut contenues dans le fichier `.env`
|
|
||||||
devraient convenir pour le développement, sans modification.
|
devraient convenir pour le développement, sans modification.
|
||||||
|
|
||||||
Maintenant que les dépendances sont installées
|
Maintenant que les dépendances sont installées
|
||||||
|
@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
|
|||||||
|
|
||||||
Cette application contient les vues suivantes :
|
Cette application contient les vues suivantes :
|
||||||
|
|
||||||
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
|
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
|
||||||
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
|
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
|
||||||
fonction de ce qu'il a le droit d'acheter.
|
fonction de ce qu'il a le droit d'acheter.
|
||||||
Si cette vue est appelée lors d'une redirection parce qu'une erreur
|
Elle est en charge de récupérer le formulaire de création d'un panier et
|
||||||
est survenue au cours de la navigation sur la boutique, il est possible
|
redirige alors vers la vue de checkout.
|
||||||
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
|
- ``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
|
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.
|
lorsque l'utilisateur paye son panier avec son argent du compte AE.
|
||||||
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
|
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
|
||||||
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
|
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
|
||||||
du panier à valider. Ce panier doit strictement être de la forme :
|
de paiement par carte bancaire.
|
||||||
```
|
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||||
[
|
|
||||||
{"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`.
|
AE. Redirige vers `payment_result`.
|
||||||
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
||||||
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
|
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
|
# Les templates
|
||||||
|
|
||||||
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
||||||
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
||||||
Retourné par la vue ``payment_result``.
|
Retourné par la vue ``payment_result``.
|
||||||
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
|
- ``eboutic_checkout.jinja`` : template contenant un résumé du panier et deux
|
||||||
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
|
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
|
||||||
Retourné par la vue ``EbouticCommand``
|
Retourné par la vue ``EbouticCheckout``
|
||||||
|
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
|
||||||
|
Elle permet également de mettre à jour ses coordonnées de paiement
|
||||||
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
|
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
|
||||||
une interface pour que l'utilisateur puisse consulter les produits et remplir
|
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.
|
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
|
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
|
||||||
(AlpineJS, plus précisément) édite en même temps un cookie.
|
(AlpineJS, plus précisément) édite en même temps le localStorage du navigateur.
|
||||||
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
|
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
|
||||||
vérifier que la commande est valide et payer.
|
|
||||||
|
|
||||||
# Les modèles
|
# Les modèles
|
||||||
|
|
||||||
|
@ -1,22 +1,20 @@
|
|||||||
from ninja_extra import ControllerBase, api_controller, route
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
from ninja_extra.exceptions import NotFound
|
from ninja_extra.exceptions import NotFound
|
||||||
from ninja_extra.permissions import IsAuthenticated
|
|
||||||
|
|
||||||
|
from core.auth.api_permissions import CanView
|
||||||
from counter.models import BillingInfo
|
from counter.models import BillingInfo
|
||||||
from eboutic.models import Basket
|
from eboutic.models import Basket
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/etransaction", permissions=[IsAuthenticated])
|
@api_controller("/etransaction", permissions=[CanView])
|
||||||
class EtransactionInfoController(ControllerBase):
|
class EtransactionInfoController(ControllerBase):
|
||||||
@route.get("/data", url_name="etransaction_data")
|
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||||
def fetch_etransaction_data(self):
|
def fetch_etransaction_data(self, basket_id: int):
|
||||||
"""Generate the data to pay an eboutic command with paybox.
|
"""Generate the data to pay an eboutic command with paybox.
|
||||||
|
|
||||||
The data is generated with the basket that is used by the current session.
|
The data is generated with the basket that is used by the current session.
|
||||||
"""
|
"""
|
||||||
basket = Basket.from_session(self.context.request.session)
|
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||||
if basket is None:
|
|
||||||
raise NotFound
|
|
||||||
try:
|
try:
|
||||||
return dict(basket.get_e_transaction_data())
|
return dict(basket.get_e_transaction_data())
|
||||||
except BillingInfo.DoesNotExist as e:
|
except BillingInfo.DoesNotExist as e:
|
||||||
|
128
eboutic/forms.py
128
eboutic/forms.py
@ -1,128 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2022
|
|
||||||
# - Maréchal <thgirod@hotmail.com
|
|
||||||
#
|
|
||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
|
||||||
# http://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify it under
|
|
||||||
# the terms of the GNU General Public License a published by the Free Software
|
|
||||||
# Foundation; either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
from functools import cached_property
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from eboutic.models import get_eboutic_products
|
|
||||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
|
||||||
|
|
||||||
|
|
||||||
class BasketForm:
|
|
||||||
"""Class intended to perform checks on the request sended to the server when
|
|
||||||
the user submits his basket from /eboutic/.
|
|
||||||
|
|
||||||
Because it must check an unknown number of fields, coming from a cookie
|
|
||||||
and needing some databases checks to be performed, inheriting from forms.Form
|
|
||||||
or using formset would have been likely to end in a big ball of wibbly-wobbly hacky stuff.
|
|
||||||
Thus this class is a pure standalone and performs its operations by its own means.
|
|
||||||
However, it still tries to share some similarities with a standard django Form.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
::
|
|
||||||
|
|
||||||
def my_view(request):
|
|
||||||
form = BasketForm(request)
|
|
||||||
form.clean()
|
|
||||||
if form.is_valid():
|
|
||||||
# perform operations
|
|
||||||
else:
|
|
||||||
errors = form.get_error_messages()
|
|
||||||
|
|
||||||
# return the cookie that was in the request, but with all
|
|
||||||
# incorrects elements removed
|
|
||||||
cookie = form.get_cleaned_cookie()
|
|
||||||
|
|
||||||
You can also use a little shortcut by directly calling `form.is_valid()`
|
|
||||||
without calling `form.clean()`. In this case, the latter method shall be
|
|
||||||
implicitly called.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request: HttpRequest):
|
|
||||||
self.user = request.user
|
|
||||||
self.cookies = request.COOKIES
|
|
||||||
self.error_messages = set()
|
|
||||||
self.correct_items = []
|
|
||||||
|
|
||||||
def clean(self) -> None:
|
|
||||||
"""Perform all the checks, but return nothing.
|
|
||||||
To know if the form is valid, the `is_valid()` method must be used.
|
|
||||||
|
|
||||||
The form shall be considered as valid if it meets all the following conditions :
|
|
||||||
- it contains a "basket_items" key in the cookies of the request given in the constructor
|
|
||||||
- this cookie is a list of objects formatted this way : `[{'id': <int>, 'quantity': <int>,
|
|
||||||
'name': <str>, 'unit_price': <float>}, ...]`. The order of the fields in each object does not matter
|
|
||||||
- all the ids are positive integers
|
|
||||||
- all the ids refer to products available in the EBOUTIC
|
|
||||||
- all the ids refer to products the user is allowed to buy
|
|
||||||
- all the quantities are positive integers
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
basket = PurchaseItemList.validate_json(
|
|
||||||
unquote(self.cookies.get("basket_items", "[]"))
|
|
||||||
)
|
|
||||||
except ValidationError:
|
|
||||||
self.error_messages.add(_("The request was badly formatted."))
|
|
||||||
return
|
|
||||||
if len(basket) == 0:
|
|
||||||
self.error_messages.add(_("Your basket is empty."))
|
|
||||||
return
|
|
||||||
existing_ids = {product.id for product in get_eboutic_products(self.user)}
|
|
||||||
for item in basket:
|
|
||||||
# check a product with this id does exist
|
|
||||||
if item.product_id in existing_ids:
|
|
||||||
self.correct_items.append(item)
|
|
||||||
else:
|
|
||||||
self.error_messages.add(
|
|
||||||
_(
|
|
||||||
"%(name)s : this product does not exist or may no longer be available."
|
|
||||||
)
|
|
||||||
% {"name": item.name}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
# this function does not return anything.
|
|
||||||
# instead, it fills a set containing the collected error messages
|
|
||||||
# an empty set means that no error was seen thus everything is ok
|
|
||||||
# and the form is valid.
|
|
||||||
# a non-empty set means there was at least one error thus
|
|
||||||
# the form is invalid
|
|
||||||
|
|
||||||
def is_valid(self) -> bool:
|
|
||||||
"""Return True if the form is correct else False.
|
|
||||||
|
|
||||||
If the `clean()` method has not been called beforehand, call it.
|
|
||||||
"""
|
|
||||||
if not self.error_messages and not self.correct_items:
|
|
||||||
self.clean()
|
|
||||||
return not self.error_messages
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def errors(self) -> list[str]:
|
|
||||||
return list(self.error_messages)
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def cleaned_data(self) -> list[PurchaseItemSchema]:
|
|
||||||
return self.correct_items
|
|
@ -28,18 +28,27 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.fields import CurrencyField
|
from counter.fields import CurrencyField
|
||||||
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
|
from counter.models import (
|
||||||
|
BillingInfo,
|
||||||
|
Counter,
|
||||||
|
Customer,
|
||||||
|
Product,
|
||||||
|
Refilling,
|
||||||
|
Selling,
|
||||||
|
get_eboutic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_eboutic_products(user: User) -> list[Product]:
|
def get_eboutic_products(user: User) -> list[Product]:
|
||||||
products = (
|
products = (
|
||||||
Counter.objects.get(type="EBOUTIC")
|
get_eboutic()
|
||||||
.products.filter(product_type__isnull=False)
|
.products.filter(product_type__isnull=False)
|
||||||
.filter(archived=False)
|
.filter(archived=False)
|
||||||
.filter(limit_age__lte=user.age)
|
.filter(limit_age__lte=user.age)
|
||||||
.annotate(order=F("product_type__order"))
|
.annotate(order=F("product_type__order"))
|
||||||
.annotate(category=F("product_type__name"))
|
.annotate(category=F("product_type__name"))
|
||||||
.annotate(category_comment=F("product_type__comment"))
|
.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`
|
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||||
)
|
)
|
||||||
return [p for p in products if p.can_be_sold_to(user)]
|
return [p for p in products if p.can_be_sold_to(user)]
|
||||||
@ -84,6 +93,9 @@ class Basket(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||||
|
|
||||||
|
def can_be_viewed_by(self, user):
|
||||||
|
return self.user == user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def contains_refilling_item(self) -> bool:
|
def contains_refilling_item(self) -> bool:
|
||||||
return self.items.filter(
|
return self.items.filter(
|
||||||
@ -98,13 +110,6 @@ class Basket(models.Model):
|
|||||||
)["total"]
|
)["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):
|
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||||
"""Generate a list of sold items corresponding to the items
|
"""Generate a list of sold items corresponding to the items
|
||||||
of this basket WITHOUT saving them NOR deleting the basket.
|
of this basket WITHOUT saving them NOR deleting the basket.
|
||||||
@ -139,7 +144,7 @@ class Basket(models.Model):
|
|||||||
club=product.club,
|
club=product.club,
|
||||||
product=product,
|
product=product,
|
||||||
seller=seller,
|
seller=seller,
|
||||||
customer=self.user.customer,
|
customer=Customer.get_or_create(self.user)[0],
|
||||||
unit_price=item.product_unit_price,
|
unit_price=item.product_unit_price,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
payment_method=payment_method,
|
payment_method=payment_method,
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("etransaction", (initialData) => ({
|
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||||
data: initialData,
|
data: initialData,
|
||||||
isCbAvailable: Object.keys(initialData).length > 0,
|
isCbAvailable: Object.keys(initialData).length > 0,
|
||||||
|
|
||||||
async fill() {
|
async fill() {
|
||||||
this.isCbAvailable = false;
|
this.isCbAvailable = false;
|
||||||
const res = await etransactioninfoFetchEtransactionData();
|
const res = await etransactioninfoFetchEtransactionData({
|
||||||
|
path: {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
|
basket_id: basketId,
|
||||||
|
},
|
||||||
|
});
|
||||||
if (res.response.ok) {
|
if (res.response.ok) {
|
||||||
this.data = res.data;
|
this.data = res.data;
|
||||||
this.isCbAvailable = true;
|
this.isCbAvailable = true;
|
@ -8,55 +8,55 @@ interface BasketItem {
|
|||||||
unit_price: number;
|
unit_price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||||
|
basket: [] as BasketItem[],
|
||||||
|
|
||||||
/**
|
init() {
|
||||||
* Search for a cookie by name
|
this.basket = this.loadBasket();
|
||||||
* @param name Name of the cookie to get
|
this.$watch("basket", () => {
|
||||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
this.saveBasket();
|
||||||
*/
|
});
|
||||||
function getCookie(name: string): string | null | undefined {
|
|
||||||
if (!document.cookie || document.cookie.length === 0) {
|
// Invalidate basket if a purchase was made
|
||||||
return null;
|
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||||
|
if (
|
||||||
|
new Date(lastPurchaseTime) >=
|
||||||
|
new Date(Number.parseInt(localStorage.basketTimestamp))
|
||||||
|
) {
|
||||||
|
this.basket = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = document.cookie
|
// It's quite tricky to manually apply attributes to the management part
|
||||||
.split(";")
|
// of a formset so we dynamically apply it here
|
||||||
.map((c) => c.trim())
|
this.$refs.basketManagementForm
|
||||||
.find((c) => c.startsWith(`${name}=`));
|
.querySelector("#id_form-TOTAL_FORMS")
|
||||||
|
.setAttribute(":value", "basket.length");
|
||||||
|
},
|
||||||
|
|
||||||
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
|
loadBasket(): BasketItem[] {
|
||||||
}
|
if (localStorage.basket === undefined) {
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
// Django cookie backend converts `,` to `\054`
|
try {
|
||||||
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
|
return JSON.parse(localStorage.basket);
|
||||||
if (typeof parsed === "string") {
|
} catch (_err) {
|
||||||
// In some conditions, a second parsing is needed
|
return [];
|
||||||
parsed = JSON.parse(parsed);
|
|
||||||
}
|
}
|
||||||
const res = Array.isArray(parsed) ? parsed : [];
|
},
|
||||||
return res.filter((i) => !!document.getElementById(i.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
saveBasket() {
|
||||||
Alpine.data("basket", () => ({
|
localStorage.basket = JSON.stringify(this.basket);
|
||||||
items: getStartingItems() as BasketItem[],
|
localStorage.basketTimestamp = Date.now();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total price of the basket
|
* Get the total price of the basket
|
||||||
* @returns {number} The total price of the basket
|
* @returns {number} The total price of the basket
|
||||||
*/
|
*/
|
||||||
getTotal() {
|
getTotal() {
|
||||||
return this.items.reduce(
|
return this.basket.reduce(
|
||||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
*/
|
*/
|
||||||
add(item: BasketItem) {
|
add(item: BasketItem) {
|
||||||
item.quantity++;
|
item.quantity++;
|
||||||
this.setCookies();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param itemId the id of the item to remove
|
* @param itemId the id of the item to remove
|
||||||
*/
|
*/
|
||||||
remove(itemId: number) {
|
remove(itemId: number) {
|
||||||
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
||||||
|
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.items[index].quantity -= 1;
|
this.basket[index].quantity -= 1;
|
||||||
|
|
||||||
if (this.items[index].quantity === 0) {
|
if (this.basket[index].quantity === 0) {
|
||||||
this.items = this.items.filter(
|
this.basket = this.basket.filter(
|
||||||
(e: BasketItem) => e.id !== this.items[index].id,
|
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setCookies();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||||
*/
|
*/
|
||||||
clearBasket() {
|
clearBasket() {
|
||||||
this.items = [];
|
this.basket = [];
|
||||||
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`;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
unit_price: price,
|
unit_price: price,
|
||||||
} as BasketItem;
|
} as BasketItem;
|
||||||
|
|
||||||
this.items.push(newItem);
|
this.basket.push(newItem);
|
||||||
this.add(newItem);
|
this.add(newItem);
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* @param price The unit price of the product
|
* @param price The unit price of the product
|
||||||
*/
|
*/
|
||||||
addFromCatalog(id: number, name: string, price: number) {
|
addFromCatalog(id: number, name: string, price: number) {
|
||||||
let item = this.items.find((e: BasketItem) => e.id === id);
|
let item = this.basket.find((e: BasketItem) => e.id === id);
|
||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
||||||
|
@ -61,6 +61,7 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
#eboutic .fa-plus,
|
#eboutic .fa-plus,
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script type="module" src="{{ static('bundled/eboutic/makecommand-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -19,7 +19,7 @@
|
|||||||
let billingInfos = {{ billing_infos|safe }};
|
let billingInfos = {{ billing_infos|safe }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div x-data="etransaction(billingInfos)">
|
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@ -82,9 +82,8 @@
|
|||||||
{% elif basket.total > user.account_balance %}
|
{% elif basket.total > user.account_balance %}
|
||||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="action" value="pay_with_sith_account">
|
|
||||||
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
@ -21,13 +21,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||||
<div id="eboutic" x-data="basket">
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
||||||
<div id="basket">
|
<div id="basket">
|
||||||
<h3>Panier</h3>
|
<h3>Panier</h3>
|
||||||
{% if errors %}
|
<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 alert-red">
|
||||||
<div class="alert-main">
|
<div class="alert-main">
|
||||||
{% for error in errors %}
|
{% for error in form.non_form_errors() + form.errors %}
|
||||||
<p style="margin: 0">{{ error }}</p>
|
<p style="margin: 0">{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -43,7 +58,8 @@
|
|||||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<template x-for="item in items" :key="item.id">
|
|
||||||
|
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
|
||||||
<li class="item-row" x-show="item.quantity > 0">
|
<li class="item-row" x-show="item.quantity > 0">
|
||||||
<div class="item-quantity">
|
<div class="item-quantity">
|
||||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||||
@ -52,6 +68,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="item-name" x-text="item.name"></span>
|
<span class="item-name" x-text="item.name"></span>
|
||||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></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>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
{# Total price #}
|
{# Total price #}
|
||||||
@ -61,17 +95,16 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="catalog-buttons">
|
<div class="catalog-buttons">
|
||||||
<button @click="clearBasket()" class="btn btn-grey">
|
<button @click.prevent="clearBasket()" class="btn btn-grey">
|
||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
{% trans %}Clear{% endtrans %}
|
{% trans %}Clear{% endtrans %}
|
||||||
</button>
|
</button>
|
||||||
<form method="get" action="{{ url('eboutic:command') }}">
|
|
||||||
<button class="btn btn-blue">
|
<button class="btn btn-blue">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="catalog">
|
<div id="catalog">
|
||||||
{% if not request.user.date_of_birth %}
|
{% if not request.user.date_of_birth %}
|
||||||
@ -108,7 +141,7 @@
|
|||||||
{% trans trimmed %}
|
{% trans trimmed %}
|
||||||
Our partner uses Weezevent to sell tickets.
|
Our partner uses Weezevent to sell tickets.
|
||||||
Weezevent may collect user info according to
|
Weezevent may collect user info according to
|
||||||
it's own privacy policy.
|
its own privacy policy.
|
||||||
By clicking the accept button you consent to
|
By clicking the accept button you consent to
|
||||||
their terms of services.
|
their terms of services.
|
||||||
{% endtrans %}
|
{% endtrans %}
|
||||||
@ -158,7 +191,7 @@
|
|||||||
<button
|
<button
|
||||||
id="{{ p.id }}"
|
id="{{ p.id }}"
|
||||||
class="card product-button clickable shadow"
|
class="card product-button clickable shadow"
|
||||||
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
||||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||||
>
|
>
|
||||||
{% if p.icon %}
|
{% if p.icon %}
|
||||||
|
@ -4,6 +4,14 @@
|
|||||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if success %}
|
{% if success %}
|
||||||
{% trans %}Payment successful{% endtrans %}
|
{% trans %}Payment successful{% endtrans %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
179
eboutic/tests/test_basket.py
Normal file
179
eboutic/tests/test_basket.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import pytest
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.test.client import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from model_bakery import baker
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import Group, User
|
||||||
|
from counter.baker_recipes import product_recipe
|
||||||
|
from counter.models import Counter, ProductType, get_eboutic
|
||||||
|
from counter.tests.test_counter import BasketItem
|
||||||
|
from eboutic.models import Basket
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_eboutic():
|
||||||
|
assert Counter.objects.get(name="Eboutic") == get_eboutic()
|
||||||
|
|
||||||
|
baker.make(Counter, type="EBOUTIC")
|
||||||
|
|
||||||
|
assert Counter.objects.get(name="Eboutic") == get_eboutic()
|
||||||
|
|
||||||
|
|
||||||
|
class TestEboutic(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.group_cotiz = baker.make(Group)
|
||||||
|
cls.group_public = baker.make(Group)
|
||||||
|
|
||||||
|
cls.new_customer = baker.make(User)
|
||||||
|
cls.new_customer_adult = baker.make(User)
|
||||||
|
cls.subscriber = subscriber_user.make()
|
||||||
|
|
||||||
|
cls.set_age(cls.new_customer, 5)
|
||||||
|
cls.set_age(cls.new_customer_adult, 20)
|
||||||
|
cls.set_age(cls.subscriber, 20)
|
||||||
|
|
||||||
|
product_type = baker.make(ProductType)
|
||||||
|
|
||||||
|
cls.snack = product_recipe.make(
|
||||||
|
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||||
|
)
|
||||||
|
cls.beer = product_recipe.make(
|
||||||
|
limit_age=18,
|
||||||
|
selling_price=2.5,
|
||||||
|
special_selling_price=1,
|
||||||
|
product_type=product_type,
|
||||||
|
)
|
||||||
|
cls.not_in_counter = product_recipe.make(
|
||||||
|
selling_price=3.5, product_type=product_type
|
||||||
|
)
|
||||||
|
cls.cotiz = product_recipe.make(selling_price=10, product_type=product_type)
|
||||||
|
|
||||||
|
cls.group_public.products.add(cls.snack, cls.beer, cls.not_in_counter)
|
||||||
|
cls.group_cotiz.products.add(cls.cotiz)
|
||||||
|
|
||||||
|
cls.subscriber.groups.add(cls.group_cotiz, cls.group_public)
|
||||||
|
cls.new_customer.groups.add(cls.group_public)
|
||||||
|
cls.new_customer_adult.groups.add(cls.group_public)
|
||||||
|
|
||||||
|
cls.eboutic = get_eboutic()
|
||||||
|
cls.eboutic.products.add(cls.cotiz, cls.beer, cls.snack)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_age(cls, user: User, age: int):
|
||||||
|
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
def submit_basket(
|
||||||
|
self, basket: list[BasketItem], client: Client | None = None
|
||||||
|
) -> HttpResponse:
|
||||||
|
used_client: Client = client if client is not None else self.client
|
||||||
|
data = {
|
||||||
|
"form-TOTAL_FORMS": str(len(basket)),
|
||||||
|
"form-INITIAL_FORMS": "0",
|
||||||
|
}
|
||||||
|
for index, item in enumerate(basket):
|
||||||
|
data.update(item.to_form(index))
|
||||||
|
return used_client.post(reverse("eboutic:main"), data)
|
||||||
|
|
||||||
|
def test_submit_empty_basket(self):
|
||||||
|
self.client.force_login(self.subscriber)
|
||||||
|
for basket in [
|
||||||
|
[],
|
||||||
|
[BasketItem(None, None)],
|
||||||
|
[BasketItem(None, None), BasketItem(None, None)],
|
||||||
|
]:
|
||||||
|
response = self.submit_basket(basket)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Votre panier est vide" in response.text
|
||||||
|
|
||||||
|
def test_submit_invalid_basket(self):
|
||||||
|
self.client.force_login(self.subscriber)
|
||||||
|
for item in [
|
||||||
|
BasketItem(-1, 2),
|
||||||
|
BasketItem(self.snack.id, -1),
|
||||||
|
BasketItem(None, 1),
|
||||||
|
BasketItem(self.snack.id, None),
|
||||||
|
]:
|
||||||
|
response = self.submit_basket([item])
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_anonymous(self):
|
||||||
|
assertRedirects(
|
||||||
|
self.client.get(reverse("eboutic:main")),
|
||||||
|
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||||
|
)
|
||||||
|
assertRedirects(
|
||||||
|
self.submit_basket([]),
|
||||||
|
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||||
|
)
|
||||||
|
assertRedirects(
|
||||||
|
self.submit_basket([BasketItem(self.snack.id, 1)]),
|
||||||
|
reverse("core:login", query={"next": reverse("eboutic:main")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_forbidden_product(self):
|
||||||
|
self.client.force_login(self.new_customer)
|
||||||
|
response = self.submit_basket([BasketItem(self.beer.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
self.client.force_login(self.new_customer)
|
||||||
|
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert Basket.objects.first() is None
|
||||||
|
|
||||||
|
def test_create_basket(self):
|
||||||
|
self.client.force_login(self.new_customer)
|
||||||
|
assertRedirects(
|
||||||
|
self.submit_basket([BasketItem(self.snack.id, 2)]),
|
||||||
|
reverse("eboutic:checkout", kwargs={"basket_id": 1}),
|
||||||
|
)
|
||||||
|
assert Basket.objects.get(id=1).total == self.snack.selling_price * 2
|
||||||
|
|
||||||
|
self.client.force_login(self.new_customer_adult)
|
||||||
|
assertRedirects(
|
||||||
|
self.submit_basket(
|
||||||
|
[BasketItem(self.snack.id, 2), BasketItem(self.beer.id, 1)]
|
||||||
|
),
|
||||||
|
reverse("eboutic:checkout", kwargs={"basket_id": 2}),
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
Basket.objects.get(id=2).total
|
||||||
|
== self.snack.selling_price * 2 + self.beer.selling_price
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(self.subscriber)
|
||||||
|
assertRedirects(
|
||||||
|
self.submit_basket(
|
||||||
|
[
|
||||||
|
BasketItem(self.snack.id, 2),
|
||||||
|
BasketItem(self.beer.id, 1),
|
||||||
|
BasketItem(self.cotiz.id, 1),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
reverse("eboutic:checkout", kwargs={"basket_id": 3}),
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
Basket.objects.get(id=3).total
|
||||||
|
== self.snack.selling_price * 2
|
||||||
|
+ self.beer.selling_price
|
||||||
|
+ self.cotiz.selling_price
|
||||||
|
)
|
269
eboutic/tests/test_payment.py
Normal file
269
eboutic/tests/test_payment.py
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import base64
|
||||||
|
import urllib
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
|
from cryptography.hazmat.primitives.hashes import SHA1
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.messages import get_messages
|
||||||
|
from django.contrib.messages.constants import DEFAULT_LEVELS
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
|
from counter.baker_recipes import product_recipe
|
||||||
|
from counter.models import Product, ProductType, Selling
|
||||||
|
from counter.tests.test_counter import force_refill_user
|
||||||
|
from eboutic.models import Basket, BasketItem
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaymentBase(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.customer = subscriber_user.make()
|
||||||
|
cls.basket = baker.make(Basket, user=cls.customer)
|
||||||
|
cls.refilling = product_recipe.make(
|
||||||
|
product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING,
|
||||||
|
selling_price=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
product_type = baker.make(ProductType)
|
||||||
|
|
||||||
|
cls.snack = product_recipe.make(
|
||||||
|
selling_price=1.5, special_selling_price=1, product_type=product_type
|
||||||
|
)
|
||||||
|
cls.beer = product_recipe.make(
|
||||||
|
limit_age=18,
|
||||||
|
selling_price=2.5,
|
||||||
|
special_selling_price=1,
|
||||||
|
product_type=product_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
BasketItem.from_product(cls.snack, 1, cls.basket).save()
|
||||||
|
BasketItem.from_product(cls.beer, 2, cls.basket).save()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaymentSith(TestPaymentBase):
|
||||||
|
def test_anonymous(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Basket.objects.contains(self.basket), (
|
||||||
|
"After an unsuccessful request, the basket should be kept"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unauthorized(self):
|
||||||
|
self.client.force_login(subscriber_user.make())
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert Basket.objects.contains(self.basket), (
|
||||||
|
"After an unsuccessful request, the basket should be kept"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
self.client.force_login(self.customer)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id + 1})
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert Basket.objects.contains(self.basket), (
|
||||||
|
"After an unsuccessful request, the basket should be kept"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_post_allowed(self):
|
||||||
|
self.client.force_login(self.customer)
|
||||||
|
force_refill_user(self.customer, self.basket.total + 1)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 405
|
||||||
|
|
||||||
|
assert Basket.objects.contains(self.basket), (
|
||||||
|
"After an unsuccessful request, the basket should be kept"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert self.customer.customer.amount == self.basket.total + 1
|
||||||
|
|
||||||
|
def test_buy_success(self):
|
||||||
|
self.client.force_login(self.customer)
|
||||||
|
force_refill_user(self.customer, self.basket.total + 1)
|
||||||
|
assertRedirects(
|
||||||
|
self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}),
|
||||||
|
),
|
||||||
|
reverse("eboutic:payment_result", kwargs={"result": "success"}),
|
||||||
|
)
|
||||||
|
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||||
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert self.customer.customer.amount == Decimal("1")
|
||||||
|
|
||||||
|
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||||
|
"quantity"
|
||||||
|
)
|
||||||
|
assert len(sellings) == 2
|
||||||
|
assert sellings[0].payment_method == "SITH_ACCOUNT"
|
||||||
|
assert sellings[0].quantity == 1
|
||||||
|
assert sellings[0].unit_price == self.snack.selling_price
|
||||||
|
assert sellings[0].counter.type == "EBOUTIC"
|
||||||
|
assert sellings[0].product == self.snack
|
||||||
|
|
||||||
|
assert sellings[1].payment_method == "SITH_ACCOUNT"
|
||||||
|
assert sellings[1].quantity == 2
|
||||||
|
assert sellings[1].unit_price == self.beer.selling_price
|
||||||
|
assert sellings[1].counter.type == "EBOUTIC"
|
||||||
|
assert sellings[1].product == self.beer
|
||||||
|
|
||||||
|
def test_not_enough_money(self):
|
||||||
|
self.client.force_login(self.customer)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||||
|
)
|
||||||
|
assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = list(get_messages(response.wsgi_request))
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||||
|
assert messages[0].message == "Solde insuffisant"
|
||||||
|
|
||||||
|
assert Basket.objects.contains(self.basket), (
|
||||||
|
"After an unsuccessful request, the basket should be kept"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refilling_in_basket(self):
|
||||||
|
BasketItem.from_product(self.refilling, 1, self.basket).save()
|
||||||
|
self.client.force_login(self.customer)
|
||||||
|
force_refill_user(self.customer, self.basket.total + 1)
|
||||||
|
self.customer.customer.refresh_from_db()
|
||||||
|
initial_account_balance = self.customer.customer.amount
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
|
||||||
|
)
|
||||||
|
assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
|
||||||
|
)
|
||||||
|
assert Basket.objects.filter(id=self.basket.id).first() is not None
|
||||||
|
messages = list(get_messages(response.wsgi_request))
|
||||||
|
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
|
||||||
|
assert (
|
||||||
|
messages[0].message
|
||||||
|
== "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
|
||||||
|
)
|
||||||
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert self.customer.customer.amount == initial_account_balance
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaymentCard(TestPaymentBase):
|
||||||
|
def generate_bank_valid_answer(self, basket: Basket):
|
||||||
|
query = (
|
||||||
|
f"Amount={int(basket.total * 100)}&BasketID={basket.id}&Auto=42&Error=00000"
|
||||||
|
)
|
||||||
|
with open("./eboutic/tests/private_key.pem", "br") as f:
|
||||||
|
PRIVKEY = f.read()
|
||||||
|
with open("./eboutic/tests/public_key.pem") as f:
|
||||||
|
settings.SITH_EBOUTIC_PUB_KEY = f.read()
|
||||||
|
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
|
||||||
|
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
|
||||||
|
b64sig = base64.b64encode(sig).decode("ascii")
|
||||||
|
|
||||||
|
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
|
||||||
|
query,
|
||||||
|
urllib.parse.quote_plus(b64sig),
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def test_buy_success(self):
|
||||||
|
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content.decode("utf-8") == "Payment successful"
|
||||||
|
assert Basket.objects.filter(id=self.basket.id).first() is None
|
||||||
|
|
||||||
|
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
|
||||||
|
"quantity"
|
||||||
|
)
|
||||||
|
assert len(sellings) == 2
|
||||||
|
assert sellings[0].payment_method == "CARD"
|
||||||
|
assert sellings[0].quantity == 1
|
||||||
|
assert sellings[0].unit_price == self.snack.selling_price
|
||||||
|
assert sellings[0].counter.type == "EBOUTIC"
|
||||||
|
assert sellings[0].product == self.snack
|
||||||
|
|
||||||
|
assert sellings[1].payment_method == "CARD"
|
||||||
|
assert sellings[1].quantity == 2
|
||||||
|
assert sellings[1].unit_price == self.beer.selling_price
|
||||||
|
assert sellings[1].counter.type == "EBOUTIC"
|
||||||
|
assert sellings[1].product == self.beer
|
||||||
|
|
||||||
|
def test_buy_subscribe_product(self):
|
||||||
|
customer = old_subscriber_user.make()
|
||||||
|
assert customer.subscriptions.count() == 1
|
||||||
|
assert not customer.subscriptions.first().is_valid_now()
|
||||||
|
|
||||||
|
basket = baker.make(Basket, user=customer)
|
||||||
|
BasketItem.from_product(Product.objects.get(code="2SCOTIZ"), 1, basket).save()
|
||||||
|
response = self.client.get(self.generate_bank_valid_answer(basket))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
assert customer.subscriptions.count() == 2
|
||||||
|
|
||||||
|
subscription = customer.subscriptions.order_by("-subscription_end").first()
|
||||||
|
assert subscription.is_valid_now()
|
||||||
|
assert subscription.subscription_type == "deux-semestres"
|
||||||
|
assert subscription.location == "EBOUTIC"
|
||||||
|
|
||||||
|
def test_buy_refilling(self):
|
||||||
|
BasketItem.from_product(self.refilling, 2, self.basket).save()
|
||||||
|
response = self.client.get(self.generate_bank_valid_answer(self.basket))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert self.customer.customer.amount == self.refilling.selling_price * 2
|
||||||
|
|
||||||
|
def test_multiple_responses(self):
|
||||||
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
|
|
||||||
|
response = self.client.get(bank_response)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = self.client.get(bank_response)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert (
|
||||||
|
response.text
|
||||||
|
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_basket(self):
|
||||||
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
|
self.basket.delete()
|
||||||
|
response = self.client.get(bank_response)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert (
|
||||||
|
response.text
|
||||||
|
== "Basket processing failed with error: SuspiciousOperation('Basket does not exists')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_altered_basket(self):
|
||||||
|
bank_response = self.generate_bank_valid_answer(self.basket)
|
||||||
|
BasketItem.from_product(self.snack, 1, self.basket).save()
|
||||||
|
response = self.client.get(bank_response)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert (
|
||||||
|
response.text == "Basket processing failed with error: "
|
||||||
|
"SuspiciousOperation('Basket total and amount do not match')"
|
||||||
|
)
|
@ -1,254 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2016,2017
|
|
||||||
# - Skia <skia@libskia.so>
|
|
||||||
# - Maréchal <thgirod@hotmail.com
|
|
||||||
#
|
|
||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
|
||||||
# http://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify it under
|
|
||||||
# the terms of the GNU General Public License a published by the Free Software
|
|
||||||
# Foundation; either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import urllib
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
|
|
||||||
from cryptography.hazmat.primitives.hashes import SHA1
|
|
||||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db.models import Max
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
from counter.models import Counter, Customer, Product, Selling
|
|
||||||
from eboutic.models import Basket, BasketItem
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
||||||
|
|
||||||
|
|
||||||
class TestEboutic(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.barbar = Product.objects.get(code="BARB")
|
|
||||||
cls.refill = Product.objects.get(code="15REFILL")
|
|
||||||
cls.cotis = Product.objects.get(code="1SCOTIZ")
|
|
||||||
cls.eboutic = Counter.objects.get(name="Eboutic")
|
|
||||||
cls.skia = User.objects.get(username="skia")
|
|
||||||
cls.subscriber = User.objects.get(username="subscriber")
|
|
||||||
cls.old_subscriber = User.objects.get(username="old_subscriber")
|
|
||||||
cls.public = User.objects.get(username="public")
|
|
||||||
|
|
||||||
def get_busy_basket(self, user) -> Basket:
|
|
||||||
"""Create and return a basket with 3 barbar and 1 cotis in it.
|
|
||||||
|
|
||||||
Edit the client session to store the basket id in it.
|
|
||||||
"""
|
|
||||||
session = self.client.session
|
|
||||||
basket = Basket.objects.create(user=user)
|
|
||||||
session["basket_id"] = basket.id
|
|
||||||
session.save()
|
|
||||||
BasketItem.from_product(self.barbar, 3, basket).save()
|
|
||||||
BasketItem.from_product(self.cotis, 1, basket).save()
|
|
||||||
return basket
|
|
||||||
|
|
||||||
def generate_bank_valid_answer(self) -> str:
|
|
||||||
basket = Basket.from_session(self.client.session)
|
|
||||||
basket_id = basket.id
|
|
||||||
amount = int(basket.total * 100)
|
|
||||||
query = f"Amount={amount}&BasketID={basket_id}&Auto=42&Error=00000"
|
|
||||||
with open("./eboutic/tests/private_key.pem", "br") as f:
|
|
||||||
PRIVKEY = f.read()
|
|
||||||
with open("./eboutic/tests/public_key.pem") as f:
|
|
||||||
settings.SITH_EBOUTIC_PUB_KEY = f.read()
|
|
||||||
key: RSAPrivateKey = load_pem_private_key(PRIVKEY, None)
|
|
||||||
sig = key.sign(query.encode("utf-8"), PKCS1v15(), SHA1())
|
|
||||||
b64sig = base64.b64encode(sig).decode("ascii")
|
|
||||||
|
|
||||||
url = reverse("eboutic:etransation_autoanswer") + "?%s&Sig=%s" % (
|
|
||||||
query,
|
|
||||||
urllib.parse.quote_plus(b64sig),
|
|
||||||
)
|
|
||||||
return url
|
|
||||||
|
|
||||||
def test_buy_with_sith_account(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.subscriber.customer.amount = 100 # give money before test
|
|
||||||
self.subscriber.customer.save()
|
|
||||||
basket = self.get_busy_basket(self.subscriber)
|
|
||||||
amount = basket.total
|
|
||||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
|
||||||
self.assertRedirects(response, "/eboutic/pay/success/")
|
|
||||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
|
||||||
assert float(new_balance) == 100 - amount
|
|
||||||
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
|
|
||||||
assert expected == self.client.cookies["basket_items"].OutputString()
|
|
||||||
|
|
||||||
def test_buy_with_sith_account_no_money(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
basket = self.get_busy_basket(self.subscriber)
|
|
||||||
initial = basket.total - 1 # just not enough to complete the sale
|
|
||||||
self.subscriber.customer.amount = initial
|
|
||||||
self.subscriber.customer.save()
|
|
||||||
response = self.client.post(reverse("eboutic:pay_with_sith"))
|
|
||||||
self.assertRedirects(response, "/eboutic/pay/failure/")
|
|
||||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
|
||||||
assert float(new_balance) == initial
|
|
||||||
# this cookie should be removed after payment
|
|
||||||
expected = 'basket_items=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/eboutic'
|
|
||||||
assert expected == self.client.cookies["basket_items"].OutputString()
|
|
||||||
|
|
||||||
def test_submit_basket(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.client.cookies["basket_items"] = """[
|
|
||||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28},
|
|
||||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
|
||||||
]"""
|
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
|
||||||
assert response.status_code == 200
|
|
||||||
self.assertInHTML(
|
|
||||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
self.assertInHTML(
|
|
||||||
"<tr><td>Barbar</td><td>3</td><td>1.70 €</td></tr>",
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
assert "basket_id" in self.client.session
|
|
||||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
|
||||||
assert basket.items.count() == 2
|
|
||||||
barbar = basket.items.filter(product_name="Barbar").first()
|
|
||||||
assert barbar is not None
|
|
||||||
assert barbar.quantity == 3
|
|
||||||
cotis = basket.items.filter(product_name="Cotis 2 semestres").first()
|
|
||||||
assert cotis is not None
|
|
||||||
assert cotis.quantity == 1
|
|
||||||
assert basket.total == 3 * 1.7 + 28
|
|
||||||
|
|
||||||
def test_submit_empty_basket(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.client.cookies["basket_items"] = "[]"
|
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
|
||||||
self.assertRedirects(response, "/eboutic/")
|
|
||||||
|
|
||||||
def test_submit_invalid_basket(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
max_id = Product.objects.aggregate(res=Max("id"))["res"]
|
|
||||||
self.client.cookies["basket_items"] = f"""[
|
|
||||||
{{"id": {max_id + 1}, "name": "", "quantity": 1, "unit_price": 28}}
|
|
||||||
]"""
|
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
|
||||||
cookie = self.client.cookies["basket_items"].OutputString()
|
|
||||||
assert 'basket_items="[]"' in cookie
|
|
||||||
assert "Path=/eboutic" in cookie
|
|
||||||
self.assertRedirects(response, "/eboutic/")
|
|
||||||
|
|
||||||
def test_submit_basket_illegal_quantity(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.client.cookies["basket_items"] = """[
|
|
||||||
{"id": 4, "name": "Barbar", "quantity": -1, "unit_price": 1.7}
|
|
||||||
]"""
|
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
|
||||||
self.assertRedirects(response, "/eboutic/")
|
|
||||||
|
|
||||||
def test_buy_subscribe_product_with_credit_card(self):
|
|
||||||
self.client.force_login(self.old_subscriber)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_profile", kwargs={"user_id": self.old_subscriber.id})
|
|
||||||
)
|
|
||||||
assert "Non cotisant" in str(response.content)
|
|
||||||
self.client.cookies["basket_items"] = """[
|
|
||||||
{"id": 2, "name": "Cotis 2 semestres", "quantity": 1, "unit_price": 28}
|
|
||||||
]"""
|
|
||||||
response = self.client.get(reverse("eboutic:command"))
|
|
||||||
self.assertInHTML(
|
|
||||||
"<tr><td>Cotis 2 semestres</td><td>1</td><td>28.00 €</td></tr>",
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
basket = Basket.objects.get(id=self.client.session["basket_id"])
|
|
||||||
assert basket.items.count() == 1
|
|
||||||
response = self.client.get(self.generate_bank_valid_answer())
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.content.decode("utf-8") == "Payment successful"
|
|
||||||
|
|
||||||
subscriber = User.objects.get(id=self.old_subscriber.id)
|
|
||||||
assert subscriber.subscriptions.count() == 2
|
|
||||||
sub = subscriber.subscriptions.order_by("-subscription_end").first()
|
|
||||||
assert sub.is_valid_now()
|
|
||||||
assert sub.member == subscriber
|
|
||||||
assert sub.subscription_type == "deux-semestres"
|
|
||||||
assert sub.location == "EBOUTIC"
|
|
||||||
|
|
||||||
def test_buy_refill_product_with_credit_card(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
# basket contains 1 refill item worth 15€
|
|
||||||
self.client.cookies["basket_items"] = json.dumps(
|
|
||||||
[{"id": 3, "name": "Rechargement 15 €", "quantity": 1, "unit_price": 15}]
|
|
||||||
)
|
|
||||||
initial_balance = self.subscriber.customer.amount
|
|
||||||
self.client.get(reverse("eboutic:command"))
|
|
||||||
|
|
||||||
url = self.generate_bank_valid_answer()
|
|
||||||
response = self.client.get(url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.text == "Payment successful"
|
|
||||||
new_balance = Customer.objects.get(user=self.subscriber).amount
|
|
||||||
assert new_balance == initial_balance + 15
|
|
||||||
|
|
||||||
def test_alter_basket_after_submission(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.client.cookies["basket_items"] = json.dumps(
|
|
||||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
|
||||||
)
|
|
||||||
self.client.get(reverse("eboutic:command"))
|
|
||||||
et_answer_url = self.generate_bank_valid_answer()
|
|
||||||
self.client.cookies["basket_items"] = json.dumps(
|
|
||||||
[ # alter basket
|
|
||||||
{"id": 4, "name": "Barbar", "quantity": 3, "unit_price": 1.7}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
self.client.get(reverse("eboutic:command"))
|
|
||||||
response = self.client.get(et_answer_url)
|
|
||||||
assert response.status_code == 500
|
|
||||||
msg = (
|
|
||||||
"Basket processing failed with error: "
|
|
||||||
"SuspiciousOperation('Basket total and amount do not match'"
|
|
||||||
)
|
|
||||||
assert msg in response.content.decode("utf-8")
|
|
||||||
|
|
||||||
def test_buy_simple_product_with_credit_card(self):
|
|
||||||
self.client.force_login(self.subscriber)
|
|
||||||
self.client.cookies["basket_items"] = json.dumps(
|
|
||||||
[{"id": 4, "name": "Barbar", "quantity": 1, "unit_price": 1.7}]
|
|
||||||
)
|
|
||||||
self.client.get(reverse("eboutic:command"))
|
|
||||||
et_answer_url = self.generate_bank_valid_answer()
|
|
||||||
response = self.client.get(et_answer_url)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.content.decode("utf-8") == "Payment successful"
|
|
||||||
|
|
||||||
selling = (
|
|
||||||
Selling.objects.filter(customer=self.subscriber.customer)
|
|
||||||
.order_by("-date")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert selling.payment_method == "CARD"
|
|
||||||
assert selling.quantity == 1
|
|
||||||
assert selling.unit_price == self.barbar.selling_price
|
|
||||||
assert selling.counter.type == "EBOUTIC"
|
|
||||||
assert selling.product == self.barbar
|
|
@ -27,11 +27,11 @@ from django.urls import path, register_converter
|
|||||||
from eboutic.converters import PaymentResultConverter
|
from eboutic.converters import PaymentResultConverter
|
||||||
from eboutic.views import (
|
from eboutic.views import (
|
||||||
BillingInfoFormFragment,
|
BillingInfoFormFragment,
|
||||||
EbouticCommand,
|
EbouticCheckout,
|
||||||
|
EbouticMainView,
|
||||||
|
EbouticPayWithSith,
|
||||||
EtransactionAutoAnswer,
|
EtransactionAutoAnswer,
|
||||||
EurokPartnerFragment,
|
EurokPartnerFragment,
|
||||||
eboutic_main,
|
|
||||||
pay_with_sith,
|
|
||||||
payment_result,
|
payment_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Subscription views
|
# Subscription views
|
||||||
path("", eboutic_main, name="main"),
|
path("", EbouticMainView.as_view(), name="main"),
|
||||||
path("command/", EbouticCommand.as_view(), name="command"),
|
path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
|
||||||
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
||||||
path("pay/sith/", pay_with_sith, name="pay_with_sith"),
|
path(
|
||||||
|
"pay/sith/<int:basket_id>", EbouticPayWithSith.as_view(), name="pay_with_sith"
|
||||||
|
),
|
||||||
path("pay/<res:result>/", payment_result, name="payment_result"),
|
path("pay/<res:result>/", payment_result, name="payment_result"),
|
||||||
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
path("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
||||||
path(
|
path(
|
||||||
|
192
eboutic/views.py
192
eboutic/views.py
@ -18,7 +18,6 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
@ -33,23 +32,23 @@ from django.contrib.auth.mixins import (
|
|||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
)
|
)
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.db import DatabaseError, transaction
|
from django.db import DatabaseError, transaction
|
||||||
|
from django.db.models.fields import forms
|
||||||
from django.db.utils import cached_property
|
from django.db.utils import cached_property
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET
|
||||||
from django.views.generic import TemplateView, UpdateView, View
|
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_countries.fields import Country
|
||||||
|
|
||||||
from core.auth.mixins import IsSubscriberMixin
|
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||||
from counter.forms import BillingInfoForm
|
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
|
||||||
from counter.models import BillingInfo, Counter, Customer, Product
|
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
|
||||||
from eboutic.forms import BasketForm
|
|
||||||
from eboutic.models import (
|
from eboutic.models import (
|
||||||
Basket,
|
Basket,
|
||||||
BasketItem,
|
BasketItem,
|
||||||
@ -58,39 +57,82 @@ from eboutic.models import (
|
|||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
get_eboutic_products,
|
get_eboutic_products,
|
||||||
)
|
)
|
||||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||||
from django.utils.html import SafeString
|
from django.utils.html import SafeString
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
class BaseEbouticBasketForm(BaseBasketForm):
|
||||||
@require_GET
|
def _check_enough_money(self, *args, **kwargs):
|
||||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
|
# Disable money check
|
||||||
"""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
|
EbouticBasketForm = forms.formset_factory(
|
||||||
the catalogue of products that he can buy and fill
|
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||||
his shopping cart.
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EbouticMainView(LoginRequiredMixin, FormView):
|
||||||
|
"""Main view of the eboutic application.
|
||||||
|
|
||||||
The purchasable products are those of the eboutic which
|
The purchasable products are those of the eboutic which
|
||||||
belong to a category of products of a product category
|
belong to a category of products of a product category
|
||||||
(orphan products are inaccessible).
|
(orphan products are inaccessible).
|
||||||
|
|
||||||
If the session contains a key-value pair that associates "errors"
|
|
||||||
with a list of strings, this pair is removed from the session
|
|
||||||
and its value displayed to the user when the page is rendered.
|
|
||||||
"""
|
"""
|
||||||
errors = request.session.pop("errors", None)
|
|
||||||
products = get_eboutic_products(request.user)
|
template_name = "eboutic/eboutic_main.jinja"
|
||||||
context = {
|
form_class = EbouticBasketForm
|
||||||
"errors": errors,
|
|
||||||
"products": products,
|
def get_form_kwargs(self):
|
||||||
"customer_amount": request.user.account_balance,
|
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 render(request, "eboutic/eboutic_main.jinja", context)
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, formset):
|
||||||
|
if len(formset) == 0:
|
||||||
|
formset.errors.append(_("Your basket is empty"))
|
||||||
|
return self.form_invalid(formset)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
self.basket = Basket.objects.create(user=self.request.user)
|
||||||
|
for form in formset:
|
||||||
|
BasketItem.from_product(
|
||||||
|
form.product, form.cleaned_data["quantity"], self.basket
|
||||||
|
).save()
|
||||||
|
self.basket.save()
|
||||||
|
return super().form_valid(formset)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def products(self) -> list[Product]:
|
||||||
|
return get_eboutic_products(self.request.user)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def customer(self) -> Customer:
|
||||||
|
return Customer.get_or_create(self.request.user)[0]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["products"] = self.products
|
||||||
|
context["customer_amount"] = self.request.user.account_balance
|
||||||
|
last_purchase: Selling | None = (
|
||||||
|
self.customer.buyings.filter(counter__type="EBOUTIC")
|
||||||
|
.order_by("-date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
context["last_purchase_time"] = (
|
||||||
|
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
@require_GET
|
||||||
@ -166,48 +208,15 @@ class BillingInfoFormFragment(
|
|||||||
return self.request.path
|
return self.request.path
|
||||||
|
|
||||||
|
|
||||||
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
model = Basket
|
||||||
basket: Basket
|
pk_url_kwarg = "basket_id"
|
||||||
|
context_object_name = "basket"
|
||||||
|
template_name = "eboutic/eboutic_checkout.jinja"
|
||||||
fragments = {
|
fragments = {
|
||||||
"billing_infos_form": BillingInfoFormFragment,
|
"billing_infos_form": BillingInfoFormFragment,
|
||||||
}
|
}
|
||||||
|
|
||||||
@method_decorator(login_required)
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
return redirect("eboutic:main")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
if hasattr(self.request.user, "customer"):
|
if hasattr(self.request.user, "customer"):
|
||||||
@ -215,32 +224,31 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
|||||||
kwargs["customer_amount"] = customer.amount
|
kwargs["customer_amount"] = customer.amount
|
||||||
else:
|
else:
|
||||||
kwargs["customer_amount"] = None
|
kwargs["customer_amount"] = None
|
||||||
kwargs["basket"] = self.basket
|
|
||||||
kwargs["billing_infos"] = {}
|
kwargs["billing_infos"] = {}
|
||||||
|
|
||||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||||
kwargs["billing_infos"] = json.dumps(
|
kwargs["billing_infos"] = json.dumps(
|
||||||
dict(self.basket.get_e_transaction_data())
|
dict(self.object.get_e_transaction_data())
|
||||||
)
|
)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||||
@require_POST
|
model = Basket
|
||||||
def pay_with_sith(request):
|
pk_url_kwarg = "basket_id"
|
||||||
basket = Basket.from_session(request.session)
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
basket = self.get_object()
|
||||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
if basket.items.filter(type_id=refilling).exists():
|
||||||
return redirect("eboutic:main")
|
messages.error(
|
||||||
c = Customer.objects.filter(user__id=basket.user_id).first()
|
self.request,
|
||||||
if c is None:
|
_("You can't buy a refilling with sith money"),
|
||||||
return redirect("eboutic:main")
|
)
|
||||||
if c.amount < basket.total:
|
return redirect("eboutic:payment_result", "failure")
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
|
||||||
res.delete_cookie("basket_items", "/eboutic")
|
eboutic = get_eboutic()
|
||||||
return res
|
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
|
||||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
|
||||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Selling.save has some important business logic in it.
|
# Selling.save has some important business logic in it.
|
||||||
@ -248,18 +256,12 @@ def pay_with_sith(request):
|
|||||||
for sale in sales:
|
for sale in sales:
|
||||||
sale.save()
|
sale.save()
|
||||||
basket.delete()
|
basket.delete()
|
||||||
request.session.pop("basket_id", None)
|
return redirect("eboutic:payment_result", "success")
|
||||||
res = redirect("eboutic:payment_result", "success")
|
|
||||||
except DatabaseError as e:
|
except DatabaseError as e:
|
||||||
with sentry_sdk.push_scope() as scope:
|
sentry_sdk.capture_exception(e)
|
||||||
scope.user = {"username": request.user.username}
|
except ValidationError as e:
|
||||||
scope.set_extra("someVariable", e.__repr__())
|
messages.error(self.request, e.message)
|
||||||
sentry_sdk.capture_message(
|
return redirect("eboutic:payment_result", "failure")
|
||||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
|
||||||
)
|
|
||||||
res = redirect("eboutic:payment_result", "failure")
|
|
||||||
res.delete_cookie("basket_items", "/eboutic")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
class EtransactionAutoAnswer(View):
|
class EtransactionAutoAnswer(View):
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from launderette.models import Launderette, Machine, Slot, Token
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Launderette)
|
|
||||||
class LaunderetteAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "counter")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Machine)
|
|
||||||
class MachineAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "launderette", "type", "is_working")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Token)
|
|
||||||
class TokenAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "launderette", "type", "user")
|
|
||||||
autocomplete_fields = ("user",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Slot)
|
|
||||||
class SlotAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("machine", "user", "start_date")
|
|
||||||
autocomplete_fields = ("user",)
|
|
@ -0,0 +1,14 @@
|
|||||||
|
# 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"),
|
||||||
|
]
|
@ -1,193 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import DataError, models
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from club.models import Club
|
|
||||||
from core.models import User
|
|
||||||
from counter.models import Counter
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
|
|
||||||
|
|
||||||
class Launderette(models.Model):
|
|
||||||
name = models.CharField(_("name"), max_length=30)
|
|
||||||
counter = models.OneToOneField(
|
|
||||||
Counter,
|
|
||||||
verbose_name=_("counter"),
|
|
||||||
related_name="launderette",
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Launderette")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse("launderette:launderette_list")
|
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
|
||||||
"""Method to see if that object can be edited by the given user."""
|
|
||||||
if user.is_anonymous:
|
|
||||||
return False
|
|
||||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
|
||||||
m = launderette_club.get_membership_for(user)
|
|
||||||
return bool(m and m.role >= 9)
|
|
||||||
|
|
||||||
def can_be_edited_by(self, user):
|
|
||||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
|
||||||
m = launderette_club.get_membership_for(user)
|
|
||||||
return bool(m and m.role >= 2)
|
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
|
||||||
return user.is_subscribed
|
|
||||||
|
|
||||||
def get_machine_list(self):
|
|
||||||
return Machine.objects.filter(launderette_id=self.id)
|
|
||||||
|
|
||||||
def machine_list(self):
|
|
||||||
return [m.id for m in self.get_machine_list()]
|
|
||||||
|
|
||||||
def get_token_list(self):
|
|
||||||
return Token.objects.filter(launderette_id=self.id)
|
|
||||||
|
|
||||||
def token_list(self):
|
|
||||||
return [t.id for t in self.get_token_list()]
|
|
||||||
|
|
||||||
|
|
||||||
class Machine(models.Model):
|
|
||||||
name = models.CharField(_("name"), max_length=30)
|
|
||||||
launderette = models.ForeignKey(
|
|
||||||
Launderette,
|
|
||||||
related_name="machines",
|
|
||||||
verbose_name=_("launderette"),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
type = models.CharField(
|
|
||||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
|
||||||
)
|
|
||||||
is_working = models.BooleanField(_("is working"), default=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Machine")
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "%s %s" % (self._meta.verbose_name, self.name)
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse(
|
|
||||||
"launderette:launderette_admin",
|
|
||||||
kwargs={"launderette_id": self.launderette.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
|
||||||
"""Method to see if that object can be edited by the given user."""
|
|
||||||
if user.is_anonymous:
|
|
||||||
return False
|
|
||||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
|
||||||
m = launderette_club.get_membership_for(user)
|
|
||||||
return bool(m and m.role >= 9)
|
|
||||||
|
|
||||||
|
|
||||||
class Token(models.Model):
|
|
||||||
name = models.CharField(_("name"), max_length=5)
|
|
||||||
launderette = models.ForeignKey(
|
|
||||||
Launderette,
|
|
||||||
related_name="tokens",
|
|
||||||
verbose_name=_("launderette"),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
type = models.CharField(
|
|
||||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
|
||||||
)
|
|
||||||
borrow_date = models.DateTimeField(_("borrow date"), null=True, blank=True)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
related_name="tokens",
|
|
||||||
verbose_name=_("user"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Token")
|
|
||||||
unique_together = ("name", "launderette", "type")
|
|
||||||
ordering = ["type", "name"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f"{self.__class__._meta.verbose_name} {self.get_type_display()} "
|
|
||||||
f"#{self.name} ({self.launderette.name})"
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
if self.name == "":
|
|
||||||
raise DataError(_("Token name can not be blank"))
|
|
||||||
else:
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
|
||||||
"""Method to see if that object can be edited by the given user."""
|
|
||||||
if user.is_anonymous:
|
|
||||||
return False
|
|
||||||
launderette_club = Club.objects.get(id=settings.SITH_LAUNDERETTE_CLUB_ID)
|
|
||||||
m = launderette_club.get_membership_for(user)
|
|
||||||
return bool(m and m.role >= 9)
|
|
||||||
|
|
||||||
|
|
||||||
class Slot(models.Model):
|
|
||||||
start_date = models.DateTimeField(_("start date"))
|
|
||||||
type = models.CharField(
|
|
||||||
_("type"), max_length=10, choices=settings.SITH_LAUNDERETTE_MACHINE_TYPES
|
|
||||||
)
|
|
||||||
machine = models.ForeignKey(
|
|
||||||
Machine,
|
|
||||||
related_name="slots",
|
|
||||||
verbose_name=_("machine"),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
token = models.ForeignKey(
|
|
||||||
Token,
|
|
||||||
related_name="slots",
|
|
||||||
verbose_name=_("token"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User, related_name="slots", verbose_name=_("user"), on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("Slot")
|
|
||||||
ordering = ["start_date"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "User: %s - Date: %s - Type: %s - Machine: %s - Token: %s" % (
|
|
||||||
self.user,
|
|
||||||
self.start_date,
|
|
||||||
self.get_type_display(),
|
|
||||||
self.machine.name,
|
|
||||||
self.token,
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_owned_by(self, user):
|
|
||||||
return user == self.user
|
|
@ -1,69 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans %}Launderette admin{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h3>{% trans %}Selling{% endtrans %}</h3>
|
|
||||||
<p><a href="{{ url('launderette:main_click', launderette_id=launderette.id) }}">{% trans %}Sell{% endtrans %}</a></p>
|
|
||||||
<hr>
|
|
||||||
<h3>{% trans %}Machines{% endtrans %}</h3>
|
|
||||||
<p><a href="{{ url('launderette:machine_new') }}?launderette={{ launderette.id }}">{% trans %}New machine{% endtrans %}</a></p>
|
|
||||||
<ul>
|
|
||||||
{% for m in launderette.machines.all() %}
|
|
||||||
<li><a href="{{ url('launderette:machine_edit', machine_id=m.id) }}">{{ m }}</a> -
|
|
||||||
<a href="{{ url('launderette:machine_delete', machine_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<hr>
|
|
||||||
<h3>{% trans %}Tokens{% endtrans %}</h3>
|
|
||||||
<p>
|
|
||||||
<form method="post" action="" id="token_form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<p>{{ form.action.errors }}<label for="{{ form.action.name }}">{{ form.action.label }}</label>
|
|
||||||
{% for c in form.action %}
|
|
||||||
{{ c }}
|
|
||||||
{% endfor %}
|
|
||||||
</p>
|
|
||||||
<p>{{ form.token_type.errors }}<label for="{{ form.token_type.name }}">{{ form.token_type.label }}</label>
|
|
||||||
{% for c in form.token_type %}
|
|
||||||
{{ c }}
|
|
||||||
{% endfor %}
|
|
||||||
</p>
|
|
||||||
{{ form.tokens }}
|
|
||||||
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
|
|
||||||
</form>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td>{% trans %}Type{% endtrans %}</td>
|
|
||||||
<td>{% trans %}Name{% endtrans %}</td>
|
|
||||||
<td>{% trans %}User{% endtrans %}</td>
|
|
||||||
<td>{% trans %}Since{% endtrans %}</td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for t in launderette.tokens.all() %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ t.get_type_display() }}</td>
|
|
||||||
<td>{{ t.name }}</td>
|
|
||||||
{% if t.user %}
|
|
||||||
<td>{{ t.user.get_display_name() }}</td>
|
|
||||||
<td>{{ t.borrow_date|date(DATETIME_FORMAT) }} - {{ t.borrow_date|time(DATETIME_FORMAT) }}</td>
|
|
||||||
{% else %}
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</p>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
{% from "core/macros.jinja" import show_slots, show_tokens %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans %}Launderette{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% macro choose(date) %}
|
|
||||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="slot_type" value="{{ slot_type }}">
|
|
||||||
<button type="submit" name="slot" value="{{ date.isoformat() }}">{% trans %}Choose{% endtrans %}</button>
|
|
||||||
</form>
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h3>{{ launderette }}</h3>
|
|
||||||
<p>
|
|
||||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}"
|
|
||||||
class="inline" style="display:inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" name="slot_type" value="BOTH" {% if slot_type == "BOTH" -%}style="background: #FF0"{% endif %}>{% trans %}Washing and drying{% endtrans %}</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" name="slot_type" value="WASHING" {% if slot_type == "WASHING" -%}style="background: #FF0"{% endif %}>{% trans %}Washing{% endtrans %}</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="{{ url('launderette:book_slot', launderette_id=launderette.id) }}" class="inline" style="display:inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" name="slot_type" value="DRYING" {% if slot_type == "DRYING" -%}style="background: #FF0"{% endif %}>{% trans %}Drying{% endtrans %}</button>
|
|
||||||
</form>
|
|
||||||
</p>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% for day in planning.keys() %}
|
|
||||||
<th>{{ day|date('l') }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for i in range(0, 24) %}
|
|
||||||
<tr>
|
|
||||||
{% for hours in planning.values() %}
|
|
||||||
<td>
|
|
||||||
{% if hours[i] %}
|
|
||||||
{{ hours[i]|time(TIME_FORMAT) }} {{ choose(hours[i]) }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{ show_slots(user) }}
|
|
||||||
{{ show_tokens(user) }}
|
|
||||||
{% endblock %}
|
|
@ -1,21 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans %}Launderette{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if request.user.is_subscribed %}
|
|
||||||
<ul>
|
|
||||||
{% for l in launderette_list %}
|
|
||||||
<li><a href="{{ url('launderette:book_slot', launderette_id=l.id) }}">{{ l }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{{ counter }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h3>{% trans counter_name=counter %}{{ counter_name }} counter{% endtrans %}</h3>
|
|
||||||
<div>
|
|
||||||
<form method="post" action="" class="inline" style="display:inline">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_p() }}
|
|
||||||
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -1,25 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block title %}
|
|
||||||
{% trans %}Launderette admin list{% endtrans %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% if user.is_root %}
|
|
||||||
<p><a href="{{ url('launderette:launderette_new') }}">{% trans %}New launderette{% endtrans %}</a></p>
|
|
||||||
{% endif %}
|
|
||||||
{% if launderette_list %}
|
|
||||||
<h3>{% trans %}Launderette admin list{% endtrans %}</h3>
|
|
||||||
<ul>
|
|
||||||
{% for l in launderette_list %}
|
|
||||||
<li><a href="{{ url('launderette:launderette_admin', launderette_id=l.id) }}">{{ l }}</a> -
|
|
||||||
<a href="{{ url('launderette:launderette_edit', launderette_id=l.id) }}">{% trans %}Edit{% endtrans %}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
{% trans %}There is no launderette in this website.{% endtrans %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
@ -1,73 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
from django.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",
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,511 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2023 © AE UTBM
|
|
||||||
# ae@utbm.fr / ae.info@utbm.fr
|
|
||||||
#
|
|
||||||
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
|
||||||
# https://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
|
||||||
#
|
|
||||||
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
|
||||||
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
|
||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
|
||||||
#
|
|
||||||
#
|
|
||||||
|
|
||||||
from 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() + " N°" + 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() + " N°" + 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
|
|
@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-04-14 01:16+0200\n"
|
"POT-Creation-Date: 2025-04-15 23:39+0200\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@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
|
#: core/templates/core/base/navbar.jinja counter/models.py
|
||||||
#: counter/templates/counter/counter_list.jinja
|
#: counter/templates/counter/counter_list.jinja
|
||||||
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
|
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
|
||||||
msgid "Eboutic"
|
msgid "Eboutic"
|
||||||
msgstr "Eboutic"
|
msgstr "Eboutic"
|
||||||
@ -2882,6 +2882,30 @@ msgstr ""
|
|||||||
msgid "Refound this account"
|
msgid "Refound this account"
|
||||||
msgstr "Rembourser ce compte"
|
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
|
#: counter/management/commands/dump_accounts.py
|
||||||
msgid "Your AE account has been emptied"
|
msgid "Your AE account has been emptied"
|
||||||
msgstr "Votre compte AE a été vidé"
|
msgstr "Votre compte AE a été vidé"
|
||||||
@ -2906,10 +2930,6 @@ msgstr "client"
|
|||||||
msgid "customers"
|
msgid "customers"
|
||||||
msgstr "clients"
|
msgstr "clients"
|
||||||
|
|
||||||
#: counter/models.py counter/views/click.py
|
|
||||||
msgid "Not enough money"
|
|
||||||
msgstr "Solde insuffisant"
|
|
||||||
|
|
||||||
#: counter/models.py
|
#: counter/models.py
|
||||||
msgid "First name"
|
msgid "First name"
|
||||||
msgstr "Prénom"
|
msgstr "Prénom"
|
||||||
@ -3278,7 +3298,7 @@ msgid "Go"
|
|||||||
msgstr "Valider"
|
msgstr "Valider"
|
||||||
|
|
||||||
#: counter/templates/counter/counter_click.jinja
|
#: counter/templates/counter/counter_click.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
msgid "Basket: "
|
msgid "Basket: "
|
||||||
msgstr "Panier : "
|
msgstr "Panier : "
|
||||||
|
|
||||||
@ -3672,26 +3692,6 @@ msgstr "Montant du chèque"
|
|||||||
msgid "Check quantity"
|
msgid "Check quantity"
|
||||||
msgstr "Nombre de chèque"
|
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
|
#: counter/views/eticket.py
|
||||||
msgid "people(s)"
|
msgid "people(s)"
|
||||||
msgstr "personne(s)"
|
msgstr "personne(s)"
|
||||||
@ -3729,19 +3729,6 @@ msgstr "Types de produit"
|
|||||||
msgid "%(name)s has no registered student card"
|
msgid "%(name)s has no registered student card"
|
||||||
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
|
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
|
#: eboutic/models.py
|
||||||
msgid "validated"
|
msgid "validated"
|
||||||
msgstr "validé"
|
msgstr "validé"
|
||||||
@ -3779,15 +3766,44 @@ msgstr "Informations de facturation"
|
|||||||
msgid "Validate"
|
msgid "Validate"
|
||||||
msgstr "Valider"
|
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_main.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
msgid "Current account amount: "
|
msgid "Current account amount: "
|
||||||
msgstr "Solde actuel : "
|
msgstr "Solde actuel : "
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
msgid "Remaining account amount: "
|
||||||
msgid "Basket amount: "
|
msgstr "Solde restant : "
|
||||||
msgstr "Valeur du panier : "
|
|
||||||
|
#: 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_main.jinja
|
||||||
msgid "Clear"
|
msgid "Clear"
|
||||||
@ -3815,12 +3831,13 @@ msgstr "Partenariat Eurockéennes 2025"
|
|||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid ""
|
msgid ""
|
||||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||||
"according to it's own privacy policy. By clicking the accept button you "
|
"according to its own privacy policy. By clicking the accept button you "
|
||||||
"consent to their terms of services."
|
"consent to their terms of services."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut collecter des informatinos utilisateur "
|
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||||
"conformément à sa propre politique de confidentialité. En cliquant sur le bouton d'acceptation vous "
|
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||||
"consentez à leurs termes de service."
|
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||||
|
"leurs termes de service."
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "Privacy policy"
|
msgid "Privacy policy"
|
||||||
@ -3852,35 +3869,6 @@ msgstr ""
|
|||||||
msgid "There are no items available for sale"
|
msgid "There are no items available for sale"
|
||||||
msgstr "Aucun article n'est disponible à la vente"
|
msgstr "Aucun article n'est disponible à la vente"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
msgid "Basket state"
|
|
||||||
msgstr "État du panier"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
|
||||||
msgid "Remaining account amount: "
|
|
||||||
msgstr "Solde restant : "
|
|
||||||
|
|
||||||
#: 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
|
#: eboutic/templates/eboutic/eboutic_payment_result.jinja
|
||||||
msgid "Payment successful"
|
msgid "Payment successful"
|
||||||
msgstr "Le paiement a été effectué"
|
msgstr "Le paiement a été effectué"
|
||||||
@ -3893,6 +3881,10 @@ msgstr "Le paiement a échoué"
|
|||||||
msgid "Return to eboutic"
|
msgid "Return to eboutic"
|
||||||
msgstr "Retourner à l'eboutic"
|
msgstr "Retourner à l'eboutic"
|
||||||
|
|
||||||
|
#: eboutic/views.py
|
||||||
|
msgid "Your basket is empty"
|
||||||
|
msgstr "Votre panier est vide"
|
||||||
|
|
||||||
#: eboutic/views.py
|
#: eboutic/views.py
|
||||||
msgid "Billing info registration success"
|
msgid "Billing info registration success"
|
||||||
msgstr "Informations de facturation enregistrées"
|
msgstr "Informations de facturation enregistrées"
|
||||||
@ -3916,6 +3908,10 @@ msgstr ""
|
|||||||
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||||
"données que vous aviez déjà fourni."
|
"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"
|
||||||
|
|
||||||
#: election/models.py
|
#: election/models.py
|
||||||
msgid "start candidature"
|
msgid "start candidature"
|
||||||
msgstr "début des candidatures"
|
msgstr "début des candidatures"
|
||||||
|
@ -37,6 +37,10 @@ msgstr "Supprimer"
|
|||||||
msgid "Copy calendar link"
|
msgid "Copy calendar link"
|
||||||
msgstr "Copier le lien du calendrier"
|
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
|
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||||
msgid "Link copied"
|
msgid "Link copied"
|
||||||
msgstr "Lien copié"
|
msgstr "Lien copié"
|
||||||
|
11
manage.py
11
manage.py
@ -18,10 +18,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||||
|
|
||||||
from sith.composer import start_composer, stop_composer
|
from sith.composer import start_composer, stop_composer
|
||||||
from sith.settings import PROCFILE_SERVICE
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(encoding="utf-8", level=logging.INFO)
|
logging.basicConfig(encoding="utf-8", level=logging.INFO)
|
||||||
@ -30,8 +30,11 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and PROCFILE_SERVICE is not None:
|
if (
|
||||||
start_composer(PROCFILE_SERVICE)
|
os.environ.get(DJANGO_AUTORELOAD_ENV) is None
|
||||||
_ = atexit.register(stop_composer, procfile=PROCFILE_SERVICE)
|
and settings.PROCFILE_SERVICE is not None
|
||||||
|
):
|
||||||
|
start_composer(settings.PROCFILE_SERVICE)
|
||||||
|
_ = atexit.register(stop_composer, procfile=settings.PROCFILE_SERVICE)
|
||||||
|
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
@ -113,9 +113,6 @@ nav:
|
|||||||
- galaxy:
|
- galaxy:
|
||||||
- reference/galaxy/models.md
|
- reference/galaxy/models.md
|
||||||
- reference/galaxy/views.md
|
- reference/galaxy/views.md
|
||||||
- launderette:
|
|
||||||
- reference/launderette/models.md
|
|
||||||
- reference/launderette/views.md
|
|
||||||
- matmat:
|
- matmat:
|
||||||
- reference/matmat/models.md
|
- reference/matmat/models.md
|
||||||
- reference/matmat/views.md
|
- reference/matmat/views.md
|
||||||
|
26
package-lock.json
generated
26
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/sort": "^3.14.7",
|
"@alpinejs/sort": "^3.14.7",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@fullcalendar/core": "^6.1.15",
|
"@fullcalendar/core": "^6.1.15",
|
||||||
"@fullcalendar/daygrid": "^6.1.15",
|
"@fullcalendar/daygrid": "^6.1.15",
|
||||||
@ -2162,6 +2163,31 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
"version": "6.7.2",
|
"version": "6.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpinejs/sort": "^3.14.7",
|
"@alpinejs/sort": "^3.14.7",
|
||||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||||
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@fullcalendar/core": "^6.1.15",
|
"@fullcalendar/core": "^6.1.15",
|
||||||
"@fullcalendar/daygrid": "^6.1.15",
|
"@fullcalendar/daygrid": "^6.1.15",
|
||||||
|
@ -17,15 +17,15 @@ authors = [
|
|||||||
{ name = "Vial", email = "robin.trioux@utbm.fr" },
|
{ name = "Vial", email = "robin.trioux@utbm.fr" },
|
||||||
]
|
]
|
||||||
license = { text = "GPL-3.0-only" }
|
license = { text = "GPL-3.0-only" }
|
||||||
requires-python = "<4.0,>=3.12"
|
requires-python = "<4.0,>=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django<6.0.0,>=5.2.0",
|
"django>=5.2.1,<6.0.0",
|
||||||
"django-ninja<2.0.0,>=1.4.0",
|
"django-ninja<2.0.0,>=1.4.0",
|
||||||
"django-ninja-extra<1.0.0,>=0.22.9",
|
"django-ninja-extra<1.0.0,>=0.22.9",
|
||||||
"Pillow<12.0.0,>=11.1.0",
|
"Pillow<12.0.0,>=11.1.0",
|
||||||
"mistune<4.0.0,>=3.1.3",
|
"mistune<4.0.0,>=3.1.3",
|
||||||
"django-jinja<3.0.0,>=2.11.0",
|
"django-jinja<3.0.0,>=2.11.0",
|
||||||
"cryptography<45.0.0,>=44.0.2",
|
"cryptography>=45.0.3,<46.0.0",
|
||||||
"django-phonenumber-field<9.0.0,>=8.1.0",
|
"django-phonenumber-field<9.0.0,>=8.1.0",
|
||||||
"phonenumbers>=9.0.2,<10.0.0",
|
"phonenumbers>=9.0.2,<10.0.0",
|
||||||
"reportlab<5.0.0,>=4.3.1",
|
"reportlab<5.0.0,>=4.3.1",
|
||||||
@ -39,16 +39,19 @@ dependencies = [
|
|||||||
"jinja2<4.0.0,>=3.1.6",
|
"jinja2<4.0.0,>=3.1.6",
|
||||||
"django-countries<8.0.0,>=7.6.1",
|
"django-countries<8.0.0,>=7.6.1",
|
||||||
"dict2xml<2.0.0,>=1.7.6",
|
"dict2xml<2.0.0,>=1.7.6",
|
||||||
"Sphinx<6,>=5",
|
"Sphinx>=8.2.3,<9",
|
||||||
"tomli<3.0.0,>=2.2.1",
|
"tomli<3.0.0,>=2.2.1",
|
||||||
"django-honeypot",
|
"django-honeypot",
|
||||||
"pydantic-extra-types<3.0.0,>=2.10.3",
|
"pydantic-extra-types<3.0.0,>=2.10.3",
|
||||||
"ical<10.0.0,>=9.1.0",
|
"ical<10.0.0,>=9.1.0",
|
||||||
"redis[hiredis]<6.0.0,>=5.2.1",
|
"redis[hiredis]<6.0.0,>=5.3.0",
|
||||||
"environs[django]<15.0.0,>=14.1.1",
|
"environs[django]<15.0.0,>=14.1.1",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"honcho>=2.0.0",
|
"honcho>=2.0.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
|
"celery[redis]>=5.5.2",
|
||||||
|
"django-celery-results>=2.5.1",
|
||||||
|
"django-celery-beat>=2.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@ -57,13 +60,13 @@ documentation = "https://sith-ae.readthedocs.io/"
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
prod = [
|
prod = [
|
||||||
"psycopg[c]<4.0.0,>=3.2.6",
|
"psycopg[c]>=3.2.9,<4.0.0",
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"django-debug-toolbar<5.0.0,>=4.4.6",
|
"django-debug-toolbar>=5.2.0,<6.0.0",
|
||||||
"ipython<10.0.0,>=9.0.2",
|
"ipython<10.0.0,>=9.0.2",
|
||||||
"pre-commit<5.0.0,>=4.1.0",
|
"pre-commit<5.0.0,>=4.1.0",
|
||||||
"ruff<1.0.0,>=0.11.1",
|
"ruff>=0.11.11,<1.0.0",
|
||||||
"djhtml<4.0.0,>=3.0.7",
|
"djhtml<4.0.0,>=3.0.7",
|
||||||
"faker<38.0.0,>=37.0.0",
|
"faker<38.0.0,>=37.0.0",
|
||||||
"rjsmin<2.0.0,>=1.2.4",
|
"rjsmin<2.0.0,>=1.2.4",
|
||||||
|
@ -27,7 +27,9 @@ class AlbumCreateForm(forms.ModelForm):
|
|||||||
self.instance.moderator = owner
|
self.instance.moderator = owner
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if not self.instance.owner.can_edit(self.instance.parent):
|
parent = self.cleaned_data["parent"]
|
||||||
|
parent.__class__ = Album # by default, parent is a SithFile
|
||||||
|
if not self.instance.owner.can_edit(parent):
|
||||||
raise ValidationError(_("You do not have the permission to do that"))
|
raise ValidationError(_("You do not have the permission to do that"))
|
||||||
return super().clean()
|
return super().clean()
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if is_sas_admin %}
|
{% if album_create_fragment %}
|
||||||
</form>
|
</form>
|
||||||
<br>
|
<br>
|
||||||
{{ album_create_fragment }}
|
{{ album_create_fragment }}
|
||||||
|
@ -15,12 +15,13 @@
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
@ -41,16 +42,37 @@ from sas.models import Album, Picture
|
|||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||||
),
|
),
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
|
lambda: None,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_load_main_page(client: Client, user_factory: Callable[[], User]):
|
def test_load_main_page(client: Client, user_factory: Callable[[], User]):
|
||||||
"""Just check that the SAS doesn't crash."""
|
"""Just check that the SAS doesn't crash."""
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
|
if user is not None:
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("sas:main"))
|
res = client.get(reverse("sas:main"))
|
||||||
assert res.status_code == 200
|
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
|
@pytest.mark.django_db
|
||||||
def test_album_access_non_subscriber(client: Client):
|
def test_album_access_non_subscriber(client: Client):
|
||||||
"""Test that non-subscribers can only access albums where they are identified."""
|
"""Test that non-subscribers can only access albums where they are identified."""
|
||||||
@ -67,6 +89,50 @@ def test_album_access_non_subscriber(client: Client):
|
|||||||
assert res.status_code == 200
|
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):
|
class TestSasModeration(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
@ -65,12 +65,16 @@ class SASMainView(UseFragmentsMixin, TemplateView):
|
|||||||
template_name = "sas/main.jinja"
|
template_name = "sas/main.jinja"
|
||||||
|
|
||||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
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)}
|
form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
|
||||||
return {
|
return {
|
||||||
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
|
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
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)
|
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||||
return {"album_create_fragment": {"owner": root_user}}
|
return {"album_create_fragment": {"owner": root_user}}
|
||||||
|
|
||||||
|
@ -12,3 +12,9 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# 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",)
|
||||||
|
17
sith/celery.py
Normal file
17
sith/celery.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Set the default Django settings module for the 'celery' program.
|
||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sith.settings")
|
||||||
|
|
||||||
|
app = Celery("sith")
|
||||||
|
|
||||||
|
# Using a string here means the worker doesn't have to serialize
|
||||||
|
# the configuration object to child processes.
|
||||||
|
# - namespace='CELERY' means all celery-related configuration keys
|
||||||
|
# should have a `CELERY_` prefix.
|
||||||
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
|
|
||||||
|
# Load task modules from all registered Django apps.
|
||||||
|
app.autodiscover_tasks()
|
@ -47,7 +47,7 @@ from sentry_sdk.integrations.django import DjangoIntegration
|
|||||||
|
|
||||||
from .honeypot import custom_honeypot_error
|
from .honeypot import custom_honeypot_error
|
||||||
|
|
||||||
env = Env()
|
env = Env(expand_vars=True)
|
||||||
env.read_env()
|
env.read_env()
|
||||||
|
|
||||||
|
|
||||||
@ -106,6 +106,8 @@ INSTALLED_APPS = (
|
|||||||
"django_jinja",
|
"django_jinja",
|
||||||
"ninja_extra",
|
"ninja_extra",
|
||||||
"haystack",
|
"haystack",
|
||||||
|
"django_celery_results",
|
||||||
|
"django_celery_beat",
|
||||||
"captcha",
|
"captcha",
|
||||||
"core",
|
"core",
|
||||||
"club",
|
"club",
|
||||||
@ -182,7 +184,6 @@ TEMPLATES = [
|
|||||||
"can_edit": "core.auth.mixins.can_edit",
|
"can_edit": "core.auth.mixins.can_edit",
|
||||||
"can_view": "core.auth.mixins.can_view",
|
"can_view": "core.auth.mixins.can_view",
|
||||||
"settings": "sith.settings",
|
"settings": "sith.settings",
|
||||||
"Launderette": "launderette.models.Launderette",
|
|
||||||
"Counter": "counter.models.Counter",
|
"Counter": "counter.models.Counter",
|
||||||
"timezone": "django.utils.timezone",
|
"timezone": "django.utils.timezone",
|
||||||
"get_sith": "com.views.sith",
|
"get_sith": "com.views.sith",
|
||||||
@ -336,6 +337,14 @@ EMAIL_BACKEND = env.str(
|
|||||||
EMAIL_HOST = env.str("EMAIL_HOST", default="localhost")
|
EMAIL_HOST = env.str("EMAIL_HOST", default="localhost")
|
||||||
EMAIL_PORT = env.int("EMAIL_PORT", default=25)
|
EMAIL_PORT = env.int("EMAIL_PORT", default=25)
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
CELERY_TASK_TIME_LIMIT = 30 * 60
|
||||||
|
CELERY_BROKER_URL = env.str("TASK_BROKER_URL")
|
||||||
|
CELERY_RESULT_BACKEND = "django-db"
|
||||||
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
|
|
||||||
# Below this line, only Sith-specific variables are defined
|
# Below this line, only Sith-specific variables are defined
|
||||||
|
|
||||||
SITH_URL = env.str("SITH_URL", default="127.0.0.1:8000")
|
SITH_URL = env.str("SITH_URL", default="127.0.0.1:8000")
|
||||||
@ -658,10 +667,6 @@ with open(
|
|||||||
) as f:
|
) as f:
|
||||||
SITH_EBOUTIC_PUB_KEY = f.read()
|
SITH_EBOUTIC_PUB_KEY = f.read()
|
||||||
|
|
||||||
# Launderette variables
|
|
||||||
SITH_LAUNDERETTE_MACHINE_TYPES = [("WASHING", _("Washing")), ("DRYING", _("Drying"))]
|
|
||||||
SITH_LAUNDERETTE_PRICES = {"WASHING": 1.0, "DRYING": 0.75}
|
|
||||||
|
|
||||||
SITH_NOTIFICATIONS = [
|
SITH_NOTIFICATIONS = [
|
||||||
("POSTER_MODERATION", _("A new poster needs to be moderated")),
|
("POSTER_MODERATION", _("A new poster needs to be moderated")),
|
||||||
("MAILING_MODERATION", _("A new mailing list needs to be moderated")),
|
("MAILING_MODERATION", _("A new mailing list needs to be moderated")),
|
||||||
@ -718,7 +723,6 @@ if DEBUG:
|
|||||||
"debug_toolbar.panels.headers.HeadersPanel",
|
"debug_toolbar.panels.headers.HeadersPanel",
|
||||||
"debug_toolbar.panels.request.RequestPanel",
|
"debug_toolbar.panels.request.RequestPanel",
|
||||||
"debug_toolbar.panels.sql.SQLPanel",
|
"debug_toolbar.panels.sql.SQLPanel",
|
||||||
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
|
|
||||||
"sith.toolbar_debug.TemplatesPanel",
|
"sith.toolbar_debug.TemplatesPanel",
|
||||||
"debug_toolbar.panels.cache.CachePanel",
|
"debug_toolbar.panels.cache.CachePanel",
|
||||||
"debug_toolbar.panels.signals.SignalsPanel",
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
|
@ -42,10 +42,6 @@ urlpatterns = [
|
|||||||
path("club/", include(("club.urls", "club"), namespace="club")),
|
path("club/", include(("club.urls", "club"), namespace="club")),
|
||||||
path("counter/", include(("counter.urls", "counter"), namespace="counter")),
|
path("counter/", include(("counter.urls", "counter"), namespace="counter")),
|
||||||
path("eboutic/", include(("eboutic.urls", "eboutic"), namespace="eboutic")),
|
path("eboutic/", include(("eboutic.urls", "eboutic"), namespace="eboutic")),
|
||||||
path(
|
|
||||||
"launderette/",
|
|
||||||
include(("launderette.urls", "launderette"), namespace="launderette"),
|
|
||||||
),
|
|
||||||
path("sas/", include(("sas.urls", "sas"), namespace="sas")),
|
path("sas/", include(("sas.urls", "sas"), namespace="sas")),
|
||||||
path("election/", include(("election.urls", "election"), namespace="election")),
|
path("election/", include(("election.urls", "election"), namespace="election")),
|
||||||
path("forum/", include(("forum.urls", "forum"), namespace="forum")),
|
path("forum/", include(("forum.urls", "forum"), namespace="forum")),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user