mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-06 11:15: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
|
||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
||||
TASK_BROKER_URL=redis://127.0.0.1:${REDIS_PORT}/1
|
||||
|
||||
# Used to select which other services to run alongside
|
||||
# manage.py, pytest and runserver
|
||||
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -11,6 +11,7 @@ env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
TASK_BROKER_URL: redis://127.0.0.1:6379/1
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
|
@ -1,10 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.4
|
||||
rev: v0.11.11
|
||||
hooks:
|
||||
- id: ruff # just check the code, and print the errors
|
||||
- id: ruff # actually fix the fixable errors, but print nothing
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
args: ["--fix", "--silent"]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
|
@ -1 +1 @@
|
||||
3.12
|
||||
3.13
|
@ -1 +1,2 @@
|
||||
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.conf import settings
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDate, SelectDateTime
|
||||
from core.views.widgets.ajax_select import AutoCompleteSelectMultipleUser
|
||||
from counter.models import Counter
|
||||
from counter.models import Counter, Selling
|
||||
|
||||
|
||||
class ClubEditForm(forms.ModelForm):
|
||||
@ -159,12 +161,20 @@ class SellingsForm(forms.Form):
|
||||
label=_("End date"), widget=SelectDateTime, required=False
|
||||
)
|
||||
|
||||
counters = forms.ModelMultipleChoiceField(
|
||||
Counter.objects.order_by("name").all(), label=_("Counter"), required=False
|
||||
)
|
||||
|
||||
def __init__(self, club, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
counters_qs = (
|
||||
Counter.objects.filter(
|
||||
Q(club=club)
|
||||
| Q(products__club=club)
|
||||
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Lower("name"))
|
||||
)
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
self.fields["products"] = forms.ModelMultipleChoiceField(
|
||||
club.products.order_by("name").filter(archived=False).all(),
|
||||
label=_("Products"),
|
||||
|
@ -16,22 +16,13 @@
|
||||
</ul>
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
||||
{% for l in Launderette.objects.all() %}
|
||||
<li><a href="{{ url('launderette:main_click', launderette_id=l.id) }}">{{ l }}</a></li>
|
||||
{% endfor %}
|
||||
{% elif object.counters.filter(type="OFFICE")|count > 0 %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if object.id == settings.SITH_LAUNDERETTE_CLUB_ID %}
|
||||
<li><a href="{{ url('launderette:launderette_list') }}">{% trans %}Manage launderettes{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -3,8 +3,11 @@ from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import SellingsForm
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
from counter.models import Counter, Customer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -14,3 +17,22 @@ def test_sales_page_doesnt_crash(client: Client):
|
||||
client.force_login(admin)
|
||||
response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sales_form_counter_filter():
|
||||
"""Test that counters are properly filtered in SellingsForm"""
|
||||
club = baker.make(Club)
|
||||
counters = baker.make(
|
||||
Counter, _quantity=5, _bulk_create=True, name=iter(["Z", "a", "B", "e", "f"])
|
||||
)
|
||||
counters[0].club = club
|
||||
counters[0].save()
|
||||
sale_recipe.make(
|
||||
counter=counters[1], club=club, unit_price=0, customer=baker.make(Customer)
|
||||
)
|
||||
product_recipe.make(counters=[counters[2]], club=club)
|
||||
|
||||
form = SellingsForm(club)
|
||||
form_counters = list(form.fields["counters"].queryset)
|
||||
assert form_counters == [counters[1], counters[2], counters[0]]
|
||||
|
@ -17,11 +17,12 @@ import {
|
||||
|
||||
@registerComponent("ics-calendar")
|
||||
export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
static observedAttributes = ["locale", "can_moderate", "can_delete"];
|
||||
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
|
||||
private calendar: Calendar;
|
||||
private locale = "en";
|
||||
private canModerate = false;
|
||||
private canDelete = false;
|
||||
private helpUrl = "";
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||
if (name === "locale") {
|
||||
@ -33,6 +34,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (name === "can_delete") {
|
||||
this.canDelete = newValue.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
if (name === "ics-help-url") {
|
||||
this.helpUrl = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
isMobile() {
|
||||
@ -48,11 +53,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (this.isMobile()) {
|
||||
return {
|
||||
start: "",
|
||||
center: "getCalendarLink",
|
||||
center: "getCalendarLink helpButton",
|
||||
end: "",
|
||||
};
|
||||
}
|
||||
return { start: "getCalendarLink", center: "", end: "" };
|
||||
return { start: "getCalendarLink helpButton", center: "", end: "" };
|
||||
}
|
||||
|
||||
currentHeaderToolbar() {
|
||||
@ -87,15 +92,8 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
);
|
||||
}
|
||||
|
||||
async refreshEvents() {
|
||||
refreshEvents() {
|
||||
this.click(); // Remove focus from popup
|
||||
// We can't just refresh events because some ics files are in
|
||||
// local browser cache (especially internal.ics)
|
||||
// To invalidate the cache, we need to remove the source and add it again
|
||||
this.calendar.removeAllEventSources();
|
||||
for (const source of await this.getEventSources()) {
|
||||
this.calendar.addEventSource(source);
|
||||
}
|
||||
this.calendar.refetchEvents();
|
||||
}
|
||||
|
||||
@ -114,7 +112,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async unpublishNews(id: number) {
|
||||
@ -132,7 +130,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async deleteNews(id: number) {
|
||||
@ -150,22 +148,23 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
},
|
||||
}),
|
||||
);
|
||||
await this.refreshEvents();
|
||||
this.refreshEvents();
|
||||
}
|
||||
|
||||
async getEventSources() {
|
||||
const cacheInvalidate = `?invalidate=${Date.now()}`;
|
||||
return [
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarInternal)}${cacheInvalidate}`,
|
||||
url: `${await makeUrl(calendarCalendarInternal)}`,
|
||||
format: "ics",
|
||||
className: "internal",
|
||||
cache: false,
|
||||
},
|
||||
{
|
||||
url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`,
|
||||
url: `${await makeUrl(calendarCalendarUnpublished)}`,
|
||||
format: "ics",
|
||||
color: "red",
|
||||
className: "unpublished",
|
||||
cache: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -320,14 +319,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
click: async (event: Event) => {
|
||||
const button = event.target as HTMLButtonElement;
|
||||
button.classList.add("text-copy");
|
||||
if (!button.hasAttribute("position")) {
|
||||
button.setAttribute("tooltip", gettext("Link copied"));
|
||||
button.setAttribute("position", "top");
|
||||
button.setAttribute("no-hover", "");
|
||||
button.setAttribute("tooltip-class", "calendar-copy-tooltip");
|
||||
if (!button.hasAttribute("tooltip-position")) {
|
||||
button.setAttribute("tooltip-position", "top");
|
||||
}
|
||||
if (button.classList.contains("text-copied")) {
|
||||
button.classList.remove("text-copied");
|
||||
}
|
||||
button.setAttribute("tooltip", gettext("Link copied"));
|
||||
navigator.clipboard.writeText(
|
||||
new URL(
|
||||
await makeUrl(calendarCalendarInternal),
|
||||
@ -335,12 +334,22 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
).toString(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied");
|
||||
button.classList.remove("text-copied");
|
||||
button.classList.add("text-copied");
|
||||
button.classList.remove("text-copy");
|
||||
}, 1500);
|
||||
},
|
||||
},
|
||||
helpButton: {
|
||||
text: "?",
|
||||
hint: gettext("How to use calendar link"),
|
||||
click: () => {
|
||||
if (this.helpUrl) {
|
||||
window.open(this.helpUrl, "_blank");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
height: "auto",
|
||||
locale: this.locale,
|
||||
@ -348,6 +357,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
headerToolbar: this.currentHeaderToolbar(),
|
||||
footerToolbar: this.currentFooterToolbar(),
|
||||
eventSources: await this.getEventSources(),
|
||||
lazyFetching: false,
|
||||
windowResize: () => {
|
||||
this.calendar.changeView(this.currentView());
|
||||
this.calendar.setOption("headerToolbar", this.currentHeaderToolbar());
|
||||
|
@ -1,4 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
@import "core/static/core/tooltips";
|
||||
|
||||
|
||||
:root {
|
||||
@ -116,8 +117,33 @@ ics-calendar {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
button.text-copied[tooltip]::before {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
.fc .fc-getCalendarLink-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fc .fc-helpButton-button {
|
||||
border-radius: 70%;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transition: 100ms ease-out;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip.text-copied {
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
@ -192,6 +192,7 @@
|
||||
@calendar-delete="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.DELETED})"
|
||||
@calendar-unpublish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PENDING})"
|
||||
@calendar-publish="$dispatch('news-moderated', {newsId: $event.detail.id, state: AlertState.PUBLISHED})"
|
||||
ics-help-url="{{ url('core:page', page_name='Index/calendrier')}}"
|
||||
locale="{{ get_language() }}"
|
||||
can_moderate="{{ user.has_perm("com.moderate_news") }}"
|
||||
can_delete="{{ user.has_perm("com.delete_news") }}"
|
||||
|
@ -32,7 +32,6 @@ class SithConfig(AppConfig):
|
||||
verbose_name = "Core app of the Sith"
|
||||
|
||||
def ready(self):
|
||||
import core.signals # noqa F401
|
||||
from forum.models import Forum
|
||||
|
||||
cache.clear()
|
||||
|
@ -123,11 +123,6 @@ class Command(BaseCommand):
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
Club.objects.create(
|
||||
id=settings.SITH_LAUNDERETTE_CLUB_ID,
|
||||
name="Laverie",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
|
||||
self.reset_index("club")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||
@ -291,13 +286,7 @@ class Command(BaseCommand):
|
||||
page=services_page,
|
||||
title="Services",
|
||||
author=skia,
|
||||
content="""
|
||||
| | | |
|
||||
| :---: | :---: | :---: |
|
||||
| [Eboutic](/eboutic) | [Laverie](/launderette) | Matmat |
|
||||
| SAS | Weekmail | Forum|
|
||||
|
||||
""",
|
||||
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
|
||||
)
|
||||
|
||||
index_page = Page(name="Index")
|
||||
@ -306,23 +295,10 @@ class Command(BaseCommand):
|
||||
page=index_page,
|
||||
title="Wiki index",
|
||||
author=root,
|
||||
content="""
|
||||
Welcome to the wiki page!
|
||||
""",
|
||||
content="Welcome to the wiki page!",
|
||||
)
|
||||
|
||||
laundry_page = Page(name="launderette")
|
||||
laundry_page.save(force_lock=True)
|
||||
PageRev.objects.create(
|
||||
page=laundry_page,
|
||||
title="Laverie",
|
||||
author=root,
|
||||
content="Fonctionnement de la laverie",
|
||||
)
|
||||
|
||||
groups.public.viewable_page.set(
|
||||
[syntax_page, services_page, index_page, laundry_page]
|
||||
)
|
||||
groups.public.viewable_page.set([syntax_page, services_page, index_page])
|
||||
|
||||
self._create_subscription(root)
|
||||
self._create_subscription(skia)
|
||||
@ -868,7 +844,7 @@ Welcome to the wiki page!
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(content_type__app_label__in=["counter", "launderette"])
|
||||
Q(content_type__app_label__in=["counter"])
|
||||
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
||||
)
|
||||
)
|
||||
|
@ -396,19 +396,10 @@ class User(AbstractUser):
|
||||
return self.is_root
|
||||
return group in self.cached_groups
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def cached_groups(self) -> list[Group]:
|
||||
"""Get the list of groups this user is in.
|
||||
|
||||
The result is cached for the default duration (should be 5 minutes)
|
||||
|
||||
Returns: A list of all the groups this user is in.
|
||||
"""
|
||||
groups = cache.get(f"user_{self.id}_groups")
|
||||
if groups is None:
|
||||
groups = list(self.groups.all())
|
||||
cache.set(f"user_{self.id}_groups", groups)
|
||||
return groups
|
||||
"""Get the list of groups this user is in."""
|
||||
return list(self.groups.all())
|
||||
|
||||
@cached_property
|
||||
def is_root(self) -> bool:
|
||||
@ -421,14 +412,6 @@ class User(AbstractUser):
|
||||
def is_board_member(self) -> bool:
|
||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def is_launderette_manager(self):
|
||||
from club.models import Club
|
||||
|
||||
return Club.objects.get(
|
||||
id=settings.SITH_LAUNDERETTE_CLUB_ID
|
||||
).get_membership_for(self)
|
||||
|
||||
@cached_property
|
||||
def is_banned_alcohol(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||
@ -676,10 +659,6 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
def is_board_member(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_launderette_manager(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_banned_alcohol(self):
|
||||
return False
|
||||
|
@ -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 {
|
||||
display: inline-block;
|
||||
padding: 1px;
|
||||
@ -822,12 +765,6 @@ textarea {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/*---------------------------LAUNDERETTE-------------------------------*/
|
||||
|
||||
#token_form label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/*--------------------------------FOOTER-------------------------------*/
|
||||
|
||||
footer {
|
||||
|
26
core/static/core/tooltips.scss
Normal file
26
core/static/core/tooltips.scss
Normal file
@ -0,0 +1,26 @@
|
||||
@import "colors";
|
||||
|
||||
.tooltip {
|
||||
@include shadow;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 0.5px solid hsl(0, 0%, 50%);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
|
||||
white-space: normal;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.tooltip[tooltip-status=open] {
|
||||
opacity: 1;
|
||||
transition: opacity 500ms ease-in;
|
||||
}
|
@ -24,13 +24,21 @@ body {
|
||||
background-color: white;
|
||||
margin: 0;
|
||||
|
||||
> .title {
|
||||
.alert {
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
>.title {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> div,
|
||||
> form {
|
||||
>div,
|
||||
>form {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -41,8 +49,8 @@ body {
|
||||
max-width: 500px;
|
||||
margin-top: 20px;
|
||||
|
||||
> p,
|
||||
> div {
|
||||
>p,
|
||||
>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@ -50,7 +58,7 @@ body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
> label {
|
||||
>label {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 500px) {
|
||||
@ -59,9 +67,9 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
> input,
|
||||
> p > input,
|
||||
> div > input {
|
||||
>input,
|
||||
>p>input,
|
||||
>div>input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
@ -71,35 +79,35 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
> .errorlist {
|
||||
>.errorlist {
|
||||
color: red;
|
||||
text-align: center;
|
||||
margin: 10px 0 0 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
> .required > .helptext {
|
||||
>.required>.helptext {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
> .required:last-of-type {
|
||||
>.required:last-of-type {
|
||||
box-sizing: border-box;
|
||||
max-width: 300px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
> label {
|
||||
>label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> img {
|
||||
>img {
|
||||
width: 70px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> input {
|
||||
>input {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
||||
@ -24,6 +25,7 @@
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
|
||||
|
||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||
|
@ -24,7 +24,6 @@
|
||||
<span class="head">{% trans %}Services{% endtrans %}</span>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
||||
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||
</ul>
|
||||
|
@ -29,14 +29,13 @@
|
||||
<form method="post" action="{{ url('core:login') }}">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||
<br>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
|
||||
<input id="id_username" maxlength="254" name="username" type="text" autofocus="autofocus" />
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
|
||||
|
@ -86,19 +86,6 @@
|
||||
{% trans %}Account number: {% endtrans %}{{ user.customer.account_id }}<br/>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro show_slots(user) %}
|
||||
{% if user.slots.filter(start_date__gte=timezone.now()).exists() %}
|
||||
<h5>{% trans %}Slot{% endtrans %}</h5>
|
||||
<ul>
|
||||
{% for i in user.slots.filter(start_date__gte=timezone.now().replace(tzinfo=None)).all() %}
|
||||
<li>{{ i.get_type_display() }} - {{i.machine.launderette }}, {{ i.start_date|date("l j") }} :
|
||||
{{ i.start_date|time(DATETIME_FORMAT) }} |
|
||||
<a href="{{ url('launderette:delete_slot', slot_id=i.id) }}">{% trans %}Delete{% endtrans %}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro show_tokens(user) %}
|
||||
{% if user.tokens.exists() %}
|
||||
<h5>{% trans %}Tokens{% endtrans %}</h5>
|
||||
|
@ -18,7 +18,7 @@
|
||||
<form action="{{ url('core:register') }}" method="post">
|
||||
{% csrf_token %}
|
||||
{% render_honeypot_field %}
|
||||
{{ form }}
|
||||
{{ form.as_p() }}
|
||||
<input type="submit" value="{% trans %}Register{% endtrans %}" />
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
@ -141,12 +141,6 @@
|
||||
{{ user_subscription(profile) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user == profile or user.is_root or user.is_board_member or user.is_launderette_manager %}
|
||||
<div>
|
||||
{{ show_tokens(profile) }}
|
||||
{{ show_slots(profile) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>
|
||||
{% trans %}Not subscribed{% endtrans %}
|
||||
|
@ -411,10 +411,11 @@ class TestUserIsInGroup(TestCase):
|
||||
"""Test that the number of db queries is stable
|
||||
and that less queries are made when making a new call.
|
||||
"""
|
||||
# make sure Skia is in at least one group
|
||||
group_in = baker.make(Group)
|
||||
self.public_user.groups.add(group_in)
|
||||
|
||||
# clear the cached property `User.cached_groups`
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
cache.clear()
|
||||
# Test when the user is in the group
|
||||
with self.assertNumQueries(2):
|
||||
@ -423,6 +424,7 @@ class TestUserIsInGroup(TestCase):
|
||||
self.public_user.is_in_group(pk=group_in.id)
|
||||
|
||||
group_not_in = baker.make(Group)
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
cache.clear()
|
||||
# Test when the user is not in the group
|
||||
with self.assertNumQueries(2):
|
||||
@ -447,24 +449,6 @@ class TestUserIsInGroup(TestCase):
|
||||
)
|
||||
assert cached_membership == "not_member"
|
||||
|
||||
def test_cache_properly_cleared_group(self):
|
||||
"""Test that when a user is removed from a group,
|
||||
the is_in_group_method return False when calling it again.
|
||||
"""
|
||||
# testing with pk
|
||||
self.public_user.groups.add(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
||||
|
||||
self.public_user.groups.remove(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
||||
|
||||
# testing with name
|
||||
self.public_user.groups.add(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is True
|
||||
|
||||
self.public_user.groups.remove(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is False
|
||||
|
||||
def test_not_existing_group(self):
|
||||
"""Test that searching for a not existing group
|
||||
returns False.
|
||||
|
@ -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
|
||||
tabs = [tab["slug"] for tab in view.get_list_of_tabs()]
|
||||
assert tabs == expected_tabs
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRedirectMe:
|
||||
@pytest.mark.parametrize(
|
||||
"route", ["core:user_profile", "core:user_account", "core:user_edit"]
|
||||
)
|
||||
def test_redirect(self, client: Client, route: str):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
target_url = reverse(route, kwargs={"user_id": user.id})
|
||||
src_url = target_url.replace(str(user.id), "me")
|
||||
assertRedirects(client.get(src_url), target_url)
|
||||
|
||||
def test_anonymous_user(self, client: Client):
|
||||
url = reverse("core:user_me_redirect")
|
||||
assertRedirects(client.get(url), reverse("core:login", query={"next": url}))
|
||||
|
@ -21,7 +21,6 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
|
||||
from django.urls import path, re_path, register_converter
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
@ -68,6 +67,7 @@ from core.views import (
|
||||
UserGodfathersTreeView,
|
||||
UserGodfathersView,
|
||||
UserListView,
|
||||
UserMeRedirect,
|
||||
UserMiniView,
|
||||
UserPreferencesView,
|
||||
UserStatsView,
|
||||
@ -141,6 +141,12 @@ urlpatterns = [
|
||||
),
|
||||
# User views
|
||||
path("user/", UserListView.as_view(), name="user_list"),
|
||||
path(
|
||||
"user/me/<path:remaining_path>/",
|
||||
UserMeRedirect.as_view(),
|
||||
name="user_me_redirect_with_path",
|
||||
),
|
||||
path("user/me/", UserMeRedirect.as_view(), name="user_me_redirect"),
|
||||
path("user/<int:user_id>/mini/", UserMiniView.as_view(), name="user_profile_mini"),
|
||||
path("user/<int:user_id>/", UserView.as_view(), name="user_profile"),
|
||||
path(
|
||||
|
@ -48,6 +48,7 @@ from django.views.generic import (
|
||||
DeleteView,
|
||||
DetailView,
|
||||
ListView,
|
||||
RedirectView,
|
||||
TemplateView,
|
||||
)
|
||||
from django.views.generic.dates import MonthMixin, YearMixin
|
||||
@ -182,6 +183,13 @@ class UserCreationView(FormView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class UserMeRedirect(LoginRequiredMixin, RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
if remaining := kwargs.get("remaining_path"):
|
||||
return f"/user/{self.request.user.id}/{remaining}/"
|
||||
return f"/user/{self.request.user.id}/"
|
||||
|
||||
|
||||
class UserTabsMixin(TabedViewMixin):
|
||||
def get_tabs_title(self):
|
||||
return self.object.get_display_name()
|
||||
@ -564,10 +572,8 @@ class UserToolsView(LoginRequiredMixin, QuickNotifMixin, UserTabsMixin, Template
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object = self.request.user
|
||||
from launderette.models import Launderette
|
||||
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["launderettes"] = Launderette.objects.all()
|
||||
kwargs["profile"] = self.request.user
|
||||
kwargs["object"] = self.request.user
|
||||
return kwargs
|
||||
|
112
counter/forms.py
112
counter/forms.py
@ -1,4 +1,7 @@
|
||||
import math
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
@ -261,3 +264,112 @@ class CloseCustomerAccountForm(forms.Form):
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class ProductForm(forms.Form):
|
||||
quantity = forms.IntegerField(min_value=1, required=True)
|
||||
id = forms.IntegerField(min_value=0, required=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise forms.ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
self.forms = [form for form in self.forms if form.cleaned_data != {}]
|
||||
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise forms.ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise forms.ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise forms.ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
@ -47,6 +47,10 @@ from counter.fields import CurrencyField
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
def get_eboutic() -> Counter:
|
||||
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
|
||||
|
||||
|
||||
class CustomerQuerySet(models.QuerySet):
|
||||
def update_amount(self) -> int:
|
||||
"""Update the amount of all customers selected by this queryset.
|
||||
|
@ -46,6 +46,15 @@ from counter.models import (
|
||||
)
|
||||
|
||||
|
||||
def set_age(user: User, age: int):
|
||||
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||
user.save()
|
||||
|
||||
|
||||
def force_refill_user(user: User, amount: Decimal | int):
|
||||
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
|
||||
|
||||
|
||||
class TestFullClickBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -226,11 +235,11 @@ class TestCounterClick(TestFullClickBase):
|
||||
cls.banned_counter_customer = subscriber_user.make()
|
||||
cls.banned_alcohol_customer = subscriber_user.make()
|
||||
|
||||
cls.set_age(cls.customer, 20)
|
||||
cls.set_age(cls.barmen, 20)
|
||||
cls.set_age(cls.club_admin, 20)
|
||||
cls.set_age(cls.banned_alcohol_customer, 20)
|
||||
cls.set_age(cls.underage_customer, 17)
|
||||
set_age(cls.customer, 20)
|
||||
set_age(cls.barmen, 20)
|
||||
set_age(cls.club_admin, 20)
|
||||
set_age(cls.banned_alcohol_customer, 20)
|
||||
set_age(cls.underage_customer, 17)
|
||||
|
||||
cls.banned_alcohol_customer.ban_groups.add(
|
||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
@ -278,11 +287,6 @@ class TestCounterClick(TestFullClickBase):
|
||||
{"username": used_barman.username, "password": "plop"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_age(cls, user: User, age: int):
|
||||
user.date_of_birth = localdate().replace(year=localdate().year - age)
|
||||
user.save()
|
||||
|
||||
def submit_basket(
|
||||
self,
|
||||
user: User,
|
||||
@ -306,9 +310,6 @@ class TestCounterClick(TestFullClickBase):
|
||||
data,
|
||||
)
|
||||
|
||||
def refill_user(self, user: User, amount: Decimal | int):
|
||||
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
|
||||
|
||||
def test_click_eboutic_failure(self):
|
||||
eboutic = baker.make(Counter, type="EBOUTIC")
|
||||
self.client.force_login(self.club_admin)
|
||||
@ -318,7 +319,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert res.status_code == 404
|
||||
|
||||
def test_click_office_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.client.force_login(self.club_admin)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.stamps.id, 5)], counter=self.club_counter
|
||||
@ -327,7 +328,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||
|
||||
# Test no special price on office counter
|
||||
self.refill_user(self.club_admin, 10)
|
||||
force_refill_user(self.club_admin, 10)
|
||||
res = self.submit_basket(
|
||||
self.club_admin, [BasketItem(self.stamps.id, 1)], counter=self.club_counter
|
||||
)
|
||||
@ -336,7 +337,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||
|
||||
def test_click_bar_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
|
||||
@ -347,7 +348,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
# Test barmen special price
|
||||
|
||||
self.refill_user(self.barmen, 10)
|
||||
force_refill_user(self.barmen, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
||||
@ -356,7 +357,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.barmen) == Decimal("9")
|
||||
|
||||
def test_click_tray_price(self):
|
||||
self.refill_user(self.customer, 20)
|
||||
force_refill_user(self.customer, 20)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
# Not applying tray price
|
||||
@ -373,7 +374,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.login_in_bar()
|
||||
|
||||
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
||||
self.refill_user(user, 10)
|
||||
force_refill_user(user, 10)
|
||||
|
||||
# Buy product without age limit
|
||||
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
@ -394,7 +395,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
self.banned_counter_customer,
|
||||
self.customer_old_can_not_buy,
|
||||
]:
|
||||
self.refill_user(user, 10)
|
||||
force_refill_user(user, 10)
|
||||
resp = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
|
||||
assert resp.status_code == 302
|
||||
assert resp.url == resolve_url(self.counter)
|
||||
@ -410,7 +411,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_allowed_old_subscriber(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer_old_can_buy, 10)
|
||||
force_refill_user(self.customer_old_can_buy, 10)
|
||||
res = self.submit_basket(
|
||||
self.customer_old_can_buy, [BasketItem(self.snack.id, 2)]
|
||||
)
|
||||
@ -420,7 +421,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
|
||||
def test_click_wrong_counter(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
res = self.submit_basket(
|
||||
self.customer, [BasketItem(self.snack.id, 2)], counter=self.other_counter
|
||||
)
|
||||
@ -444,7 +445,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_connected(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
|
||||
assertRedirects(res, self.counter.get_absolute_url())
|
||||
|
||||
@ -456,15 +457,29 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_not_in_counter(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
|
||||
assert res.status_code == 200
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_basket_empty(self):
|
||||
force_refill_user(self.customer, 10)
|
||||
|
||||
for basket in [
|
||||
[],
|
||||
[BasketItem(None, None)],
|
||||
[BasketItem(None, None), BasketItem(None, None)],
|
||||
]:
|
||||
assertRedirects(
|
||||
self.submit_basket(self.customer, basket),
|
||||
self.counter.get_absolute_url(),
|
||||
)
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_invalid(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
for item in [
|
||||
@ -472,14 +487,12 @@ class TestCounterClick(TestFullClickBase):
|
||||
BasketItem(self.beer.id, -1),
|
||||
BasketItem(None, 1),
|
||||
BasketItem(self.beer.id, None),
|
||||
BasketItem(None, None),
|
||||
]:
|
||||
assert self.submit_basket(self.customer, [item]).status_code == 200
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_enough_money(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
force_refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
res = self.submit_basket(
|
||||
self.customer,
|
||||
@ -509,7 +522,7 @@ class TestCounterClick(TestFullClickBase):
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_recordings(self):
|
||||
self.refill_user(self.customer, self.cons.selling_price * 3)
|
||||
force_refill_user(self.customer, self.cons.selling_price * 3)
|
||||
self.login_in_bar(self.barmen)
|
||||
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
|
||||
assert res.status_code == 302
|
||||
|
@ -12,18 +12,17 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from datetime import datetime
|
||||
from datetime import timezone as tz
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.http import Http404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic.edit import UpdateView
|
||||
from django.views.generic.edit import BaseFormView, UpdateView
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from counter.forms import CashSummaryFormBase
|
||||
from counter.models import (
|
||||
CashRegisterSummary,
|
||||
@ -31,9 +30,7 @@ from counter.models import (
|
||||
Counter,
|
||||
Refilling,
|
||||
)
|
||||
from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import (
|
||||
CounterAdminMixin,
|
||||
CounterAdminTabsMixin,
|
||||
CounterTabsMixin,
|
||||
)
|
||||
@ -157,11 +154,9 @@ class CashRegisterSummaryForm(forms.Form):
|
||||
else:
|
||||
self.instance = None
|
||||
|
||||
def save(self, counter=None):
|
||||
def save(self, counter: Counter | None = None, user: User | None = None):
|
||||
cd = self.cleaned_data
|
||||
summary = self.instance or CashRegisterSummary(
|
||||
counter=counter, user=counter.get_random_barman()
|
||||
)
|
||||
summary = self.instance or CashRegisterSummary(counter=counter, user=user)
|
||||
summary.comment = cd["comment"]
|
||||
summary.emptied = cd["emptied"]
|
||||
summary.save()
|
||||
@ -247,48 +242,33 @@ class CashRegisterSummaryForm(forms.Form):
|
||||
summary.delete()
|
||||
|
||||
|
||||
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
class CounterCashSummaryView(
|
||||
CounterTabsMixin, PermissionRequiredMixin, BaseFormView, DetailView
|
||||
):
|
||||
"""Provide the cash summary form."""
|
||||
|
||||
model = Counter
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "counter/cash_register_summary.jinja"
|
||||
current_tab = "cash_summary"
|
||||
permission_required = "counter.add_cashregistersummary"
|
||||
form_class = CashRegisterSummaryForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""We have here again a very particular right handling."""
|
||||
self.object = self.get_object()
|
||||
if is_logged_in_counter(request) and self.object.barmen_list:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return HttpResponseRedirect(
|
||||
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||
+ "?bad_location"
|
||||
)
|
||||
if self.object.type != "BAR":
|
||||
raise Http404
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = CashRegisterSummaryForm()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.form = CashRegisterSummaryForm(request.POST)
|
||||
if self.form.is_valid():
|
||||
self.form.save(self.object)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
return super().get(request, *args, **kwargs)
|
||||
def form_valid(self, form):
|
||||
form.save(counter=self.object, user=self.request.user)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("counter:details", kwargs={"counter_id": self.object.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add form to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["form"] = self.form
|
||||
return kwargs
|
||||
return reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||
|
||||
|
||||
class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
class CashSummaryEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
|
||||
"""Edit cash summaries."""
|
||||
|
||||
model = CashRegisterSummary
|
||||
@ -297,12 +277,11 @@ class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
pk_url_kwarg = "cashsummary_id"
|
||||
form_class = CashRegisterSummaryForm
|
||||
current_tab = "cash_summary"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("counter:cash_summary_list")
|
||||
permission_required = "counter.change_cashregistersummary"
|
||||
success_url = reverse_lazy("counter:cash_summary_list")
|
||||
|
||||
|
||||
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
class CashSummaryListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
|
||||
"""Display a list of cash summaries."""
|
||||
|
||||
model = CashRegisterSummary
|
||||
@ -311,6 +290,7 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
current_tab = "cash_summary"
|
||||
queryset = CashRegisterSummary.objects.all().order_by("-date")
|
||||
paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
|
||||
permission_required = "counter.view_cashregistersummary"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add sums to the context."""
|
||||
@ -321,12 +301,12 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
kwargs["refilling_sums"] = {}
|
||||
for c in Counter.objects.filter(type="BAR").all():
|
||||
refillings = Refilling.objects.filter(counter=c)
|
||||
cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)
|
||||
cash_register_summaries = CashRegisterSummary.objects.filter(counter=c)
|
||||
if form.is_valid() and form.cleaned_data["begin_date"]:
|
||||
refillings = refillings.filter(
|
||||
date__gte=form.cleaned_data["begin_date"]
|
||||
)
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__gte=form.cleaned_data["begin_date"]
|
||||
)
|
||||
else:
|
||||
@ -337,23 +317,16 @@ class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
)
|
||||
if last_summary:
|
||||
refillings = refillings.filter(date__gt=last_summary.date)
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__gt=last_summary.date
|
||||
)
|
||||
else:
|
||||
refillings = refillings.filter(
|
||||
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||
) # My birth date should be old enough
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||
)
|
||||
if form.is_valid() and form.cleaned_data["end_date"]:
|
||||
refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
|
||||
cashredistersummaries = cashredistersummaries.filter(
|
||||
cash_register_summaries = cash_register_summaries.filter(
|
||||
date__lte=form.cleaned_data["end_date"]
|
||||
)
|
||||
kwargs["summaries_sums"][c.name] = sum(
|
||||
[s.get_total() for s in cashredistersummaries.all()]
|
||||
[s.get_total() for s in cash_register_summaries.all()]
|
||||
)
|
||||
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
|
||||
return kwargs
|
||||
|
@ -12,23 +12,14 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import math
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.forms import (
|
||||
BaseFormSet,
|
||||
Form,
|
||||
IntegerField,
|
||||
ValidationError,
|
||||
formset_factory,
|
||||
)
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
@ -36,11 +27,10 @@ from ninja.main import HttpRequest
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.models import User
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import RefillForm
|
||||
from counter.forms import BasketForm, RefillForm
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
ReturnableProduct,
|
||||
Selling,
|
||||
)
|
||||
@ -57,113 +47,6 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
|
||||
return counter.get_random_barman()
|
||||
|
||||
|
||||
class ProductForm(Form):
|
||||
quantity = IntegerField(min_value=1)
|
||||
id = IntegerField(min_value=0)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer: Customer,
|
||||
counter: Counter,
|
||||
allowed_products: dict[int, Product],
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
self.customer = customer # Used by formset
|
||||
self.counter = counter # Used by formset
|
||||
self.allowed_products = allowed_products
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_id(self):
|
||||
data = self.cleaned_data["id"]
|
||||
|
||||
# We store self.product so we can use it later on the formset validation
|
||||
# And also in the global clean
|
||||
self.product = self.allowed_products.get(data, None)
|
||||
if self.product is None:
|
||||
raise ValidationError(
|
||||
_("The selected product isn't available for this user")
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if len(self.errors) > 0:
|
||||
return
|
||||
|
||||
# Compute prices
|
||||
cleaned_data["bonus_quantity"] = 0
|
||||
if self.product.tray:
|
||||
cleaned_data["bonus_quantity"] = math.floor(
|
||||
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
|
||||
)
|
||||
cleaned_data["total_price"] = self.product.price * (
|
||||
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class BaseBasketForm(BaseFormSet):
|
||||
def clean(self):
|
||||
if len(self.forms) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
self._check_product_are_unique()
|
||||
self._check_recorded_products(self[0].customer)
|
||||
self._check_enough_money(self[0].counter, self[0].customer)
|
||||
|
||||
def _check_forms_have_errors(self):
|
||||
if any(len(form.errors) > 0 for form in self):
|
||||
raise ValidationError(_("Submitted basket is invalid"))
|
||||
|
||||
def _check_product_are_unique(self):
|
||||
product_ids = {form.cleaned_data["id"] for form in self.forms}
|
||||
if len(product_ids) != len(self.forms):
|
||||
raise ValidationError(_("Duplicated product entries."))
|
||||
|
||||
def _check_enough_money(self, counter: Counter, customer: Customer):
|
||||
self.total_price = sum([data["total_price"] for data in self.cleaned_data])
|
||||
if self.total_price > customer.amount:
|
||||
raise ValidationError(_("Not enough money"))
|
||||
|
||||
def _check_recorded_products(self, customer: Customer):
|
||||
"""Check for, among other things, ecocups and pitchers"""
|
||||
items = {
|
||||
form.cleaned_data["id"]: form.cleaned_data["quantity"]
|
||||
for form in self.forms
|
||||
}
|
||||
ids = list(items.keys())
|
||||
returnables = list(
|
||||
ReturnableProduct.objects.filter(
|
||||
Q(product_id__in=ids) | Q(returned_product_id__in=ids)
|
||||
).annotate_balance_for(customer)
|
||||
)
|
||||
limit_reached = []
|
||||
for returnable in returnables:
|
||||
returnable.balance += items.get(returnable.product_id, 0)
|
||||
for returnable in returnables:
|
||||
dcons = items.get(returnable.returned_product_id, 0)
|
||||
returnable.balance -= dcons
|
||||
if dcons and returnable.balance < -returnable.max_return:
|
||||
limit_reached.append(returnable.returned_product)
|
||||
if limit_reached:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This user have reached his recording limit "
|
||||
"for the following products : %s"
|
||||
)
|
||||
% ", ".join([str(p) for p in limit_reached])
|
||||
)
|
||||
|
||||
|
||||
BasketForm = formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class CounterClick(
|
||||
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
|
||||
):
|
||||
|
@ -15,11 +15,12 @@
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.base import View
|
||||
|
||||
from core.views.mixins import TabedViewMixin
|
||||
from counter.utils import is_logged_in_counter
|
||||
|
||||
|
||||
class CounterAdminMixin(View):
|
||||
@ -49,34 +50,40 @@ class CounterTabsMixin(TabedViewMixin):
|
||||
return self.object
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
if self.object.type != "BAR":
|
||||
return []
|
||||
tab_list = [
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:details", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "counter",
|
||||
"name": _("Counter"),
|
||||
}
|
||||
]
|
||||
if self.object.type == "BAR":
|
||||
if self.request.user.has_perm("counter.add_cashregistersummary"):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:cash_summary", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "cash_summary",
|
||||
"name": _("Cash summary"),
|
||||
}
|
||||
)
|
||||
if is_logged_in_counter(self.request):
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse_lazy(
|
||||
"url": reverse(
|
||||
"counter:last_ops", kwargs={"counter_id": self.object.id}
|
||||
),
|
||||
"slug": "last_ops",
|
||||
"name": _("Last operations"),
|
||||
}
|
||||
)
|
||||
if len(tab_list) <= 1:
|
||||
# It would be strange to show only one tab
|
||||
return []
|
||||
return tab_list
|
||||
|
||||
|
||||
|
@ -131,6 +131,31 @@ de données fonctionnent avec l'un comme avec l'autre.
|
||||
Heureusement, et grâce à l'ORM de Django, cette
|
||||
double compatibilité est presque toujours possible.
|
||||
|
||||
### Celery
|
||||
|
||||
[Site officiel](https://docs.celeryq.dev/en/stable/)
|
||||
|
||||
Dans certaines situations, on veut séparer une tâche
|
||||
pour la faire tourner dans son coin.
|
||||
Deux cas qui correspondent à cette situation sont :
|
||||
|
||||
- les tâches longues à exécuter
|
||||
(comme l'envoi de mail ou la génération de documents),
|
||||
pour lesquelles on veut pouvoir dire à l'utilisateur
|
||||
que sa requête a été prise en compte, sans pour autant
|
||||
le faire trop patienter
|
||||
- les tâches régulières séparées du cycle requête/réponse.
|
||||
|
||||
Pour ça, nous utilisons Celery.
|
||||
Grâce à son intégration avec Django,
|
||||
il permet de mettre en place une queue de message
|
||||
avec assez peu complexité ajoutée.
|
||||
|
||||
En outre, ses extensions `django-celery-results`
|
||||
et `django-celery-beat` enrichissent son intégration
|
||||
avec django et offrent des moyens de manipuler certaines
|
||||
tâches directement dans l'interface admin de django.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Jinja2
|
||||
|
@ -1 +0,0 @@
|
||||
::: launderette.models
|
@ -1 +0,0 @@
|
||||
::: launderette.views
|
@ -120,7 +120,7 @@ les conflits avec les instances de redis déjà en fonctionnement.
|
||||
|
||||
```dotenv
|
||||
REDIS_PORT=6379
|
||||
CACHE_URL=redis://127.0.0.1:${REDIS_PORT}/0
|
||||
CACHE_URL=redis://127.0.0.1:6379/0
|
||||
```
|
||||
|
||||
Si on souhaite configurer redis pour communiquer via un socket :
|
||||
@ -151,7 +151,7 @@ ALTER ROLE sith SET client_encoding TO 'utf8';
|
||||
ALTER ROLE sith SET default_transaction_isolation TO 'read committed';
|
||||
ALTER ROLE sith SET timezone TO 'UTC';
|
||||
|
||||
GRANT ALL PRIVILEGES ON DATABASE sith TO SITH;
|
||||
GRANT ALL PRIVILEGES ON DATABASE sith TO sith;
|
||||
\q
|
||||
```
|
||||
|
||||
@ -279,6 +279,26 @@ Toutes les requêtes vers des fichiers statiques et les medias publiques
|
||||
seront servies directement par nginx.
|
||||
Toutes les autres requêtes seront transmises au serveur django.
|
||||
|
||||
## Celery
|
||||
|
||||
Celery ne tourne pas dans django.
|
||||
C'est une application à part, avec ses propres processus,
|
||||
qui tourne de manière indépendante et qui ne communique
|
||||
que par messages avec l'instance de django.
|
||||
|
||||
Pour faire tourner Celery, faites la commande suivante dans
|
||||
un terminal à part :
|
||||
|
||||
```bash
|
||||
uv run celery -A sith worker --beat -l INFO
|
||||
```
|
||||
|
||||
!!!note
|
||||
|
||||
Nous utilisons Redis comme broker pour Celery,
|
||||
donc vous devez aussi configurer l'URL du broker,
|
||||
de la même manière que ce qui est décrit plus haut
|
||||
pour Redis.
|
||||
|
||||
## Mettre à jour la base de données antispam
|
||||
|
||||
|
@ -100,7 +100,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||
installé par uv.
|
||||
|
||||
|
||||
## Finaliser l'installation
|
||||
|
||||
Clonez le projet (depuis votre console WSL, si vous utilisez WSL)
|
||||
@ -113,6 +112,7 @@ cd sith
|
||||
# Création de l'environnement et installation des dépendances
|
||||
uv sync
|
||||
npm install # Dépendances frontend
|
||||
cp .env.example .env # Configuration par défaut
|
||||
uv run ./manage.py install_xapian
|
||||
```
|
||||
|
||||
@ -122,17 +122,12 @@ uv run ./manage.py install_xapian
|
||||
de texte à l'écran.
|
||||
C'est normal, il ne faut pas avoir peur.
|
||||
|
||||
Une fois les dépendances installées, il faut encore
|
||||
mettre en place quelques éléments de configuration,
|
||||
qui peuvent varier d'un environnement à l'autre.
|
||||
|
||||
La modification de la configuration passe par la modification de variables
|
||||
d'environnement.
|
||||
Ces variables sont stockées dans un fichier `.env`.
|
||||
Pour le créer, vous pouvez copier le fichier `.env.example` :
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Les variables par défaut contenues dans le fichier `.env`
|
||||
Les variables par défaut contenues dans le fichier `.env.example`
|
||||
devraient convenir pour le développement, sans modification.
|
||||
|
||||
Maintenant que les dépendances sont installées
|
||||
|
@ -32,48 +32,39 @@ susnommés afin de comprendre comment celui-ci marche.
|
||||
|
||||
Cette application contient les vues suivantes :
|
||||
|
||||
- `eboutic_main` (GET) : la vue retournant la page principale de la boutique en ligne.
|
||||
- `EbouticMainView` (GET/POST) : la vue retournant la page principale de la boutique en ligne.
|
||||
Cette vue effectue un filtrage des produits à montrer à l'utilisateur en
|
||||
fonction de ce qu'il a le droit d'acheter.
|
||||
Si cette vue est appelée lors d'une redirection parce qu'une erreur
|
||||
est survenue au cours de la navigation sur la boutique, il est possible
|
||||
de donner les messages d'erreur à donner à l'utilisateur dans la session
|
||||
avec la clef ``"errors"``.
|
||||
Elle est en charge de récupérer le formulaire de création d'un panier et
|
||||
redirige alors vers la vue de checkout.
|
||||
- ``payment_result`` (GET) : retourne une page assez simple disant à l'utilisateur
|
||||
si son paiement a échoué ou réussi. Cette vue est appelée par redirection
|
||||
lorsque l'utilisateur paye son panier avec son argent du compte AE.
|
||||
- ``EbouticCommand`` (POST) : traite la soumission d'un panier par l'utilisateur.
|
||||
Lors de l'appel de cette vue, la requête doit contenir un cookie avec l'état
|
||||
du panier à valider. Ce panier doit strictement être de la forme :
|
||||
```
|
||||
[
|
||||
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
|
||||
{"id": <int>, "name": <str>, "quantity": <int>, "unit_price": <float>},
|
||||
<etc.>
|
||||
]
|
||||
```
|
||||
Si le panier est mal formaté ou contient des valeurs invalides,
|
||||
une redirection est faite vers `eboutic_main`.
|
||||
- ``pay_with_sith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||
- ``EbouticCheckout`` (GET/POST) : Page récapitulant le contenu d'un panier.
|
||||
Permet de sélectionner le moyen de paiement et de mettre à jour ses coordonnées
|
||||
de paiement par carte bancaire.
|
||||
- ``PayWithSith`` (POST) : paie le panier avec l'argent présent sur le compte
|
||||
AE. Redirige vers `payment_result`.
|
||||
- ``ETransactionAutoAnswer`` (GET) : vue destinée à communiquer avec le service
|
||||
de paiement bancaire pour valider ou non le paiement de l'utilisateur.
|
||||
- ``BillingInfoFormFragment`` (GET/POST) : vue destinée à gérer les informations de paiement de l'utilisateur courant.
|
||||
|
||||
# Les templates
|
||||
|
||||
- ``eboutic_payment_result.jinja`` : très court template contenant juste
|
||||
un message pour dire à l'utilisateur si son achat s'est bien déroulé.
|
||||
Retourné par la vue ``payment_result``.
|
||||
- ``eboutic_makecommand.jinja`` : template contenant un résumé du panier et deux
|
||||
- ``eboutic_checkout.jinja`` : template contenant un résumé du panier et deux
|
||||
boutons, un pour payer avec le site AE et l'autre pour payer par carte bancaire.
|
||||
Retourné par la vue ``EbouticCommand``
|
||||
Retourné par la vue ``EbouticCheckout``
|
||||
- ``eboutic_billing_info.jinja`` : formulaire de modification des coordonnées bancaires.
|
||||
Elle permet également de mettre à jour ses coordonnées de paiement
|
||||
- ``eboutic_main.jinja`` : le plus gros template de cette application. Contient
|
||||
une interface pour que l'utilisateur puisse consulter les produits et remplir
|
||||
son panier. Les opérations de remplissage du panier se font entièrement côté client.
|
||||
À chaque clic pour ajouter ou retirer un élément du panier, le script JS
|
||||
(AlpineJS, plus précisément) édite en même temps un cookie.
|
||||
Au moment de la validation du panier, ce cookie est envoyé au serveur pour
|
||||
vérifier que la commande est valide et payer.
|
||||
(AlpineJS, plus précisément) édite en même temps le localStorage du navigateur.
|
||||
Cette vue fabrique dynamiquement un formulaire qui sera soumis au serveur.
|
||||
|
||||
# Les modèles
|
||||
|
||||
|
@ -1,22 +1,20 @@
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
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 eboutic.models import Basket
|
||||
|
||||
|
||||
@api_controller("/etransaction", permissions=[IsAuthenticated])
|
||||
@api_controller("/etransaction", permissions=[CanView])
|
||||
class EtransactionInfoController(ControllerBase):
|
||||
@route.get("/data", url_name="etransaction_data")
|
||||
def fetch_etransaction_data(self):
|
||||
@route.get("/data/{basket_id}", url_name="etransaction_data")
|
||||
def fetch_etransaction_data(self, basket_id: int):
|
||||
"""Generate the data to pay an eboutic command with paybox.
|
||||
|
||||
The data is generated with the basket that is used by the current session.
|
||||
"""
|
||||
basket = Basket.from_session(self.context.request.session)
|
||||
if basket is None:
|
||||
raise NotFound
|
||||
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
|
||||
try:
|
||||
return dict(basket.get_e_transaction_data())
|
||||
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 counter.fields import CurrencyField
|
||||
from counter.models import BillingInfo, Counter, Customer, Product, Refilling, Selling
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Product,
|
||||
Refilling,
|
||||
Selling,
|
||||
get_eboutic,
|
||||
)
|
||||
|
||||
|
||||
def get_eboutic_products(user: User) -> list[Product]:
|
||||
products = (
|
||||
Counter.objects.get(type="EBOUTIC")
|
||||
get_eboutic()
|
||||
.products.filter(product_type__isnull=False)
|
||||
.filter(archived=False)
|
||||
.filter(limit_age__lte=user.age)
|
||||
.annotate(order=F("product_type__order"))
|
||||
.annotate(category=F("product_type__name"))
|
||||
.annotate(category_comment=F("product_type__comment"))
|
||||
.annotate(price=F("selling_price")) # <-- selected price for basket validation
|
||||
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
|
||||
)
|
||||
return [p for p in products if p.can_be_sold_to(user)]
|
||||
@ -84,6 +93,9 @@ class Basket(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.user}'s basket ({self.items.all().count()} items)"
|
||||
|
||||
def can_be_viewed_by(self, user):
|
||||
return self.user == user
|
||||
|
||||
@cached_property
|
||||
def contains_refilling_item(self) -> bool:
|
||||
return self.items.filter(
|
||||
@ -98,13 +110,6 @@ class Basket(models.Model):
|
||||
)["total"]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_session(cls, session) -> Basket | None:
|
||||
"""The basket stored in the session object, if it exists."""
|
||||
if "basket_id" in session:
|
||||
return cls.objects.filter(id=session["basket_id"]).first()
|
||||
return None
|
||||
|
||||
def generate_sales(self, counter, seller: User, payment_method: str):
|
||||
"""Generate a list of sold items corresponding to the items
|
||||
of this basket WITHOUT saving them NOR deleting the basket.
|
||||
@ -139,7 +144,7 @@ class Basket(models.Model):
|
||||
club=product.club,
|
||||
product=product,
|
||||
seller=seller,
|
||||
customer=self.user.customer,
|
||||
customer=Customer.get_or_create(self.user)[0],
|
||||
unit_price=item.product_unit_price,
|
||||
quantity=item.quantity,
|
||||
payment_method=payment_method,
|
||||
|
@ -1,13 +1,18 @@
|
||||
import { etransactioninfoFetchEtransactionData } from "#openapi";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("etransaction", (initialData) => ({
|
||||
Alpine.data("etransaction", (initialData, basketId: number) => ({
|
||||
data: initialData,
|
||||
isCbAvailable: Object.keys(initialData).length > 0,
|
||||
|
||||
async fill() {
|
||||
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) {
|
||||
this.data = res.data;
|
||||
this.isCbAvailable = true;
|
@ -8,55 +8,55 @@ interface BasketItem {
|
||||
unit_price: number;
|
||||
}
|
||||
|
||||
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
||||
|
||||
/**
|
||||
* Search for a cookie by name
|
||||
* @param name Name of the cookie to get
|
||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
||||
*/
|
||||
function getCookie(name: string): string | null | undefined {
|
||||
if (!document.cookie || document.cookie.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${name}=`));
|
||||
|
||||
return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the basket items from the associated cookie
|
||||
* @returns the items in the basket
|
||||
*/
|
||||
function getStartingItems(): BasketItem[] {
|
||||
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||
if (!cookie) {
|
||||
return [];
|
||||
}
|
||||
// Django cookie backend converts `,` to `\054`
|
||||
let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
|
||||
if (typeof parsed === "string") {
|
||||
// In some conditions, a second parsing is needed
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
const res = Array.isArray(parsed) ? parsed : [];
|
||||
return res.filter((i) => !!document.getElementById(i.id));
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", () => ({
|
||||
items: getStartingItems() as BasketItem[],
|
||||
Alpine.data("basket", (lastPurchaseTime?: number) => ({
|
||||
basket: [] as BasketItem[],
|
||||
|
||||
init() {
|
||||
this.basket = this.loadBasket();
|
||||
this.$watch("basket", () => {
|
||||
this.saveBasket();
|
||||
});
|
||||
|
||||
// Invalidate basket if a purchase was made
|
||||
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
|
||||
if (
|
||||
new Date(lastPurchaseTime) >=
|
||||
new Date(Number.parseInt(localStorage.basketTimestamp))
|
||||
) {
|
||||
this.basket = [];
|
||||
}
|
||||
}
|
||||
|
||||
// It's quite tricky to manually apply attributes to the management part
|
||||
// of a formset so we dynamically apply it here
|
||||
this.$refs.basketManagementForm
|
||||
.querySelector("#id_form-TOTAL_FORMS")
|
||||
.setAttribute(":value", "basket.length");
|
||||
},
|
||||
|
||||
loadBasket(): BasketItem[] {
|
||||
if (localStorage.basket === undefined) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(localStorage.basket);
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
saveBasket() {
|
||||
localStorage.basket = JSON.stringify(this.basket);
|
||||
localStorage.basketTimestamp = Date.now();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the total price of the basket
|
||||
* @returns {number} The total price of the basket
|
||||
*/
|
||||
getTotal() {
|
||||
return this.items.reduce(
|
||||
return this.basket.reduce(
|
||||
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||
0,
|
||||
);
|
||||
@ -68,7 +68,6 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
add(item: BasketItem) {
|
||||
item.quantity++;
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
@ -76,39 +75,25 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param itemId the id of the item to remove
|
||||
*/
|
||||
remove(itemId: number) {
|
||||
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
||||
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.items[index].quantity -= 1;
|
||||
this.basket[index].quantity -= 1;
|
||||
|
||||
if (this.items[index].quantity === 0) {
|
||||
this.items = this.items.filter(
|
||||
(e: BasketItem) => e.id !== this.items[index].id,
|
||||
if (this.basket[index].quantity === 0) {
|
||||
this.basket = this.basket.filter(
|
||||
(e: BasketItem) => e.id !== this.basket[index].id,
|
||||
);
|
||||
}
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all the items from the basket & cleans the catalog CSS classes
|
||||
*/
|
||||
clearBasket() {
|
||||
this.items = [];
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the cookie in the browser with the basket items
|
||||
* ! the cookie survives an hour
|
||||
*/
|
||||
setCookies() {
|
||||
if (this.items.length === 0) {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
|
||||
} else {
|
||||
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
|
||||
}
|
||||
this.basket = [];
|
||||
},
|
||||
|
||||
/**
|
||||
@ -127,7 +112,7 @@ document.addEventListener("alpine:init", () => {
|
||||
unit_price: price,
|
||||
} as BasketItem;
|
||||
|
||||
this.items.push(newItem);
|
||||
this.basket.push(newItem);
|
||||
this.add(newItem);
|
||||
|
||||
return newItem;
|
||||
@ -141,7 +126,7 @@ document.addEventListener("alpine:init", () => {
|
||||
* @param price The unit price of the product
|
||||
*/
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.items.find((e: BasketItem) => e.id === id);
|
||||
let item = this.basket.find((e: BasketItem) => e.id === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
||||
|
@ -61,6 +61,7 @@
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
line-height: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#eboutic .fa-plus,
|
||||
|
@ -9,7 +9,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block content %}
|
||||
@ -19,7 +19,7 @@
|
||||
let billingInfos = {{ billing_infos|safe }};
|
||||
</script>
|
||||
|
||||
<div x-data="etransaction(billingInfos)">
|
||||
<div x-data="etransaction(billingInfos, {{ basket.id }})">
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
<table>
|
||||
<thead>
|
||||
@ -82,9 +82,8 @@
|
||||
{% elif basket.total > user.account_balance %}
|
||||
<p>{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith') }}" name="sith-pay-form">
|
||||
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
|
||||
{% 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 %}"/>
|
||||
</form>
|
||||
{% endif %}
|
@ -21,57 +21,90 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
|
||||
<div id="eboutic" x-data="basket">
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div id="eboutic" x-data="basket({{ last_purchase_time }})">
|
||||
<div id="basket">
|
||||
<h3>Panier</h3>
|
||||
{% if errors %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in errors %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
<template x-for="item in items" :key="item.id">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
|
||||
{% if form.non_form_errors() or form.errors %}
|
||||
<div class="alert alert-red">
|
||||
<div class="alert-main">
|
||||
{% for error in form.non_form_errors() + form.errors %}
|
||||
<p style="margin: 0">{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="item-list">
|
||||
{# Starting money #}
|
||||
<li>
|
||||
<span class="item-name">
|
||||
<strong>{% trans %}Current account amount: {% endtrans %}</strong>
|
||||
</span>
|
||||
<span class="item-price">
|
||||
<strong>{{ "%0.2f"|format(customer_amount) }} €</strong>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<form method="get" action="{{ url('eboutic:command') }}">
|
||||
|
||||
<template x-for="(item, index) in Object.values(basket)" :key="item.id">
|
||||
<li class="item-row" x-show="item.quantity > 0">
|
||||
<div class="item-quantity">
|
||||
<i class="fa fa-minus fa-xs" @click="remove(item.id)"></i>
|
||||
<span x-text="item.quantity"></span>
|
||||
<i class="fa fa-plus" @click="add(item)"></i>
|
||||
</div>
|
||||
<span class="item-name" x-text="item.name"></span>
|
||||
<span class="item-price" x-text="(item.unit_price * item.quantity).toFixed(2) + ' €'"></span>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
|
||||
</li>
|
||||
</template>
|
||||
{# Total price #}
|
||||
<li style="margin-top: 20px">
|
||||
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
|
||||
<span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="catalog-buttons">
|
||||
<button @click.prevent="clearBasket()" class="btn btn-grey">
|
||||
<i class="fa fa-trash"></i>
|
||||
{% trans %}Clear{% endtrans %}
|
||||
</button>
|
||||
<button class="btn btn-blue">
|
||||
<i class="fa fa-check"></i>
|
||||
<input type="submit" value="{% trans %}Validate{% endtrans %}"/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div id="catalog">
|
||||
{% if not request.user.date_of_birth %}
|
||||
@ -108,7 +141,7 @@
|
||||
{% trans trimmed %}
|
||||
Our partner uses Weezevent to sell tickets.
|
||||
Weezevent may collect user info according to
|
||||
it's own privacy policy.
|
||||
its own privacy policy.
|
||||
By clicking the accept button you consent to
|
||||
their terms of services.
|
||||
{% endtrans %}
|
||||
@ -158,7 +191,7 @@
|
||||
<button
|
||||
id="{{ p.id }}"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
||||
:class="{selected: basket.some((i) => i.id === {{ p.id }})}"
|
||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||
>
|
||||
{% if p.icon %}
|
||||
|
@ -4,6 +4,14 @@
|
||||
<h3>{% trans %}Eboutic{% endtrans %}</h3>
|
||||
|
||||
<div>
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
{% trans %}Payment successful{% endtrans %}
|
||||
{% else %}
|
||||
|
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.views import (
|
||||
BillingInfoFormFragment,
|
||||
EbouticCommand,
|
||||
EbouticCheckout,
|
||||
EbouticMainView,
|
||||
EbouticPayWithSith,
|
||||
EtransactionAutoAnswer,
|
||||
EurokPartnerFragment,
|
||||
eboutic_main,
|
||||
pay_with_sith,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
@ -39,10 +39,12 @@ register_converter(PaymentResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
path("", eboutic_main, name="main"),
|
||||
path("command/", EbouticCommand.as_view(), name="command"),
|
||||
path("", EbouticMainView.as_view(), name="main"),
|
||||
path("checkout/<int:basket_id>", EbouticCheckout.as_view(), name="checkout"),
|
||||
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
|
||||
path("pay/sith/", 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("eurok/", EurokPartnerFragment.as_view(), name="eurok"),
|
||||
path(
|
||||
|
210
eboutic/views.py
210
eboutic/views.py
@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sentry_sdk
|
||||
@ -33,23 +32,23 @@ from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
)
|
||||
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.models.fields import forms
|
||||
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.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
from django.views.generic import TemplateView, UpdateView, View
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from core.auth.mixins import IsSubscriberMixin
|
||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BillingInfoForm
|
||||
from counter.models import BillingInfo, Counter, Customer, Product
|
||||
from eboutic.forms import BasketForm
|
||||
from counter.forms import BaseBasketForm, BillingInfoForm, ProductForm
|
||||
from counter.models import BillingInfo, Customer, Product, Selling, get_eboutic
|
||||
from eboutic.models import (
|
||||
Basket,
|
||||
BasketItem,
|
||||
@ -58,39 +57,82 @@ from eboutic.models import (
|
||||
InvoiceItem,
|
||||
get_eboutic_products,
|
||||
)
|
||||
from eboutic.schemas import PurchaseItemList, PurchaseItemSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
from django.utils.html import SafeString
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def eboutic_main(request: HttpRequest) -> HttpResponse:
|
||||
"""Main view of the eboutic application.
|
||||
class BaseEbouticBasketForm(BaseBasketForm):
|
||||
def _check_enough_money(self, *args, **kwargs):
|
||||
# Disable money check
|
||||
...
|
||||
|
||||
Return an Http response whose content is of type text/html.
|
||||
The latter represents the page from which a user can see
|
||||
the catalogue of products that he can buy and fill
|
||||
his shopping cart.
|
||||
|
||||
EbouticBasketForm = forms.formset_factory(
|
||||
ProductForm, formset=BaseEbouticBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class EbouticMainView(LoginRequiredMixin, FormView):
|
||||
"""Main view of the eboutic application.
|
||||
|
||||
The purchasable products are those of the eboutic which
|
||||
belong to a category of products of a product category
|
||||
(orphan products are inaccessible).
|
||||
|
||||
If the session contains a key-value pair that associates "errors"
|
||||
with a list of strings, this pair is removed from the session
|
||||
and its value displayed to the user when the page is rendered.
|
||||
"""
|
||||
errors = request.session.pop("errors", None)
|
||||
products = get_eboutic_products(request.user)
|
||||
context = {
|
||||
"errors": errors,
|
||||
"products": products,
|
||||
"customer_amount": request.user.account_balance,
|
||||
}
|
||||
return render(request, "eboutic/eboutic_main.jinja", context)
|
||||
|
||||
template_name = "eboutic/eboutic_main.jinja"
|
||||
form_class = EbouticBasketForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": get_eboutic(),
|
||||
"allowed_products": {product.id: product for product in self.products},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, formset):
|
||||
if len(formset) == 0:
|
||||
formset.errors.append(_("Your basket is empty"))
|
||||
return self.form_invalid(formset)
|
||||
|
||||
with transaction.atomic():
|
||||
self.basket = Basket.objects.create(user=self.request.user)
|
||||
for form in formset:
|
||||
BasketItem.from_product(
|
||||
form.product, form.cleaned_data["quantity"], self.basket
|
||||
).save()
|
||||
self.basket.save()
|
||||
return super().form_valid(formset)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("eboutic:checkout", kwargs={"basket_id": self.basket.id})
|
||||
|
||||
@cached_property
|
||||
def products(self) -> list[Product]:
|
||||
return get_eboutic_products(self.request.user)
|
||||
|
||||
@cached_property
|
||||
def customer(self) -> Customer:
|
||||
return Customer.get_or_create(self.request.user)[0]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["products"] = self.products
|
||||
context["customer_amount"] = self.request.user.account_balance
|
||||
last_purchase: Selling | None = (
|
||||
self.customer.buyings.filter(counter__type="EBOUTIC")
|
||||
.order_by("-date")
|
||||
.first()
|
||||
)
|
||||
context["last_purchase_time"] = (
|
||||
int(last_purchase.date.timestamp() * 1000) if last_purchase else "null"
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@require_GET
|
||||
@ -166,48 +208,15 @@ class BillingInfoFormFragment(
|
||||
return self.request.path
|
||||
|
||||
|
||||
class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
||||
template_name = "eboutic/eboutic_makecommand.jinja"
|
||||
basket: Basket
|
||||
class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
|
||||
model = Basket
|
||||
pk_url_kwarg = "basket_id"
|
||||
context_object_name = "basket"
|
||||
template_name = "eboutic/eboutic_checkout.jinja"
|
||||
fragments = {
|
||||
"billing_infos_form": BillingInfoFormFragment,
|
||||
}
|
||||
|
||||
@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):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
if hasattr(self.request.user, "customer"):
|
||||
@ -215,51 +224,44 @@ class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
|
||||
kwargs["customer_amount"] = customer.amount
|
||||
else:
|
||||
kwargs["customer_amount"] = None
|
||||
kwargs["basket"] = self.basket
|
||||
kwargs["billing_infos"] = {}
|
||||
|
||||
with contextlib.suppress(BillingInfo.DoesNotExist):
|
||||
kwargs["billing_infos"] = json.dumps(
|
||||
dict(self.basket.get_e_transaction_data())
|
||||
dict(self.object.get_e_transaction_data())
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def pay_with_sith(request):
|
||||
basket = Basket.from_session(request.session)
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket is None or basket.items.filter(type_id=refilling).exists():
|
||||
return redirect("eboutic:main")
|
||||
c = Customer.objects.filter(user__id=basket.user_id).first()
|
||||
if c is None:
|
||||
return redirect("eboutic:main")
|
||||
if c.amount < basket.total:
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
eboutic = Counter.objects.get(type="EBOUTIC")
|
||||
sales = basket.generate_sales(eboutic, c.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
request.session.pop("basket_id", None)
|
||||
res = redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
scope.user = {"username": request.user.username}
|
||||
scope.set_extra("someVariable", e.__repr__())
|
||||
sentry_sdk.capture_message(
|
||||
f"Erreur le {datetime.now()} dans eboutic.pay_with_sith"
|
||||
class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
|
||||
model = Basket
|
||||
pk_url_kwarg = "basket_id"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
basket = self.get_object()
|
||||
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
|
||||
if basket.items.filter(type_id=refilling).exists():
|
||||
messages.error(
|
||||
self.request,
|
||||
_("You can't buy a refilling with sith money"),
|
||||
)
|
||||
res = redirect("eboutic:payment_result", "failure")
|
||||
res.delete_cookie("basket_items", "/eboutic")
|
||||
return res
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
eboutic = get_eboutic()
|
||||
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Selling.save has some important business logic in it.
|
||||
# Do not bulk_create this
|
||||
for sale in sales:
|
||||
sale.save()
|
||||
basket.delete()
|
||||
return redirect("eboutic:payment_result", "success")
|
||||
except DatabaseError as e:
|
||||
sentry_sdk.capture_exception(e)
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, e.message)
|
||||
return redirect("eboutic:payment_result", "failure")
|
||||
|
||||
|
||||
class EtransactionAutoAnswer(View):
|
||||
|
@ -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 ""
|
||||
msgstr ""
|
||||
"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"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -1764,8 +1764,8 @@ msgstr "Photos"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja counter/models.py
|
||||
#: counter/templates/counter/counter_list.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_payment_result.jinja sith/settings.py
|
||||
msgid "Eboutic"
|
||||
msgstr "Eboutic"
|
||||
@ -2882,6 +2882,30 @@ msgstr ""
|
||||
msgid "Refound this account"
|
||||
msgstr "Rembourser ce compte"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "The selected product isn't available for this user"
|
||||
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Submitted basket is invalid"
|
||||
msgstr "Le panier envoyé est invalide"
|
||||
|
||||
#: counter/forms.py
|
||||
msgid "Duplicated product entries."
|
||||
msgstr "Saisie de produit dupliquée"
|
||||
|
||||
#: counter/forms.py counter/models.py
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
#: counter/forms.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This user have reached his recording limit for the following products : %s"
|
||||
msgstr ""
|
||||
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
|
||||
"suivants : %s"
|
||||
|
||||
#: counter/management/commands/dump_accounts.py
|
||||
msgid "Your AE account has been emptied"
|
||||
msgstr "Votre compte AE a été vidé"
|
||||
@ -2906,10 +2930,6 @@ msgstr "client"
|
||||
msgid "customers"
|
||||
msgstr "clients"
|
||||
|
||||
#: counter/models.py counter/views/click.py
|
||||
msgid "Not enough money"
|
||||
msgstr "Solde insuffisant"
|
||||
|
||||
#: counter/models.py
|
||||
msgid "First name"
|
||||
msgstr "Prénom"
|
||||
@ -3278,7 +3298,7 @@ msgid "Go"
|
||||
msgstr "Valider"
|
||||
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Basket: "
|
||||
msgstr "Panier : "
|
||||
|
||||
@ -3672,26 +3692,6 @@ msgstr "Montant du chèque"
|
||||
msgid "Check quantity"
|
||||
msgstr "Nombre de chèque"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "The selected product isn't available for this user"
|
||||
msgstr "Le produit sélectionné n'est pas disponnible pour cet utilisateur"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "Submitted basket is invalid"
|
||||
msgstr "Le panier envoyé est invalide"
|
||||
|
||||
#: counter/views/click.py
|
||||
msgid "Duplicated product entries."
|
||||
msgstr "Saisie de produit dupliquée"
|
||||
|
||||
#: counter/views/click.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This user have reached his recording limit for the following products : %s"
|
||||
msgstr ""
|
||||
"Cet utilisateur a atteint sa limite de déconsigne pour les produits "
|
||||
"suivants : %s"
|
||||
|
||||
#: counter/views/eticket.py
|
||||
msgid "people(s)"
|
||||
msgstr "personne(s)"
|
||||
@ -3729,19 +3729,6 @@ msgstr "Types de produit"
|
||||
msgid "%(name)s has no registered student card"
|
||||
msgstr "%(name)s n'a pas de carte étudiante enregistrée"
|
||||
|
||||
#: eboutic/forms.py
|
||||
msgid "The request was badly formatted."
|
||||
msgstr "La requête a été mal formatée."
|
||||
|
||||
#: eboutic/forms.py
|
||||
msgid "Your basket is empty."
|
||||
msgstr "Votre panier est vide"
|
||||
|
||||
#: eboutic/forms.py
|
||||
#, python-format
|
||||
msgid "%(name)s : this product does not exist or may no longer be available."
|
||||
msgstr "%(name)s : ce produit n'existe pas ou n'est peut-être plus disponible."
|
||||
|
||||
#: eboutic/models.py
|
||||
msgid "validated"
|
||||
msgstr "validé"
|
||||
@ -3779,15 +3766,44 @@ msgstr "Informations de facturation"
|
||||
msgid "Validate"
|
||||
msgstr "Valider"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Basket state"
|
||||
msgstr "État du panier"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Basket amount: "
|
||||
msgstr "Valeur du panier : "
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
||||
msgid "Current account amount: "
|
||||
msgstr "Solde actuel : "
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#: eboutic/templates/eboutic/eboutic_makecommand.jinja
|
||||
msgid "Basket amount: "
|
||||
msgstr "Valeur du panier : "
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Remaining account amount: "
|
||||
msgstr "Solde restant : "
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Pay with credit card"
|
||||
msgstr "Payer avec une carte bancaire"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid ""
|
||||
"AE account payment disabled because your basket contains refilling items."
|
||||
msgstr ""
|
||||
"Paiement par compte AE désactivé parce que votre panier contient des bons de "
|
||||
"rechargement."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid ""
|
||||
"AE account payment disabled because you do not have enough money remaining."
|
||||
msgstr ""
|
||||
"Paiement par compte AE désactivé parce que votre solde est insuffisant."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_checkout.jinja
|
||||
msgid "Pay with Sith account"
|
||||
msgstr "Payer avec un compte AE"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Clear"
|
||||
@ -3815,12 +3831,13 @@ msgstr "Partenariat Eurockéennes 2025"
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||
"according to 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."
|
||||
msgstr ""
|
||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut collecter des informatinos utilisateur "
|
||||
"conformément à sa propre politique de confidentialité. En cliquant sur le bouton d'acceptation vous "
|
||||
"consentez à leurs termes de service."
|
||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||
"leurs termes de service."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Privacy policy"
|
||||
@ -3852,35 +3869,6 @@ msgstr ""
|
||||
msgid "There are no items available for sale"
|
||||
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
|
||||
msgid "Payment successful"
|
||||
msgstr "Le paiement a été effectué"
|
||||
@ -3893,6 +3881,10 @@ msgstr "Le paiement a échoué"
|
||||
msgid "Return to eboutic"
|
||||
msgstr "Retourner à l'eboutic"
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "Your basket is empty"
|
||||
msgstr "Votre panier est vide"
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "Billing info registration success"
|
||||
msgstr "Informations de facturation enregistrées"
|
||||
@ -3916,6 +3908,10 @@ msgstr ""
|
||||
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
|
||||
"données que vous aviez déjà fourni."
|
||||
|
||||
#: eboutic/views.py
|
||||
msgid "You can't buy a refilling with sith money"
|
||||
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
|
||||
|
||||
#: election/models.py
|
||||
msgid "start candidature"
|
||||
msgstr "début des candidatures"
|
||||
|
@ -37,6 +37,10 @@ msgstr "Supprimer"
|
||||
msgid "Copy calendar link"
|
||||
msgstr "Copier le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "How to use calendar link"
|
||||
msgstr "Comment utiliser le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
11
manage.py
11
manage.py
@ -18,10 +18,10 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||
|
||||
from sith.composer import start_composer, stop_composer
|
||||
from sith.settings import PROCFILE_SERVICE
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(encoding="utf-8", level=logging.INFO)
|
||||
@ -30,8 +30,11 @@ if __name__ == "__main__":
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and PROCFILE_SERVICE is not None:
|
||||
start_composer(PROCFILE_SERVICE)
|
||||
_ = atexit.register(stop_composer, procfile=PROCFILE_SERVICE)
|
||||
if (
|
||||
os.environ.get(DJANGO_AUTORELOAD_ENV) is None
|
||||
and settings.PROCFILE_SERVICE is not None
|
||||
):
|
||||
start_composer(settings.PROCFILE_SERVICE)
|
||||
_ = atexit.register(stop_composer, procfile=settings.PROCFILE_SERVICE)
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
|
@ -113,9 +113,6 @@ nav:
|
||||
- galaxy:
|
||||
- reference/galaxy/models.md
|
||||
- reference/galaxy/views.md
|
||||
- launderette:
|
||||
- reference/launderette/models.md
|
||||
- reference/launderette/views.md
|
||||
- matmat:
|
||||
- reference/matmat/models.md
|
||||
- reference/matmat/views.md
|
||||
|
26
package-lock.json
generated
26
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
@ -2162,6 +2163,31 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
||||
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
|
||||
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.6.0",
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
||||
|
@ -38,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
|
@ -17,15 +17,15 @@ authors = [
|
||||
{ name = "Vial", email = "robin.trioux@utbm.fr" },
|
||||
]
|
||||
license = { text = "GPL-3.0-only" }
|
||||
requires-python = "<4.0,>=3.12"
|
||||
requires-python = "<4.0,>=3.13"
|
||||
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-extra<1.0.0,>=0.22.9",
|
||||
"Pillow<12.0.0,>=11.1.0",
|
||||
"mistune<4.0.0,>=3.1.3",
|
||||
"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",
|
||||
"phonenumbers>=9.0.2,<10.0.0",
|
||||
"reportlab<5.0.0,>=4.3.1",
|
||||
@ -39,16 +39,19 @@ dependencies = [
|
||||
"jinja2<4.0.0,>=3.1.6",
|
||||
"django-countries<8.0.0,>=7.6.1",
|
||||
"dict2xml<2.0.0,>=1.7.6",
|
||||
"Sphinx<6,>=5",
|
||||
"Sphinx>=8.2.3,<9",
|
||||
"tomli<3.0.0,>=2.2.1",
|
||||
"django-honeypot",
|
||||
"pydantic-extra-types<3.0.0,>=2.10.3",
|
||||
"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",
|
||||
"requests>=2.32.3",
|
||||
"honcho>=2.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]
|
||||
@ -57,13 +60,13 @@ documentation = "https://sith-ae.readthedocs.io/"
|
||||
|
||||
[dependency-groups]
|
||||
prod = [
|
||||
"psycopg[c]<4.0.0,>=3.2.6",
|
||||
"psycopg[c]>=3.2.9,<4.0.0",
|
||||
]
|
||||
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",
|
||||
"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",
|
||||
"faker<38.0.0,>=37.0.0",
|
||||
"rjsmin<2.0.0,>=1.2.4",
|
||||
|
@ -27,7 +27,9 @@ class AlbumCreateForm(forms.ModelForm):
|
||||
self.instance.moderator = owner
|
||||
|
||||
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"))
|
||||
return super().clean()
|
||||
|
||||
|
@ -59,7 +59,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if is_sas_admin %}
|
||||
{% if album_create_fragment %}
|
||||
</form>
|
||||
<br>
|
||||
{{ album_create_fragment }}
|
||||
|
@ -15,12 +15,13 @@
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_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)]
|
||||
),
|
||||
lambda: baker.make(User),
|
||||
lambda: None,
|
||||
],
|
||||
)
|
||||
def test_load_main_page(client: Client, user_factory: Callable[[], User]):
|
||||
"""Just check that the SAS doesn't crash."""
|
||||
user = user_factory()
|
||||
client.force_login(user)
|
||||
if user is not None:
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("sas:main"))
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_main_page_no_form_for_regular_users(client: Client):
|
||||
"""Test that subscribed users see no form on the sas main page"""
|
||||
client.force_login(subscriber_user.make())
|
||||
res = client.get(reverse("sas:main"))
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
forms = soup.find("main").find_all("form")
|
||||
assert len(forms) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_main_page_content_anonymous(client: Client):
|
||||
"""Test that public users see only an incentive to login"""
|
||||
res = client.get(reverse("sas:main"))
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
expected = "<h3>SAS</h3><p>Vous devez être connecté pour voir les photos.</p>"
|
||||
assertHTMLEqual(soup.find("main").decode_contents(), expected)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_album_access_non_subscriber(client: Client):
|
||||
"""Test that non-subscribers can only access albums where they are identified."""
|
||||
@ -67,6 +89,50 @@ def test_album_access_non_subscriber(client: Client):
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAlbumUpload:
|
||||
@staticmethod
|
||||
def assert_album_created(response, name, parent):
|
||||
assert response.headers.get("HX-Redirect", "") == parent.get_absolute_url()
|
||||
children = list(Album.objects.filter(parent=parent))
|
||||
assert len(children) == 1
|
||||
assert children[0].name == name
|
||||
|
||||
def test_sas_admin(self, client: Client):
|
||||
user = baker.make(
|
||||
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
)
|
||||
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("sas:album_create"), {"name": "new", "parent": album.id}
|
||||
)
|
||||
self.assert_album_created(response, "new", album)
|
||||
|
||||
def test_non_admin_user_with_edit_rights_on_parent(self, client: Client):
|
||||
group = baker.make(Group)
|
||||
user = subscriber_user.make(groups=[group])
|
||||
album = baker.make(
|
||||
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, edit_groups=[group]
|
||||
)
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
reverse("sas:album_create"), {"name": "new", "parent": album.id}
|
||||
)
|
||||
self.assert_album_created(response, "new", album)
|
||||
|
||||
def test_permission_denied(self, client: Client):
|
||||
album = baker.make(Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
client.force_login(subscriber_user.make())
|
||||
response = client.post(
|
||||
reverse("sas:album_create"), {"name": "new", "parent": album.id}
|
||||
)
|
||||
errors = BeautifulSoup(response.text, "lxml").find_all(class_="errorlist")
|
||||
assert len(errors) == 1
|
||||
assert errors[0].text == "Vous n'avez pas la permission de faire cela"
|
||||
assert not album.children.exists()
|
||||
|
||||
|
||||
class TestSasModeration(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -65,12 +65,16 @@ class SASMainView(UseFragmentsMixin, TemplateView):
|
||||
template_name = "sas/main.jinja"
|
||||
|
||||
def get_fragments(self) -> dict[str, FragmentRenderer]:
|
||||
if not self.request.user.has_perm("sas.add_album"):
|
||||
return {}
|
||||
form_init = {"parent": SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)}
|
||||
return {
|
||||
"album_create_fragment": AlbumCreateFragment.as_fragment(initial=form_init)
|
||||
}
|
||||
|
||||
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
|
||||
if not self.request.user.has_perm("sas.add_album"):
|
||||
return {}
|
||||
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||
return {"album_create_fragment": {"owner": root_user}}
|
||||
|
||||
|
@ -12,3 +12,9 @@
|
||||
# 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
|
||||
|
||||
env = Env()
|
||||
env = Env(expand_vars=True)
|
||||
env.read_env()
|
||||
|
||||
|
||||
@ -106,6 +106,8 @@ INSTALLED_APPS = (
|
||||
"django_jinja",
|
||||
"ninja_extra",
|
||||
"haystack",
|
||||
"django_celery_results",
|
||||
"django_celery_beat",
|
||||
"captcha",
|
||||
"core",
|
||||
"club",
|
||||
@ -182,7 +184,6 @@ TEMPLATES = [
|
||||
"can_edit": "core.auth.mixins.can_edit",
|
||||
"can_view": "core.auth.mixins.can_view",
|
||||
"settings": "sith.settings",
|
||||
"Launderette": "launderette.models.Launderette",
|
||||
"Counter": "counter.models.Counter",
|
||||
"timezone": "django.utils.timezone",
|
||||
"get_sith": "com.views.sith",
|
||||
@ -336,6 +337,14 @@ EMAIL_BACKEND = env.str(
|
||||
EMAIL_HOST = env.str("EMAIL_HOST", default="localhost")
|
||||
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
|
||||
|
||||
SITH_URL = env.str("SITH_URL", default="127.0.0.1:8000")
|
||||
@ -658,10 +667,6 @@ with open(
|
||||
) as f:
|
||||
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 = [
|
||||
("POSTER_MODERATION", _("A new poster 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.request.RequestPanel",
|
||||
"debug_toolbar.panels.sql.SQLPanel",
|
||||
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
|
||||
"sith.toolbar_debug.TemplatesPanel",
|
||||
"debug_toolbar.panels.cache.CachePanel",
|
||||
"debug_toolbar.panels.signals.SignalsPanel",
|
||||
|
@ -42,10 +42,6 @@ urlpatterns = [
|
||||
path("club/", include(("club.urls", "club"), namespace="club")),
|
||||
path("counter/", include(("counter.urls", "counter"), namespace="counter")),
|
||||
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("election/", include(("election.urls", "election"), namespace="election")),
|
||||
path("forum/", include(("forum.urls", "forum"), namespace="forum")),
|
||||
|
Loading…
x
Reference in New Issue
Block a user