mirror of
https://github.com/ae-utbm/sith.git
synced 2025-01-02 13:11:17 +00:00
Merge pull request #973 from ae-utbm/taiste
Better counter, product management improvement, better form style and custom auth backend
This commit is contained in:
commit
673c427485
@ -136,25 +136,22 @@ type="EVENT").order_by('dates__start_date') %}
|
||||
<div id="birthdays">
|
||||
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
|
||||
<div id="birthdays_content">
|
||||
{% if user.is_subscribed %}
|
||||
{# Cache request for 1 hour #}
|
||||
{% cache 3600 "birthdays" %}
|
||||
<ul class="birthdays_year">
|
||||
{% for d in birthdays.dates('date_of_birth', 'year', 'DESC') %}
|
||||
<li>
|
||||
{% trans age=timezone.now().year - d.year %}{{ age }} year old{% endtrans %}
|
||||
<ul>
|
||||
{% for u in birthdays.filter(date_of_birth__year=d.year) %}
|
||||
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endcache %}
|
||||
{% else %}
|
||||
{%- if user.is_subscribed -%}
|
||||
<ul class="birthdays_year">
|
||||
{%- for year, users in birthdays -%}
|
||||
<li>
|
||||
{% trans age=timezone.now().year - year %}{{ age }} year old{% endtrans %}
|
||||
<ul>
|
||||
{%- for u in users -%}
|
||||
<li><a href="{{ u.get_absolute_url() }}">{{ u.get_short_name() }}</a></li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
</li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- else -%}
|
||||
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p>
|
||||
{% endif %}
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
from smtplib import SMTPRecipientsRefused
|
||||
|
||||
@ -374,13 +374,14 @@ class NewsListView(CanViewMixin, ListView):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["NewsDate"] = NewsDate
|
||||
kwargs["timedelta"] = timedelta
|
||||
kwargs["birthdays"] = (
|
||||
kwargs["birthdays"] = itertools.groupby(
|
||||
User.objects.filter(
|
||||
date_of_birth__month=localdate().month,
|
||||
date_of_birth__day=localdate().day,
|
||||
)
|
||||
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
||||
.order_by("-date_of_birth")
|
||||
.order_by("-date_of_birth"),
|
||||
key=lambda u: u.date_of_birth.year,
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
42
core/auth_backends.py
Normal file
42
core/auth_backends.py
Normal file
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from core.models import Group
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.models import User
|
||||
|
||||
|
||||
class SithModelBackend(ModelBackend):
|
||||
"""Custom auth backend for the Sith.
|
||||
|
||||
In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`,
|
||||
with the exception that group permissions are fetched slightly differently.
|
||||
Indeed, django tries by default to fetch the permissions associated
|
||||
with all the `django.contrib.auth.models.Group` of a user ;
|
||||
however, our User model overrides that, so the actual linked group model
|
||||
is [core.models.Group][].
|
||||
Instead of having the relation `auth_perm --> auth_group <-- core_user`,
|
||||
we have `auth_perm --> auth_group <-- core_group <-- core_user`.
|
||||
|
||||
Thus, this backend make the small tweaks necessary to make
|
||||
our custom models interact with the django auth.
|
||||
"""
|
||||
|
||||
def _get_group_permissions(self, user_obj: User):
|
||||
# union of querysets doesn't work if the queryset is ordered.
|
||||
# The empty `order_by` here are actually there to *remove*
|
||||
# any default ordering defined in managers or model Meta
|
||||
groups = user_obj.groups.order_by()
|
||||
if user_obj.is_subscribed:
|
||||
groups = groups.union(
|
||||
Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by()
|
||||
)
|
||||
return Permission.objects.filter(
|
||||
group__group__in=groups.values_list("pk", flat=True)
|
||||
)
|
@ -23,7 +23,7 @@
|
||||
from datetime import date, timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, NamedTuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
@ -31,6 +31,7 @@ from django.contrib.sites.models import Site
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import Image
|
||||
@ -56,6 +57,18 @@ from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
class PopulatedGroups(NamedTuple):
|
||||
root: Group
|
||||
public: Group
|
||||
subscribers: Group
|
||||
old_subscribers: Group
|
||||
sas_admin: Group
|
||||
com_admin: Group
|
||||
counter_admin: Group
|
||||
accounting_admin: Group
|
||||
pedagogy_admin: Group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||
@ -79,25 +92,7 @@ class Command(BaseCommand):
|
||||
|
||||
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
||||
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
||||
|
||||
root_group = Group.objects.create(name="Root")
|
||||
public_group = Group.objects.create(name="Public")
|
||||
subscribers = Group.objects.create(name="Subscribers")
|
||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||
Group.objects.create(name="Accounting admin")
|
||||
Group.objects.create(name="Communication admin")
|
||||
Group.objects.create(name="Counter admin")
|
||||
Group.objects.create(name="Banned from buying alcohol")
|
||||
Group.objects.create(name="Banned from counters")
|
||||
Group.objects.create(name="Banned to subscribe")
|
||||
Group.objects.create(name="SAS admin")
|
||||
Group.objects.create(name="Forum admin")
|
||||
Group.objects.create(name="Pedagogy admin")
|
||||
self.reset_index("core", "auth")
|
||||
|
||||
change_billing = Permission.objects.get(codename="change_billinginfo")
|
||||
add_billing = Permission.objects.get(codename="add_billinginfo")
|
||||
root_group.permissions.add(change_billing, add_billing)
|
||||
groups = self._create_groups()
|
||||
|
||||
root = User.objects.create_superuser(
|
||||
id=0,
|
||||
@ -155,7 +150,7 @@ class Command(BaseCommand):
|
||||
Counter.edit_groups.through.objects.bulk_create(bar_groups)
|
||||
self.reset_index("counter")
|
||||
|
||||
subscribers.viewable_files.add(home_root, club_root)
|
||||
groups.subscribers.viewable_files.add(home_root, club_root)
|
||||
|
||||
Weekmail().save()
|
||||
|
||||
@ -260,21 +255,11 @@ class Command(BaseCommand):
|
||||
)
|
||||
User.groups.through.objects.bulk_create(
|
||||
[
|
||||
User.groups.through(
|
||||
group_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter
|
||||
),
|
||||
User.groups.through(
|
||||
group_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable
|
||||
),
|
||||
User.groups.through(
|
||||
group_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity
|
||||
),
|
||||
User.groups.through(
|
||||
group_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu
|
||||
),
|
||||
User.groups.through(
|
||||
group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia
|
||||
),
|
||||
User.groups.through(group=groups.counter_admin, user=counter),
|
||||
User.groups.through(group=groups.accounting_admin, user=comptable),
|
||||
User.groups.through(group=groups.com_admin, user=comunity),
|
||||
User.groups.through(group=groups.pedagogy_admin, user=tutu),
|
||||
User.groups.through(group=groups.sas_admin, user=skia),
|
||||
]
|
||||
)
|
||||
for user in richard, sli, krophil, skia:
|
||||
@ -335,7 +320,7 @@ Welcome to the wiki page!
|
||||
content="Fonctionnement de la laverie",
|
||||
)
|
||||
|
||||
public_group.viewable_page.set(
|
||||
groups.public.viewable_page.set(
|
||||
[syntax_page, services_page, index_page, laundry_page]
|
||||
)
|
||||
|
||||
@ -512,8 +497,10 @@ Welcome to the wiki page!
|
||||
club=main_club,
|
||||
limit_age=18,
|
||||
)
|
||||
subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus)
|
||||
old_subscribers.products.add(cotis, cotis2)
|
||||
groups.subscribers.products.add(
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||
)
|
||||
groups.old_subscribers.products.add(cotis, cotis2)
|
||||
|
||||
mde = Counter.objects.get(name="MDE")
|
||||
mde.products.add(barb, cble, cons, dcons)
|
||||
@ -616,10 +603,10 @@ Welcome to the wiki page!
|
||||
start_date="1942-06-12 10:28:45+01",
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
el.view_groups.add(public_group)
|
||||
el.view_groups.add(groups.public)
|
||||
el.edit_groups.add(ae_board_group)
|
||||
el.candidature_groups.add(subscribers)
|
||||
el.vote_groups.add(subscribers)
|
||||
el.candidature_groups.add(groups.subscribers)
|
||||
el.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
listeT = ElectionList.objects.create(title="Troll", election=el)
|
||||
pres = Role.objects.create(
|
||||
@ -898,3 +885,102 @@ Welcome to the wiki page!
|
||||
start=s.subscription_start,
|
||||
)
|
||||
s.save()
|
||||
|
||||
def _create_groups(self) -> PopulatedGroups:
|
||||
perms = Permission.objects.all()
|
||||
|
||||
root_group = Group.objects.create(name="Root")
|
||||
root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
|
||||
# public has no permission.
|
||||
# Its purpose is not to link users to permissions,
|
||||
# but to other objects (like products)
|
||||
public_group = Group.objects.create(name="Public")
|
||||
|
||||
subscribers = Group.objects.create(name="Subscribers")
|
||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||
old_subscribers.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=[
|
||||
"view_user",
|
||||
"view_picture",
|
||||
"view_album",
|
||||
"view_peoplepicturerelation",
|
||||
"add_peoplepicturerelation",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
accounting_admin = Group.objects.create(name="Accounting admin")
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(content_type__app_label="accounting")
|
||||
| Q(
|
||||
codename__in=[
|
||||
"view_customer",
|
||||
"view_product",
|
||||
"change_product",
|
||||
"add_product",
|
||||
"view_producttype",
|
||||
"change_producttype",
|
||||
"add_producttype",
|
||||
"delete_selling",
|
||||
]
|
||||
)
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
com_admin = Group.objects.create(name="Communication admin")
|
||||
com_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="com").values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
counter_admin = Group.objects.create(name="Counter admin")
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
Q(content_type__app_label__in=["counter", "launderette"])
|
||||
& ~Q(codename__in=["delete_product", "delete_producttype"])
|
||||
)
|
||||
)
|
||||
)
|
||||
Group.objects.create(name="Banned from buying alcohol")
|
||||
Group.objects.create(name="Banned from counters")
|
||||
Group.objects.create(name="Banned to subscribe")
|
||||
sas_admin = Group.objects.create(name="SAS admin")
|
||||
sas_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
forum_admin = Group.objects.create(name="Forum admin")
|
||||
forum_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="forum").values_list(
|
||||
"pk", flat=True
|
||||
)
|
||||
)
|
||||
)
|
||||
pedagogy_admin = Group.objects.create(name="Pedagogy admin")
|
||||
pedagogy_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="pedagogy").values_list(
|
||||
"pk", flat=True
|
||||
)
|
||||
)
|
||||
)
|
||||
self.reset_index("core", "auth")
|
||||
|
||||
return PopulatedGroups(
|
||||
root=root_group,
|
||||
public=public_group,
|
||||
subscribers=subscribers,
|
||||
old_subscribers=old_subscribers,
|
||||
com_admin=com_admin,
|
||||
counter_admin=counter_admin,
|
||||
accounting_admin=accounting_admin,
|
||||
sas_admin=sas_admin,
|
||||
pedagogy_admin=pedagogy_admin,
|
||||
)
|
||||
|
@ -578,14 +578,6 @@ class User(AbstractUser):
|
||||
return "%s (%s)" % (self.get_full_name(), self.nick_name)
|
||||
return self.get_full_name()
|
||||
|
||||
def get_age(self):
|
||||
"""Returns the age."""
|
||||
today = timezone.now()
|
||||
born = self.date_of_birth
|
||||
return (
|
||||
today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
)
|
||||
|
||||
def get_family(
|
||||
self,
|
||||
godfathers_depth: NonNegativeInt = 4,
|
||||
|
@ -22,10 +22,13 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
|
||||
|
||||
// TODO : If one day a test workflow is made for JS in this project
|
||||
// please test this function. A all cost.
|
||||
/**
|
||||
* Load complete dataset from paginated routes.
|
||||
*/
|
||||
export const paginated = async <T>(
|
||||
endpoint: PaginatedEndpoint<T>,
|
||||
options?: PaginatedRequest,
|
||||
) => {
|
||||
): Promise<T[]> => {
|
||||
const maxPerPage = 199;
|
||||
const queryParams = options ?? {};
|
||||
queryParams.query = queryParams.query ?? {};
|
||||
|
49
core/static/bundled/utils/csv.ts
Normal file
49
core/static/bundled/utils/csv.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { NestedKeyOf } from "#core:utils/types";
|
||||
|
||||
interface StringifyOptions<T extends object> {
|
||||
/** The columns to include in the resulting CSV. */
|
||||
columns: readonly NestedKeyOf<T>[];
|
||||
/** Content of the first row */
|
||||
titleRow?: readonly string[];
|
||||
}
|
||||
|
||||
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
|
||||
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
|
||||
let res = obj[path.shift() as keyof T];
|
||||
for (const node of path) {
|
||||
if (res === null) {
|
||||
break;
|
||||
}
|
||||
res = res[node];
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the content the string to make sure it won't break
|
||||
* the resulting csv.
|
||||
* cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules
|
||||
*/
|
||||
function sanitizeCell(content: string): string {
|
||||
return `"${content.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export const csv = {
|
||||
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
|
||||
const columns = options.columns;
|
||||
const content = objs
|
||||
.map((obj) => {
|
||||
return columns
|
||||
.map((col) => {
|
||||
return sanitizeCell((getNested(obj, col) ?? "").toString());
|
||||
})
|
||||
.join(",");
|
||||
})
|
||||
.join("\n");
|
||||
if (!options.titleRow) {
|
||||
return content;
|
||||
}
|
||||
const firstRow = options.titleRow.map(sanitizeCell).join(",");
|
||||
return `${firstRow}\n${content}`;
|
||||
},
|
||||
};
|
37
core/static/bundled/utils/types.d.ts
vendored
Normal file
37
core/static/bundled/utils/types.d.ts
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A key of an object, or of one of its descendants.
|
||||
*
|
||||
* Example :
|
||||
* ```typescript
|
||||
* interface Foo {
|
||||
* foo_inner: number;
|
||||
* }
|
||||
*
|
||||
* interface Bar {
|
||||
* foo: Foo;
|
||||
* }
|
||||
*
|
||||
* const foo = (key: NestedKeyOf<Bar>) {
|
||||
* console.log(key);
|
||||
* }
|
||||
*
|
||||
* foo("foo.foo_inner"); // OK
|
||||
* foo("foo.bar"); // FAIL
|
||||
* ```
|
||||
*/
|
||||
export type NestedKeyOf<T extends object> = {
|
||||
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<T[Key], `${Key}`>;
|
||||
}[keyof T & (string | number)];
|
||||
|
||||
type NestedKeyOfInner<T extends object> = {
|
||||
[Key in keyof T & (string | number)]: NestedKeyOfHandleValue<
|
||||
T[Key],
|
||||
`['${Key}']` | `.${Key}`
|
||||
>;
|
||||
}[keyof T & (string | number)];
|
||||
|
||||
type NestedKeyOfHandleValue<T, Text extends string> = T extends unknown[]
|
||||
? Text
|
||||
: T extends object
|
||||
? Text | `${Text}${NestedKeyOfInner<T>}`
|
||||
: Text;
|
96
core/static/core/components/card.scss
Normal file
96
core/static/core/components/card.scss
Normal file
@ -0,0 +1,96 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
@mixin row-layout {
|
||||
min-height: 100px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
.card-image {
|
||||
max-width: 75px;
|
||||
}
|
||||
.card-content {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 20px 10px;
|
||||
height: fit-content;
|
||||
width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
animation: bg-in-out 1s ease;
|
||||
background-color: rgb(216, 236, 255);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 70px;
|
||||
max-height: 70px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
line-height: 70px;
|
||||
}
|
||||
|
||||
i.card-image {
|
||||
color: black;
|
||||
text-align: center;
|
||||
background-color: rgba(173, 173, 173, 0.2);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: black;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-in-out {
|
||||
0% {
|
||||
background-color: white;
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(216, 236, 255);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 765px) {
|
||||
@include row-layout
|
||||
}
|
||||
|
||||
// When combined with card, card-row display the card in a row layout,
|
||||
// whatever the size of the screen.
|
||||
&.card-row {
|
||||
@include row-layout
|
||||
}
|
||||
}
|
||||
|
@ -1,95 +1,145 @@
|
||||
@import "colors";
|
||||
|
||||
/**
|
||||
* Style related to forms
|
||||
* Style related to forms and form inputs
|
||||
*/
|
||||
|
||||
a.button,
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
/**
|
||||
* Inputs that are not enclosed in a form element.
|
||||
*/
|
||||
:not(form) {
|
||||
a.button,
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
|
||||
a.button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.button:not(:disabled),
|
||||
button:not(:disabled),
|
||||
input[type="button"]:not(:disabled),
|
||||
input[type="submit"]:not(:disabled),
|
||||
input[type="reset"]:not(:disabled),
|
||||
input[type="checkbox"]:not(:disabled),
|
||||
input[type="file"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea[type="text"],
|
||||
[type="number"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 7px;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
select {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
background-color: $background-button-color;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: $primary-dark-color;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
&:hover {
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: $primary-color;
|
||||
a.button,
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
input[type="file"] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.button:not(:disabled),
|
||||
button:not(:disabled),
|
||||
input[type="button"]:not(:disabled),
|
||||
input[type="submit"]:not(:disabled),
|
||||
input[type="reset"]:not(:disabled),
|
||||
input[type="checkbox"]:not(:disabled),
|
||||
input[type="file"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea[type="text"],
|
||||
[type="number"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 7px;
|
||||
font-size: 1.2em;
|
||||
border-radius: 5px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
select {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
background-color: $background-button-color;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: $primary-dark-color;
|
||||
|
||||
&:hover {
|
||||
color: $primary-light-color;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0 auto 10px;
|
||||
// Input size - used for height/padding calculations
|
||||
--nf-input-size: 1rem;
|
||||
|
||||
--nf-input-font-size: calc(var(--nf-input-size) * 0.875);
|
||||
--nf-small-font-size: calc(var(--nf-input-size) * 0.875);
|
||||
|
||||
// Input
|
||||
--nf-input-color: $text-color;
|
||||
--nf-input-border-radius: 0.25rem;
|
||||
--nf-input-placeholder-color: #929292;
|
||||
--nf-input-border-color: #c0c4c9;
|
||||
--nf-input-border-width: 1px;
|
||||
--nf-input-border-style: solid;
|
||||
--nf-input-border-bottom-width: 2px;
|
||||
--nf-input-focus-border-color: #3b4ce2;
|
||||
--nf-input-background-color: #f3f6f7;
|
||||
|
||||
// Valid/invalid
|
||||
--nf-invalid-input-border-color: var(--nf-input-border-color);
|
||||
--nf-invalid-input-background-color: var(--nf-input-background-color);
|
||||
--nf-invalid-input-color: var(--nf-input-color);
|
||||
--nf-valid-input-border-color: var(--nf-input-border-color);
|
||||
--nf-valid-input-background-color: var(--nf-input-background-color);
|
||||
--nf-valid-input-color: inherit;
|
||||
--nf-invalid-input-border-bottom-color: red;
|
||||
--nf-valid-input-border-bottom-color: green;
|
||||
|
||||
// Label variables
|
||||
--nf-label-font-size: var(--nf-small-font-size);
|
||||
--nf-label-color: #374151;
|
||||
--nf-label-font-weight: 500;
|
||||
|
||||
// Slider variables
|
||||
--nf-slider-track-background: #dfdfdf;
|
||||
--nf-slider-track-height: 0.25rem;
|
||||
--nf-slider-thumb-size: calc(var(--nf-slider-track-height) * 4);
|
||||
--nf-slider-track-border-radius: var(--nf-slider-track-height);
|
||||
--nf-slider-thumb-border-width: 2px;
|
||||
--nf-slider-thumb-border-focus-width: 1px;
|
||||
--nf-slider-thumb-border-color: #ffffff;
|
||||
--nf-slider-thumb-background: var(--nf-input-focus-border-color);
|
||||
|
||||
display: block;
|
||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.helptext {
|
||||
margin-top: .25rem;
|
||||
@ -107,9 +157,16 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
// ------------- LABEL
|
||||
label, legend {
|
||||
font-weight: var(--nf-label-font-weight);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: calc(var(--nf-input-size) / 2);
|
||||
white-space: initial;
|
||||
|
||||
+ small {
|
||||
font-style: initial;
|
||||
}
|
||||
|
||||
&.required:after {
|
||||
margin-left: 4px;
|
||||
@ -118,7 +175,555 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
// wrap texts
|
||||
label, legend, ul.errorlist>li, .helptext {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.choose_file_widget {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// ------------- SMALL
|
||||
|
||||
small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
opacity: 0.75;
|
||||
font-size: var(--nf-small-font-size);
|
||||
margin-bottom: calc(var(--nf-input-size) * 0.75);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- LEGEND
|
||||
|
||||
legend {
|
||||
font-weight: var(--nf-label-font-weight);
|
||||
display: block;
|
||||
margin-bottom: calc(var(--nf-input-size) / 5);
|
||||
}
|
||||
|
||||
.form-group,
|
||||
> p,
|
||||
> div {
|
||||
margin-top: calc(var(--nf-input-size) / 2);
|
||||
}
|
||||
|
||||
// ------------ ERROR LIST
|
||||
ul.errorlist {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
opacity: 60%;
|
||||
color: var(--nf-invalid-input-border-bottom-color);
|
||||
|
||||
> li {
|
||||
text-align: left;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
input[type="url"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="week"],
|
||||
input[type="time"],
|
||||
input[type="month"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
select {
|
||||
min-width: 300px;
|
||||
|
||||
&.grow {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
input[type="url"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="week"],
|
||||
input[type="time"],
|
||||
input[type="month"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
select {
|
||||
background: var(--nf-input-background-color);
|
||||
font-size: var(--nf-input-font-size);
|
||||
border-color: var(--nf-input-border-color);
|
||||
border-width: var(--nf-input-border-width);
|
||||
border-style: var(--nf-input-border-style);
|
||||
box-shadow: none;
|
||||
border-radius: var(--nf-input-border-radius);
|
||||
border-bottom-width: var(--nf-input-border-bottom-width);
|
||||
color: var(--nf-input-color);
|
||||
max-width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: calc(var(--nf-input-size) * 0.65);
|
||||
line-height: normal;
|
||||
appearance: none;
|
||||
transition: all 0.15s ease-out;
|
||||
|
||||
// ------------- VALID/INVALID
|
||||
|
||||
&.error {
|
||||
&:not(:placeholder-shown):invalid {
|
||||
background-color: var(--nf-invalid-input-background-color);
|
||||
border-color: var(--nf-valid-input-border-color);
|
||||
border-bottom-color: var(--nf-invalid-input-border-bottom-color);
|
||||
color: var(--nf-invalid-input-color);
|
||||
|
||||
// Reset to default when focus
|
||||
|
||||
&:focus {
|
||||
background-color: var(--nf-input-background-color);
|
||||
border-color: var(--nf-input-border-color);
|
||||
color: var(--nf-input-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown):valid {
|
||||
background-color: var(--nf-valid-input-background-color);
|
||||
border-color: var(--nf-valid-input-border-color);
|
||||
border-bottom-color: var(--nf-valid-input-border-bottom-color);
|
||||
color: var(--nf-valid-input-color);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- DISABLED
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// -------- PLACEHOLDERS
|
||||
|
||||
&::-webkit-input-placeholder {
|
||||
color: var(--nf-input-placeholder-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:-ms-input-placeholder {
|
||||
color: var(--nf-input-placeholder-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&::-moz-placeholder {
|
||||
color: var(--nf-input-placeholder-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
&:-moz-placeholder {
|
||||
color: var(--nf-input-placeholder-color);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
// -------- FOCUS
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--nf-input-focus-border-color);
|
||||
}
|
||||
|
||||
// -------- ADDITIONAL TEXT BENEATH INPUT FIELDS
|
||||
|
||||
+ small {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
// -------- ICONS
|
||||
|
||||
--icon-padding: calc(var(--nf-input-size) * 2.25);
|
||||
--icon-background-offset: calc(var(--nf-input-size) * 0.75);
|
||||
|
||||
&.icon-left {
|
||||
background-position: left var(--icon-background-offset) bottom 50%;
|
||||
padding-left: var(--icon-padding);
|
||||
background-size: var(--nf-input-size);
|
||||
}
|
||||
|
||||
&.icon-right {
|
||||
background-position: right var(--icon-background-offset) bottom 50%;
|
||||
padding-right: var(--icon-padding);
|
||||
background-size: var(--nf-input-size);
|
||||
}
|
||||
|
||||
// When a field has a icon and is autofilled, the background image is removed
|
||||
// by the browser. To negate this we reset the padding, not great but okay
|
||||
|
||||
&:-webkit-autofill {
|
||||
padding: calc(var(--nf-input-size) * 0.75) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- SEARCH
|
||||
|
||||
input[type="search"] {
|
||||
&:placeholder-shown {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
|
||||
background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||
padding-left: calc(var(--nf-input-size) * 2.25);
|
||||
background-size: var(--nf-input-size);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
width: var(--nf-input-size);
|
||||
height: var(--nf-input-size);
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
&:focus {
|
||||
padding-left: calc(var(--nf-input-size) * 0.75);
|
||||
background-position: left calc(var(--nf-input-size) * -1) bottom 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- EMAIL
|
||||
|
||||
input[type="email"][class^="icon"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
// -------- TEL
|
||||
|
||||
input[type="tel"][class^="icon"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
// -------- URL
|
||||
|
||||
input[type="url"][class^="icon"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
// -------- PASSWORD
|
||||
|
||||
input[type="password"] {
|
||||
letter-spacing: 2px;
|
||||
|
||||
&[class^="icon"] {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- RANGE
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// NOTE: for some reason grouping these doesn't work (just like :placeholder)
|
||||
|
||||
@mixin track {
|
||||
width: 100%;
|
||||
height: var(--nf-slider-track-height);
|
||||
background: var(--nf-slider-track-background);
|
||||
border-radius: var(--nf-slider-track-border-radius);
|
||||
}
|
||||
|
||||
@mixin thumb {
|
||||
height: var(--nf-slider-thumb-size);
|
||||
width: var(--nf-slider-thumb-size);
|
||||
border-radius: var(--nf-slider-thumb-size);
|
||||
background: var(--nf-slider-thumb-background);
|
||||
border: 0;
|
||||
border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color);
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
@mixin thumb-focus {
|
||||
box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background);
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
@include track;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
@include track;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
@include thumb;
|
||||
margin-top: calc(
|
||||
(
|
||||
calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) *
|
||||
0.5
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include thumb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&:focus::-webkit-slider-thumb {
|
||||
@include thumb-focus;
|
||||
}
|
||||
|
||||
&:focus::-moz-range-thumb {
|
||||
@include thumb-focus;
|
||||
}
|
||||
}
|
||||
|
||||
// -------- COLOR
|
||||
|
||||
input[type="color"] {
|
||||
border: var(--nf-input-border-width) solid var(--nf-input-border-color);
|
||||
border-bottom-width: var(--nf-input-border-bottom-width);
|
||||
height: calc(var(--nf-input-size) * 2);
|
||||
border-radius: var(--nf-input-border-radius);
|
||||
padding: calc(var(--nf-input-border-width) * 2);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--nf-input-focus-border-color);
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 5%;
|
||||
}
|
||||
|
||||
@mixin swatch {
|
||||
border-radius: calc(var(--nf-input-border-radius) / 2);
|
||||
border: none;
|
||||
}
|
||||
|
||||
&::-moz-color-swatch {
|
||||
@include swatch;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
@include swatch;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------- NUMBER
|
||||
|
||||
input[type="number"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// --------------- DATES
|
||||
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="week"],
|
||||
input[type="month"] {
|
||||
min-width: 300px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
input[type="time"] {
|
||||
min-width: 6em;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="datetime-local"],
|
||||
input[type="week"],
|
||||
input[type="time"],
|
||||
input[type="month"] {
|
||||
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: var(--nf-input-size);
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// FireFox reset
|
||||
// FF has restricted control of styling the date/time inputs.
|
||||
// That's why we don't show icons for FF users, and leave basic styling in place.
|
||||
@-moz-document url-prefix() {
|
||||
min-width: auto;
|
||||
width: auto;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------- TEXAREA
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// --------------- CHECKBOX/RADIO
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: var(--nf-input-size);
|
||||
height: var(--nf-input-size);
|
||||
padding: inherit;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
border-radius: calc(var(--nf-input-border-radius) / 2);
|
||||
border-width: var(--nf-input-border-width);
|
||||
cursor: pointer;
|
||||
background-position: center center;
|
||||
|
||||
&:focus:not(:checked) {
|
||||
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color);
|
||||
}
|
||||
|
||||
+ label {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
padding-left: calc(var(--nf-input-size) / 2.5);
|
||||
font-weight: normal;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
max-width: calc(100% - calc(var(--nf-input-size) * 2));
|
||||
line-height: normal;
|
||||
|
||||
> small {
|
||||
margin-top: calc(var(--nf-input-size) / 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
&:checked {
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%;
|
||||
background-color: var(--nf-input-focus-border-color);
|
||||
border-color: var(--nf-input-focus-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
border-radius: 100%;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--nf-input-focus-border-color);
|
||||
border-color: var(--nf-input-focus-border-color);
|
||||
box-shadow: 0 0 0 3px white inset;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------- SWITCH
|
||||
|
||||
--switch-orb-size: var(--nf-input-size);
|
||||
--switch-orb-offset: calc(var(--nf-input-border-width) * 2);
|
||||
--switch-width: calc(var(--nf-input-size) * 2.5);
|
||||
--switch-height: calc(
|
||||
calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset)
|
||||
);
|
||||
|
||||
input[type="checkbox"].switch {
|
||||
width: var(--switch-width);
|
||||
height: var(--switch-height);
|
||||
border-radius: var(--switch-height);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
background: var(--nf-input-border-color);
|
||||
border-radius: var(--switch-orb-size);
|
||||
height: var(--switch-orb-size);
|
||||
left: var(--switch-orb-offset);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: var(--switch-orb-size);
|
||||
content: "";
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
+ label {
|
||||
margin-top: calc(var(--switch-height) / 8);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background: var(--nf-input-focus-border-color) none initial;
|
||||
|
||||
&::after {
|
||||
transform: translateY(-50%) translateX(
|
||||
calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset))
|
||||
);
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- FILE
|
||||
|
||||
input[type="file"] {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
padding: calc(var(--nf-input-size) / 2);
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--nf-input-border-radius);
|
||||
border: 1px dashed var(--nf-input-border-color);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--nf-input-focus-border-color);
|
||||
}
|
||||
|
||||
@mixin button {
|
||||
background: var(--nf-input-focus-border-color);
|
||||
border: 0;
|
||||
appearance: none;
|
||||
border-radius: var(--nf-input-border-radius);
|
||||
color: white;
|
||||
margin-right: 0.75rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::file-selector-button {
|
||||
@include button();
|
||||
}
|
||||
|
||||
&::-webkit-file-upload-button {
|
||||
@include button();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- SELECT
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: var(--nf-input-size);
|
||||
}
|
||||
}
|
||||
|
@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
|
||||
>a {
|
||||
color: $text-color;
|
||||
> a {
|
||||
color: $text-color!important;
|
||||
}
|
||||
|
||||
&:hover>a {
|
||||
@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d;
|
||||
}
|
||||
|
||||
>input[type=text] {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
border: unset;
|
||||
height: 35px;
|
||||
border-radius: 5px;
|
||||
font-size: .9em;
|
||||
|
@ -19,6 +19,13 @@ body {
|
||||
--loading-stroke: 5px;
|
||||
--loading-duration: 1s;
|
||||
position: relative;
|
||||
|
||||
&.aria-busy-grow {
|
||||
// Make sure the element take enough place to hold the loading wheel
|
||||
min-height: calc((var(--loading-size)) * 1.5);
|
||||
min-width: calc((var(--loading-size)) * 1.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
[aria-busy]:after {
|
||||
@ -198,6 +205,10 @@ body {
|
||||
margin: 20px auto 0;
|
||||
|
||||
/*---------------------------------NAV---------------------------------*/
|
||||
a.btn {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 15px;
|
||||
font-weight: normal;
|
||||
@ -252,6 +263,13 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A spacer below an element. Somewhat cleaner than putting <br/> everywhere.
|
||||
*/
|
||||
.margin-bottom {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/*--------------------------------CONTENT------------------------------*/
|
||||
#quick_notif {
|
||||
width: 100%;
|
||||
@ -319,7 +337,8 @@ body {
|
||||
margin-left: -125px;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
z-index: 10;
|
||||
/* to get on top of tomselect */
|
||||
left: 50%;
|
||||
top: 60px;
|
||||
text-align: center;
|
||||
@ -409,6 +428,31 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
$col-gap: 1rem;
|
||||
$row-gap: 0.5rem;
|
||||
|
||||
&.gap {
|
||||
column-gap: var($col-gap);
|
||||
row-gap: var($row-gap);
|
||||
}
|
||||
|
||||
@for $i from 2 through 5 {
|
||||
&.gap-#{$i}x {
|
||||
column-gap: $i * $col-gap;
|
||||
row-gap: $i * $row-gap;
|
||||
}
|
||||
}
|
||||
|
||||
// Make an element of the row take as much space as needed
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/*---------------------------------NEWS--------------------------------*/
|
||||
#news {
|
||||
display: flex;
|
||||
@ -1210,40 +1254,6 @@ u,
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#bar-ui {
|
||||
padding: 0.4em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
#products {
|
||||
flex-basis: 100%;
|
||||
margin: 0.2em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#click_form {
|
||||
flex: auto;
|
||||
margin: 0.2em;
|
||||
}
|
||||
|
||||
#user_info {
|
||||
flex: auto;
|
||||
padding: 0.5em;
|
||||
margin: 0.2em;
|
||||
height: 100%;
|
||||
background: $secondary-neutral-light-color;
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
input {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*-----------------------------USER PROFILE----------------------------*/
|
||||
|
||||
.user_mini_profile {
|
||||
@ -1419,16 +1429,6 @@ footer {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
#user_edit {
|
||||
* {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
#cash_summary_form label,
|
||||
.inline {
|
||||
display: inline;
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@ -69,7 +71,7 @@ main {
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
|
||||
> span {
|
||||
font-size: small;
|
||||
|
@ -1,26 +1,9 @@
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.field-error {
|
||||
height: auto !important;
|
||||
|
||||
> ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
color: indianred;
|
||||
|
||||
> li {
|
||||
text-align: left !important;
|
||||
line-height: normal;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile {
|
||||
&-visible {
|
||||
display: flex;
|
||||
@ -87,11 +70,7 @@
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
> i {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
>p {
|
||||
> p {
|
||||
text-align: left !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
@ -107,16 +86,6 @@
|
||||
> div {
|
||||
max-width: 100%;
|
||||
|
||||
> input {
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
> button {
|
||||
min-width: 30%;
|
||||
}
|
||||
|
||||
@media (min-width: 750px) {
|
||||
height: auto;
|
||||
align-items: center;
|
||||
@ -124,8 +93,8 @@
|
||||
overflow: hidden;
|
||||
|
||||
> input {
|
||||
width: 70%;
|
||||
font-size: .6em;
|
||||
|
||||
&::file-selector-button {
|
||||
height: 30px;
|
||||
}
|
||||
@ -167,7 +136,7 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
>* {
|
||||
> * {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
|
||||
@ -181,45 +150,22 @@
|
||||
}
|
||||
|
||||
&-content {
|
||||
|
||||
>* {
|
||||
> * {
|
||||
box-sizing: border-box;
|
||||
text-align: left !important;
|
||||
line-height: 40px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
|
||||
>* {
|
||||
> * {
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
>textarea {
|
||||
height: 120px;
|
||||
min-height: 40px;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
line-height: initial;
|
||||
|
||||
@media (max-width: 750px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
>input[type="file"] {
|
||||
font-size: small;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
>input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
float: left;
|
||||
}
|
||||
textarea {
|
||||
height: 7rem;
|
||||
}
|
||||
.final-actions {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
@ -60,7 +60,7 @@
|
||||
{% endif %}
|
||||
{% if user.date_of_birth %}
|
||||
<div class="user_mini_profile_dob">
|
||||
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }})
|
||||
{{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -140,7 +140,7 @@
|
||||
nb_page (str): call to a javascript function or variable returning
|
||||
the maximum number of pages to paginate
|
||||
#}
|
||||
<nav class="pagination" x-show="{{ nb_pages }} > 1">
|
||||
<nav class="pagination" x-show="{{ nb_pages }} > 1" x-cloak>
|
||||
{# Adding the prevent here is important, because otherwise,
|
||||
clicking on the pagination buttons could submit the picture management form
|
||||
and reload the page #}
|
||||
|
@ -63,9 +63,7 @@
|
||||
{%- trans -%}Delete{%- endtrans -%}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
{{ form[field_name].label }}
|
||||
</p>
|
||||
{{ form[field_name].label_tag() }}
|
||||
{{ form[field_name].errors }}
|
||||
{%- else -%}
|
||||
<em>{% trans %}To edit your profile picture, ask a member of the AE{% endtrans %}</em>
|
||||
@ -118,68 +116,68 @@
|
||||
{# All fields #}
|
||||
<div class="profile-fields">
|
||||
{%- for field in form -%}
|
||||
{%-
|
||||
if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"]
|
||||
-%}
|
||||
{%- continue -%}
|
||||
{%- endif -%}
|
||||
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
|
||||
{%- continue -%}
|
||||
{%- endif -%}
|
||||
|
||||
<div class="profile-field">
|
||||
<div class="profile-field-label">{{ field.label }}</div>
|
||||
<div class="profile-field-content">
|
||||
{{ field }}
|
||||
{%- if field.errors -%}
|
||||
<div class="field-error">{{ field.errors }}</div>
|
||||
{%- endif -%}
|
||||
<div class="profile-field">
|
||||
<div class="profile-field-label">{{ field.label }}</div>
|
||||
<div class="profile-field-content">
|
||||
{{ field }}
|
||||
{%- if field.errors -%}
|
||||
<div class="field-error">{{ field.errors }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
{# Textareas #}
|
||||
<div class="profile-fields">
|
||||
{%- for field in [form.quote, form.forum_signature] -%}
|
||||
<div class="profile-field">
|
||||
<div class="profile-field-label">{{ field.label }}</div>
|
||||
<div class="profile-field-content">
|
||||
{{ field }}
|
||||
{%- if field.errors -%}
|
||||
<div class="field-error">{{ field.errors }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
<div class="profile-fields">
|
||||
{%- for field in [form.quote, form.forum_signature] -%}
|
||||
<div class="profile-field">
|
||||
<div class="profile-field-label">{{ field.label }}</div>
|
||||
<div class="profile-field-content">
|
||||
{{ field }}
|
||||
{%- if field.errors -%}
|
||||
<div class="field-error">{{ field.errors }}</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
{# Checkboxes #}
|
||||
<div class="profile-visible">
|
||||
{{ form.is_subscriber_viewable }}
|
||||
{{ form.is_subscriber_viewable.label }}
|
||||
</div>
|
||||
<div class="profile-visible">
|
||||
{{ form.is_subscriber_viewable }}
|
||||
{{ form.is_subscriber_viewable.label }}
|
||||
</div>
|
||||
<div class="final-actions">
|
||||
|
||||
{%- if form.instance == user -%}
|
||||
<p>
|
||||
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
||||
</p>
|
||||
{%- elif user.is_root -%}
|
||||
<p>
|
||||
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
||||
{%- trans -%}Change user password{%- endtrans -%}
|
||||
</a>
|
||||
</p>
|
||||
{%- endif -%}
|
||||
|
||||
<p>
|
||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{%- if form.instance == user -%}
|
||||
<p>
|
||||
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
||||
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
||||
<br />
|
||||
{%- if form.instance.customer -%}
|
||||
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
||||
{%- endif -%}
|
||||
</p>
|
||||
{%- elif user.is_root -%}
|
||||
<p>
|
||||
<a href="{{ url('core:password_root_change', user_id=form.instance.id) }}">
|
||||
{%- trans -%}Change user password{%- endtrans -%}
|
||||
</a>
|
||||
</p>
|
||||
{%- endif -%}
|
||||
|
||||
<p>
|
||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p>
|
||||
<em>{%- trans -%}Username: {%- endtrans -%} {{ form.instance.username }}</em>
|
||||
<br />
|
||||
{%- if form.instance.customer -%}
|
||||
<em>{%- trans -%}Account number: {%- endtrans -%} {{ form.instance.customer.account_id }}</em>
|
||||
{%- endif -%}
|
||||
</p>
|
||||
|
||||
{%- endblock -%}
|
||||
|
@ -173,6 +173,9 @@ class RegisteringForm(UserCreationForm):
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
"""Form handling the user profile, managing the files"""
|
||||
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
@ -293,6 +296,7 @@ class UserGroupsForm(forms.ModelForm):
|
||||
queryset=RealGroup.objects.all(),
|
||||
widget=CheckboxSelectMultiple,
|
||||
label=_("Groups"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -47,6 +47,8 @@ class BillingInfoForm(forms.ModelForm):
|
||||
class StudentCardForm(forms.ModelForm):
|
||||
"""Form for adding student cards"""
|
||||
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = StudentCard
|
||||
fields = ["uid"]
|
||||
@ -87,7 +89,7 @@ class GetUserForm(forms.Form):
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
cus = None
|
||||
customer = None
|
||||
if cleaned_data["code"] != "":
|
||||
if len(cleaned_data["code"]) == StudentCard.UID_SIZE:
|
||||
card = (
|
||||
@ -96,17 +98,18 @@ class GetUserForm(forms.Form):
|
||||
.first()
|
||||
)
|
||||
if card is not None:
|
||||
cus = card.customer
|
||||
if cus is None:
|
||||
cus = Customer.objects.filter(
|
||||
customer = card.customer
|
||||
if customer is None:
|
||||
customer = Customer.objects.filter(
|
||||
account_id__iexact=cleaned_data["code"]
|
||||
).first()
|
||||
elif cleaned_data["id"] is not None:
|
||||
cus = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
if cus is None or not cus.can_buy:
|
||||
elif cleaned_data["id"]:
|
||||
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
|
||||
|
||||
if customer is None or not customer.can_buy:
|
||||
raise forms.ValidationError(_("User not found"))
|
||||
cleaned_data["user_id"] = cus.user.id
|
||||
cleaned_data["user"] = cus.user
|
||||
cleaned_data["user_id"] = customer.user.id
|
||||
cleaned_data["user"] = customer.user
|
||||
return cleaned_data
|
||||
|
||||
|
||||
|
17
counter/migrations/0029_alter_selling_label.py
Normal file
17
counter/migrations/0029_alter_selling_label.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-22 22:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("counter", "0028_alter_producttype_comment_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="selling",
|
||||
name="label",
|
||||
field=models.CharField(max_length=128, verbose_name="label"),
|
||||
),
|
||||
]
|
@ -21,7 +21,7 @@ import string
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Self, Tuple
|
||||
from typing import Self
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
@ -138,7 +138,7 @@ class Customer(models.Model):
|
||||
return (date.today() - subscription.subscription_end) < timedelta(days=90)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, user: User) -> Tuple[Customer, bool]:
|
||||
def get_or_create(cls, user: User) -> tuple[Customer, bool]:
|
||||
"""Work in pretty much the same way as the usual get_or_create method,
|
||||
but with the default field replaced by some under the hood.
|
||||
|
||||
@ -327,6 +327,8 @@ class ProductType(OrderedModel):
|
||||
class Product(models.Model):
|
||||
"""A product, with all its related information."""
|
||||
|
||||
QUANTITY_FOR_TRAY_PRICE = 6
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
description = models.TextField(_("description"), default="")
|
||||
product_type = models.ForeignKey(
|
||||
@ -525,7 +527,7 @@ class Counter(models.Model):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
mem = self.club.get_membership_for(user)
|
||||
if mem and mem.role >= 7:
|
||||
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
return True
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
@ -657,6 +659,34 @@ class Counter(models.Model):
|
||||
# but they share the same primary key
|
||||
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
|
||||
|
||||
def get_products_for(self, customer: Customer) -> list[Product]:
|
||||
"""
|
||||
Get all allowed products for the provided customer on this counter
|
||||
Prices will be annotated
|
||||
"""
|
||||
|
||||
products = self.products.select_related("product_type").prefetch_related(
|
||||
"buying_groups"
|
||||
)
|
||||
|
||||
# Only include age appropriate products
|
||||
age = customer.user.age
|
||||
if customer.user.is_banned_alcohol:
|
||||
age = min(age, 17)
|
||||
products = products.filter(limit_age__lte=age)
|
||||
|
||||
# Compute special price for customer if he is a barmen on that bar
|
||||
if self.customer_is_barman(customer):
|
||||
products = products.annotate(price=F("special_selling_price"))
|
||||
else:
|
||||
products = products.annotate(price=F("selling_price"))
|
||||
|
||||
return [
|
||||
product
|
||||
for product in products.all()
|
||||
if product.can_be_sold_to(customer.user)
|
||||
]
|
||||
|
||||
|
||||
class RefillingQuerySet(models.QuerySet):
|
||||
def annotate_total(self) -> Self:
|
||||
@ -761,7 +791,8 @@ class SellingQuerySet(models.QuerySet):
|
||||
class Selling(models.Model):
|
||||
"""Handle the sellings."""
|
||||
|
||||
label = models.CharField(_("label"), max_length=64)
|
||||
# We make sure that sellings have a way begger label than any product name is allowed to
|
||||
label = models.CharField(_("label"), max_length=128)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
related_name="sellings",
|
||||
|
25
counter/static/bundled/counter/basket.ts
Normal file
25
counter/static/bundled/counter/basket.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { Product } from "#counter:counter/types";
|
||||
|
||||
export class BasketItem {
|
||||
quantity: number;
|
||||
product: Product;
|
||||
quantityForTrayPrice: number;
|
||||
errors: string[];
|
||||
|
||||
constructor(product: Product, quantity: number) {
|
||||
this.quantity = quantity;
|
||||
this.product = product;
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
getBonusQuantity(): number {
|
||||
if (!this.product.hasTrayPrice) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(this.quantity / this.product.quantityForTrayPrice);
|
||||
}
|
||||
|
||||
sum(): number {
|
||||
return (this.quantity - this.getBonusQuantity()) * this.product.price;
|
||||
}
|
||||
}
|
@ -4,9 +4,11 @@ import type { TomOption } from "tom-select/dist/types/types";
|
||||
import type { escape_html } from "tom-select/dist/types/utils";
|
||||
import {
|
||||
type CounterSchema,
|
||||
type ProductTypeSchema,
|
||||
type SimpleProductSchema,
|
||||
counterSearchCounter,
|
||||
productSearchProducts,
|
||||
producttypeFetchAll,
|
||||
} from "#openapi";
|
||||
|
||||
@registerComponent("product-ajax-select")
|
||||
@ -34,6 +36,37 @@ export class ProductAjaxSelect extends AjaxSelect {
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("product-type-ajax-select")
|
||||
export class ProductTypeAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
protected labelField = "name";
|
||||
protected searchField = ["name"];
|
||||
private productTypes = null as ProductTypeSchema[];
|
||||
|
||||
protected async search(query: string): Promise<TomOption[]> {
|
||||
// The production database has a grand total of 26 product types
|
||||
// and the filter logic is really simple.
|
||||
// Thus, it's appropriate to fetch all product types during first use,
|
||||
// then to reuse the result again and again.
|
||||
if (this.productTypes === null) {
|
||||
this.productTypes = (await producttypeFetchAll()).data || null;
|
||||
}
|
||||
return this.productTypes.filter((t) =>
|
||||
t.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
protected renderOption(item: ProductTypeSchema, sanitize: typeof escape_html) {
|
||||
return `<div class="select-item">
|
||||
<span class="select-item-text">${sanitize(item.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected renderItem(item: ProductTypeSchema, sanitize: typeof escape_html) {
|
||||
return `<span>${sanitize(item.name)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("counter-ajax-select")
|
||||
export class CounterAjaxSelect extends AjaxSelect {
|
||||
protected valueField = "id";
|
||||
|
@ -0,0 +1,65 @@
|
||||
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
|
||||
|
||||
const productParsingRegex = /^(\d+x)?(.*)/i;
|
||||
|
||||
function parseProduct(query: string): [number, string] {
|
||||
const parsed = productParsingRegex.exec(query);
|
||||
return [Number.parseInt(parsed[1] || "1"), parsed[2]];
|
||||
}
|
||||
|
||||
@registerComponent("counter-product-select")
|
||||
export class CounterProductSelect extends AutoCompleteSelectBase {
|
||||
public getOperationCodes(): string[] {
|
||||
return ["FIN", "ANN"];
|
||||
}
|
||||
|
||||
public getSelectedProduct(): [number, string] {
|
||||
return parseProduct(this.widget.getValue() as string);
|
||||
}
|
||||
|
||||
protected attachBehaviors(): void {
|
||||
this.allowMultipleProducts();
|
||||
}
|
||||
|
||||
private allowMultipleProducts(): void {
|
||||
const search = this.widget.search;
|
||||
const onOptionSelect = this.widget.onOptionSelect;
|
||||
this.widget.hook("instead", "search", (query: string) => {
|
||||
return search.call(this.widget, parseProduct(query)[1]);
|
||||
});
|
||||
this.widget.hook(
|
||||
"instead",
|
||||
"onOptionSelect",
|
||||
(evt: MouseEvent | KeyboardEvent, option: HTMLElement) => {
|
||||
const [quantity, _] = parseProduct(this.widget.inputValue());
|
||||
const originalValue = option.getAttribute("data-value") ?? option.innerText;
|
||||
|
||||
if (quantity === 1 || this.getOperationCodes().includes(originalValue)) {
|
||||
return onOptionSelect.call(this.widget, evt, option);
|
||||
}
|
||||
|
||||
const value = `${quantity}x${originalValue}`;
|
||||
const label = `${quantity}x${option.innerText}`;
|
||||
this.widget.addOption({ value: value, text: label }, true);
|
||||
return onOptionSelect.call(
|
||||
this.widget,
|
||||
evt,
|
||||
this.widget.getOption(value, true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this.widget.hook("after", "onOptionSelect", () => {
|
||||
/* Focus the next element if it's an input */
|
||||
if (this.nextElementSibling.nodeName === "INPUT") {
|
||||
(this.nextElementSibling as HTMLInputElement).focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
protected tomSelectSettings(): RecursivePartial<TomSettings> {
|
||||
/* We disable the dropdown on focus because we're going to always autofocus the widget */
|
||||
return { ...super.tomSelectSettings(), openOnFocus: false };
|
||||
}
|
||||
}
|
@ -1,35 +1,100 @@
|
||||
import { exportToHtml } from "#core:utils/globals";
|
||||
|
||||
interface CounterConfig {
|
||||
csrfToken: string;
|
||||
clickApiUrl: string;
|
||||
sessionBasket: Record<number, BasketItem>;
|
||||
customerBalance: number;
|
||||
customerId: number;
|
||||
}
|
||||
interface BasketItem {
|
||||
// biome-ignore lint/style/useNamingConvention: talking with python
|
||||
bonus_qty: number;
|
||||
price: number;
|
||||
qty: number;
|
||||
}
|
||||
import { BasketItem } from "#counter:counter/basket";
|
||||
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
|
||||
|
||||
exportToHtml("loadCounter", (config: CounterConfig) => {
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("counter", () => ({
|
||||
basket: config.sessionBasket,
|
||||
basket: {} as Record<string, BasketItem>,
|
||||
errors: [],
|
||||
customerBalance: config.customerBalance,
|
||||
codeField: undefined,
|
||||
alertMessage: {
|
||||
content: "",
|
||||
show: false,
|
||||
timeout: null,
|
||||
},
|
||||
|
||||
init() {
|
||||
// Fill the basket with the initial data
|
||||
for (const entry of config.formInitial) {
|
||||
if (entry.id !== undefined && entry.quantity !== undefined) {
|
||||
this.addToBasket(entry.id, entry.quantity);
|
||||
this.basket[entry.id].errors = entry.errors ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
this.codeField = this.$refs.codeField;
|
||||
this.codeField.widget.focus();
|
||||
|
||||
// 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", "getBasketSize()");
|
||||
},
|
||||
|
||||
removeFromBasket(id: string) {
|
||||
delete this.basket[id];
|
||||
},
|
||||
|
||||
addToBasket(id: string, quantity: number): ErrorMessage {
|
||||
const item: BasketItem =
|
||||
this.basket[id] || new BasketItem(config.products[id], 0);
|
||||
|
||||
const oldQty = item.quantity;
|
||||
item.quantity += quantity;
|
||||
|
||||
if (item.quantity <= 0) {
|
||||
delete this.basket[id];
|
||||
return "";
|
||||
}
|
||||
|
||||
this.basket[id] = item;
|
||||
|
||||
if (this.sumBasket() > this.customerBalance) {
|
||||
item.quantity = oldQty;
|
||||
if (item.quantity === 0) {
|
||||
delete this.basket[id];
|
||||
}
|
||||
return gettext("Not enough money");
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
|
||||
getBasketSize() {
|
||||
return Object.keys(this.basket).length;
|
||||
},
|
||||
|
||||
sumBasket() {
|
||||
if (!this.basket || Object.keys(this.basket).length === 0) {
|
||||
if (this.getBasketSize() === 0) {
|
||||
return 0;
|
||||
}
|
||||
const total = Object.values(this.basket).reduce(
|
||||
(acc: number, cur: BasketItem) => acc + cur.qty * cur.price,
|
||||
(acc: number, cur: BasketItem) => acc + cur.sum(),
|
||||
0,
|
||||
) as number;
|
||||
return total / 100;
|
||||
return total;
|
||||
},
|
||||
|
||||
showAlertMessage(message: string) {
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.content = message;
|
||||
this.alertMessage.show = true;
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.show = false;
|
||||
this.alertMessage.timeout = null;
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
addToBasketWithMessage(id: string, quantity: number) {
|
||||
const message = this.addToBasket(id, quantity);
|
||||
if (message.length > 0) {
|
||||
this.showAlertMessage(message);
|
||||
}
|
||||
},
|
||||
|
||||
onRefillingSuccess(event: CustomEvent) {
|
||||
@ -40,90 +105,51 @@ exportToHtml("loadCounter", (config: CounterConfig) => {
|
||||
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
|
||||
);
|
||||
document.getElementById("selling-accordion").click();
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
|
||||
async handleCode(event: SubmitEvent) {
|
||||
const code = (
|
||||
$(event.target).find("#code_field").val() as string
|
||||
).toUpperCase();
|
||||
if (["FIN", "ANN"].includes(code)) {
|
||||
$(event.target).submit();
|
||||
} else {
|
||||
await this.handleAction(event);
|
||||
finish() {
|
||||
if (this.getBasketSize() === 0) {
|
||||
this.showAlertMessage(gettext("You can't send an empty basket."));
|
||||
return;
|
||||
}
|
||||
this.$refs.basketForm.submit();
|
||||
},
|
||||
|
||||
async handleAction(event: SubmitEvent) {
|
||||
const payload = $(event.target).serialize();
|
||||
const request = new Request(config.clickApiUrl, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
// biome-ignore lint/style/useNamingConvention: this goes into http headers
|
||||
Accept: "application/json",
|
||||
"X-CSRFToken": config.csrfToken,
|
||||
},
|
||||
});
|
||||
const response = await fetch(request);
|
||||
const json = await response.json();
|
||||
this.basket = json.basket;
|
||||
this.errors = json.errors;
|
||||
$("form.code_form #code_field").val("").focus();
|
||||
cancel() {
|
||||
location.href = config.cancelUrl;
|
||||
},
|
||||
|
||||
handleCode() {
|
||||
const [quantity, code] = this.codeField.getSelectedProduct() as [
|
||||
number,
|
||||
string,
|
||||
];
|
||||
|
||||
if (this.codeField.getOperationCodes().includes(code.toUpperCase())) {
|
||||
if (code === "ANN") {
|
||||
this.cancel();
|
||||
}
|
||||
if (code === "FIN") {
|
||||
this.finish();
|
||||
}
|
||||
} else {
|
||||
this.addToBasketWithMessage(code, quantity);
|
||||
}
|
||||
this.codeField.widget.clear();
|
||||
this.codeField.widget.focus();
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
interface Product {
|
||||
value: string;
|
||||
label: string;
|
||||
tags: string;
|
||||
}
|
||||
declare global {
|
||||
const productsAutocomplete: Product[];
|
||||
}
|
||||
|
||||
$(() => {
|
||||
/* Autocompletion in the code field */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
const codeField: any = $("#code_field");
|
||||
|
||||
let quantity = "";
|
||||
codeField.autocomplete({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
select: (event: any, ui: any) => {
|
||||
event.preventDefault();
|
||||
codeField.val(quantity + ui.item.value);
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
focus: (event: any, ui: any) => {
|
||||
event.preventDefault();
|
||||
codeField.val(quantity + ui.item.value);
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
source: (request: any, response: any) => {
|
||||
// biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
|
||||
const res = /^(\d+x)?(.*)/i.exec(request.term);
|
||||
quantity = res[1] || "";
|
||||
const search = res[2];
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
const matcher = new RegExp(($ as any).ui.autocomplete.escapeRegex(search), "i");
|
||||
response(
|
||||
$.grep(productsAutocomplete, (value: Product) => {
|
||||
return matcher.test(value.tags);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/* Accordion UI between basket and refills */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#click_form") as any).accordion({
|
||||
($("#click-form") as any).accordion({
|
||||
heightStyle: "content",
|
||||
activate: () => $(".focus").focus(),
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#products") as any).tabs();
|
||||
|
||||
codeField.focus();
|
||||
});
|
||||
|
163
counter/static/bundled/counter/product-list-index.ts
Normal file
163
counter/static/bundled/counter/product-list-index.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { paginated } from "#core:utils/api";
|
||||
import { csv } from "#core:utils/csv";
|
||||
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import type { NestedKeyOf } from "#core:utils/types";
|
||||
import { showSaveFilePicker } from "native-file-system-adapter";
|
||||
import type TomSelect from "tom-select";
|
||||
import {
|
||||
type ProductSchema,
|
||||
type ProductSearchProductsDetailedData,
|
||||
productSearchProductsDetailed,
|
||||
} from "#openapi";
|
||||
|
||||
type ProductType = string;
|
||||
type GroupedProducts = Record<ProductType, ProductSchema[]>;
|
||||
|
||||
const defaultPageSize = 100;
|
||||
const defaultPage = 1;
|
||||
|
||||
/**
|
||||
* Keys of the properties to include in the CSV.
|
||||
*/
|
||||
const csvColumns = [
|
||||
"id",
|
||||
"name",
|
||||
"code",
|
||||
"description",
|
||||
"product_type.name",
|
||||
"club.name",
|
||||
"limit_age",
|
||||
"purchase_price",
|
||||
"selling_price",
|
||||
"archived",
|
||||
] as NestedKeyOf<ProductSchema>[];
|
||||
|
||||
/**
|
||||
* Title of the csv columns.
|
||||
*/
|
||||
const csvColumnTitles = [
|
||||
"id",
|
||||
gettext("name"),
|
||||
"code",
|
||||
"description",
|
||||
gettext("product type"),
|
||||
"club",
|
||||
gettext("limit age"),
|
||||
gettext("purchase price"),
|
||||
gettext("selling price"),
|
||||
gettext("archived"),
|
||||
];
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("productList", () => ({
|
||||
loading: false,
|
||||
csvLoading: false,
|
||||
products: {} as GroupedProducts,
|
||||
|
||||
/** Total number of elements corresponding to the current query. */
|
||||
nbPages: 0,
|
||||
|
||||
productStatus: "" as "active" | "archived" | "both",
|
||||
search: "",
|
||||
productTypes: [] as string[],
|
||||
pageSize: defaultPageSize,
|
||||
page: defaultPage,
|
||||
|
||||
async init() {
|
||||
const url = getCurrentUrlParams();
|
||||
this.search = url.get("search") || "";
|
||||
this.productStatus = url.get("productStatus") ?? "active";
|
||||
const widget = this.$refs.productTypesInput.widget as TomSelect;
|
||||
widget.on("change", (items: string[]) => {
|
||||
this.productTypes = [...items];
|
||||
});
|
||||
|
||||
await this.load();
|
||||
const searchParams = ["search", "productStatus", "productTypes"];
|
||||
for (const param of searchParams) {
|
||||
this.$watch(param, () => {
|
||||
this.page = defaultPage;
|
||||
});
|
||||
}
|
||||
for (const param of [...searchParams, "page"]) {
|
||||
this.$watch(param, async (value: string) => {
|
||||
updateQueryString(param, value, History.Replace);
|
||||
this.nbPages = 0;
|
||||
await this.load();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the object containing the query parameters corresponding
|
||||
* to the current filters
|
||||
*/
|
||||
getQueryParams(): ProductSearchProductsDetailedData {
|
||||
const search = this.search.length > 0 ? this.search : null;
|
||||
// If active or archived products must be filtered, put the filter in the request
|
||||
// Else, don't include the filter
|
||||
const isArchived = ["active", "archived"].includes(this.productStatus)
|
||||
? this.productStatus === "archived"
|
||||
: undefined;
|
||||
return {
|
||||
query: {
|
||||
page: this.page,
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
page_size: this.pageSize,
|
||||
search: search,
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
is_archived: isArchived,
|
||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||
product_type: [...this.productTypes],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the products corresponding to the current filters
|
||||
*/
|
||||
async load() {
|
||||
this.loading = true;
|
||||
const options = this.getQueryParams();
|
||||
const resp = await productSearchProductsDetailed(options);
|
||||
this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
|
||||
this.products = resp.data.results.reduce<GroupedProducts>((acc, curr) => {
|
||||
const key = curr.product_type?.name ?? gettext("Uncategorized");
|
||||
if (!(key in acc)) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Download products corresponding to the current filters as a CSV file.
|
||||
* If the pagination has multiple pages, all pages are downloaded.
|
||||
*/
|
||||
async downloadCsv() {
|
||||
this.csvLoading = true;
|
||||
const fileHandle = await showSaveFilePicker({
|
||||
_preferPolyfill: false,
|
||||
suggestedName: gettext("products.csv"),
|
||||
types: [],
|
||||
excludeAcceptAllOption: false,
|
||||
});
|
||||
// if products to download are already in-memory, directly take them.
|
||||
// If not, fetch them.
|
||||
const products =
|
||||
this.nbPages > 1
|
||||
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||
: Object.values<ProductSchema[]>(this.products).flat();
|
||||
const content = csv.stringify(products, {
|
||||
columns: csvColumns,
|
||||
titleRow: csvColumnTitles,
|
||||
});
|
||||
const file = await fileHandle.createWritable();
|
||||
await file.write(content);
|
||||
await file.close();
|
||||
this.csvLoading = false;
|
||||
},
|
||||
}));
|
||||
});
|
@ -43,7 +43,7 @@ document.addEventListener("alpine:init", () => {
|
||||
openAlertMessage(response: Response) {
|
||||
if (response.ok) {
|
||||
this.alertMessage.success = true;
|
||||
this.alertMessage.content = gettext("Products types successfully reordered");
|
||||
this.alertMessage.content = gettext("Products types reordered!");
|
||||
} else {
|
||||
this.alertMessage.success = false;
|
||||
this.alertMessage.content = interpolate(
|
||||
|
25
counter/static/bundled/counter/types.d.ts
vendored
Normal file
25
counter/static/bundled/counter/types.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
type ErrorMessage = string;
|
||||
|
||||
export interface InitialFormData {
|
||||
/* Used to refill the form when the backend raises an error */
|
||||
id?: keyof Record<string, Product>;
|
||||
quantity?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface CounterConfig {
|
||||
customerBalance: number;
|
||||
customerId: number;
|
||||
products: Record<string, Product>;
|
||||
formInitial: InitialFormData[];
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
hasTrayPrice: boolean;
|
||||
quantityForTrayPrice: number;
|
||||
}
|
11
counter/static/counter/css/admin.scss
Normal file
11
counter/static/counter/css/admin.scss
Normal file
@ -0,0 +1,11 @@
|
||||
.product-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
62
counter/static/counter/css/counter-click.scss
Normal file
62
counter/static/counter/css/counter-click.scss
Normal file
@ -0,0 +1,62 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
.quantity {
|
||||
display: inline-block;
|
||||
min-width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.remove-item {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.basket-error-container {
|
||||
position: relative;
|
||||
display: block
|
||||
}
|
||||
|
||||
.basket-error {
|
||||
z-index: 10; // to get on top of tomselect
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
#bar-ui {
|
||||
padding: 0.4em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
#products {
|
||||
flex-basis: 100%;
|
||||
margin: 0.2em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#click-form {
|
||||
flex: auto;
|
||||
margin: 0.2em;
|
||||
width: 20%;
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
}
|
||||
|
||||
#user_info {
|
||||
flex: auto;
|
||||
padding: 0.5em;
|
||||
margin: 0.2em;
|
||||
height: 100%;
|
||||
background: $secondary-neutral-light-color;
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
input {
|
||||
background: white;
|
||||
}
|
||||
}
|
@ -5,8 +5,16 @@
|
||||
{{ counter }}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/counter/counter-click-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/counter/components/counter-product-select-index.ts') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block info_boxes %}
|
||||
@ -17,7 +25,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 id="click_interface">{{ counter }}</h4>
|
||||
<h4>{{ counter }}</h4>
|
||||
|
||||
<div id="bar-ui" x-data="counter">
|
||||
<noscript>
|
||||
@ -28,76 +36,125 @@
|
||||
<h5>{% trans %}Customer{% endtrans %}</h5>
|
||||
{{ user_mini_profile(customer.user) }}
|
||||
{{ user_subscription(customer.user) }}
|
||||
<p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €</p>
|
||||
<p>{% trans %}Amount: {% endtrans %}<span x-text="customerBalance"></span> €
|
||||
<span x-cloak x-show="getBasketSize() > 0">
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
<span x-text="(customerBalance - sumBasket()).toLocaleString(undefined, { minimumFractionDigits: 2 })"></span> €
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="click_form" style="width: 20%;">
|
||||
<div id="click-form">
|
||||
<h5 id="selling-accordion">{% trans %}Selling{% endtrans %}</h5>
|
||||
<div>
|
||||
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
|
||||
|
||||
{# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #}
|
||||
<form method="post" action=""
|
||||
class="code_form" @submit.prevent="handleCode">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="code">
|
||||
<label for="code_field"></label>
|
||||
<input type="text" name="code" value="" class="focus" id="code_field"/>
|
||||
|
||||
<counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
|
||||
<option value=""></option>
|
||||
<optgroup label="{% trans %}Operations{% endtrans %}">
|
||||
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
|
||||
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
|
||||
</optgroup>
|
||||
{% for category in categories.keys() %}
|
||||
<optgroup label="{{ category }}">
|
||||
{% for product in categories[category] %}
|
||||
<option value="{{ product.id }}">{{ product }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endfor %}
|
||||
</counter-product-select>
|
||||
|
||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||
</form>
|
||||
|
||||
<template x-for="error in errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
{% for error in form.non_form_errors() %}
|
||||
<div class="alert alert-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
{% endfor %}
|
||||
<p>{% trans %}Basket: {% endtrans %}</p>
|
||||
|
||||
<ul>
|
||||
<template x-for="[id, item] in Object.entries(basket)" :key="id">
|
||||
<div>
|
||||
<form method="post" action="" class="inline del_product_form"
|
||||
@submit.prevent="handleAction">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="del_product">
|
||||
<input type="hidden" name="product_id" :value="id">
|
||||
<input type="submit" value="-"/>
|
||||
</form>
|
||||
<form x-cloak method="post" action="" x-ref="basketForm">
|
||||
|
||||
<span x-text="item['qty'] + item['bonus_qty']"></span>
|
||||
<div class="basket-error-container">
|
||||
<div
|
||||
x-cloak
|
||||
class="alert alert-red basket-error"
|
||||
x-show="alertMessage.show"
|
||||
x-transition.duration.500ms
|
||||
x-text="alertMessage.content"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="" class="inline add_product_form"
|
||||
@submit.prevent="handleAction">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_product">
|
||||
<input type="hidden" name="product_id" :value="id">
|
||||
<input type="submit" value="+">
|
||||
</form>
|
||||
|
||||
<span x-text="products[id].name"></span> :
|
||||
<span x-text="(item['qty'] * item['price'] / 100)
|
||||
.toLocaleString(undefined, { minimumFractionDigits: 2 })">
|
||||
</span> €
|
||||
<template x-if="item['bonus_qty'] > 0">P</template>
|
||||
</div>
|
||||
</template>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Total: </strong>
|
||||
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
||||
<strong> €</strong>
|
||||
</p>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="finish">
|
||||
<input type="submit" value="{% trans %}Finish{% endtrans %}"/>
|
||||
</form>
|
||||
<form method="post"
|
||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="cancel">
|
||||
<input type="submit" value="{% trans %}Cancel{% endtrans %}"/>
|
||||
<div x-ref="basketManagementForm">
|
||||
{{ form.management_form }}
|
||||
</div>
|
||||
<ul>
|
||||
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
|
||||
<template x-for="(item, index) in Object.values(basket)">
|
||||
<li>
|
||||
<template x-for="error in item.errors">
|
||||
<div class="alert alert-red" x-text="error">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, -1)">-</button>
|
||||
<span class="quantity" x-text="item.quantity"></span>
|
||||
<button @click.prevent="addToBasketWithMessage(item.product.id, 1)">+</button>
|
||||
|
||||
<span x-text="item.product.name"></span> :
|
||||
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
|
||||
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
|
||||
|
||||
<button
|
||||
class="remove-item"
|
||||
@click.prevent="removeFromBasket(item.product.id)"
|
||||
><i class="fa fa-trash-can delete-action"></i></button>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.quantity"
|
||||
:id="`id_form-${index}-quantity`"
|
||||
:name="`form-${index}-quantity`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
:value="item.product.id"
|
||||
:id="`id_form-${index}-id`"
|
||||
:name="`form-${index}-id`"
|
||||
required
|
||||
readonly
|
||||
>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<p class="margin-bottom">
|
||||
<strong>Total: </strong>
|
||||
<strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
|
||||
<strong> €</strong>
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<input
|
||||
class="btn btn-blue"
|
||||
type="submit"
|
||||
@click.prevent="finish"
|
||||
:disabled="getBasketSize() === 0"
|
||||
value="{% trans %}Finish{% endtrans %}"
|
||||
/>
|
||||
<input
|
||||
class="btn btn-grey"
|
||||
type="submit" @click.prevent="cancel"
|
||||
value="{% trans %}Cancel{% endtrans %}"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if object.type == "BAR" %}
|
||||
@ -130,34 +187,41 @@
|
||||
</div>
|
||||
|
||||
<div id="products">
|
||||
<ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<h5>{{ category }}</h5>
|
||||
{% for p in categories[category] -%}
|
||||
<form method="post"
|
||||
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"
|
||||
class="form_button add_product_form" @submit.prevent="handleAction">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_product">
|
||||
<input type="hidden" name="product_id" value="{{ p.id }}">
|
||||
<button type="submit">
|
||||
<strong>{{ p.name }}</strong>
|
||||
{% if p.icon %}
|
||||
<img src="{{ p.icon.url }}" alt="image de {{ p.name }}"/>
|
||||
{% else %}
|
||||
<img src="{{ static('core/img/na.gif') }}" alt="image de {{ p.name }}"/>
|
||||
{% endif %}
|
||||
<span>{{ p.price }} €<br>{{ p.code }}</span>
|
||||
</button>
|
||||
</form>
|
||||
{%- endfor %}
|
||||
{% if not products %}
|
||||
<div class="alert alert-red">
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@ -166,30 +230,38 @@
|
||||
{{ super() }}
|
||||
<script>
|
||||
const products = {
|
||||
{%- for p in products -%}
|
||||
{{ p.id }}: {
|
||||
code: "{{ p.code }}",
|
||||
name: "{{ p.name }}",
|
||||
price: {{ p.price }},
|
||||
{%- for product in products -%}
|
||||
{{ product.id }}: {
|
||||
id: "{{ product.id }}",
|
||||
name: "{{ product.name }}",
|
||||
price: {{ product.price }},
|
||||
hasTrayPrice: {{ product.tray | tojson }},
|
||||
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
|
||||
},
|
||||
{%- endfor -%}
|
||||
};
|
||||
const productsAutocomplete = [
|
||||
{% for p in products -%}
|
||||
{
|
||||
value: "{{ p.code }}",
|
||||
label: "{{ p.name }}",
|
||||
tags: "{{ p.code }} {{ p.name }}",
|
||||
},
|
||||
{%- endfor %}
|
||||
const formInitial = [
|
||||
{%- for f in form -%}
|
||||
{%- if f.cleaned_data -%}
|
||||
{
|
||||
{%- if f.cleaned_data["id"] -%}
|
||||
id: '{{ f.cleaned_data["id"] | tojson }}',
|
||||
{%- endif -%}
|
||||
{%- if f.cleaned_data["quantity"] -%}
|
||||
quantity: {{ f.cleaned_data["quantity"] | tojson }},
|
||||
{%- endif -%}
|
||||
errors: {{ form_errors[loop.index0] | tojson }},
|
||||
},
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
];
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
loadCounter({
|
||||
csrfToken: "{{ csrf_token }}",
|
||||
clickApiUrl: "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}",
|
||||
sessionBasket: {{ request.session["basket"]|tojson }},
|
||||
customerBalance: {{ customer.amount }},
|
||||
products: products,
|
||||
customerId: {{ customer.pk }},
|
||||
formInitial: formInitial,
|
||||
cancelUrl: "{{ cancel_url }}",
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@ -59,5 +59,26 @@
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// The login form annoyingly takes priority over the code form
|
||||
// This is due to the loading time of the web component
|
||||
// We can't rely on DOMContentLoaded to know if the component is there so we
|
||||
// periodically run a script until the field is there
|
||||
const autofocus = () => {
|
||||
const field = document.querySelector("input[id='id_code']");
|
||||
if (field === null){
|
||||
setTimeout(autofocus, 0.5);
|
||||
return;
|
||||
}
|
||||
field.focus();
|
||||
}
|
||||
autofocus()
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
@ -1,26 +1,103 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "core/macros.jinja" import paginate_alpine %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}Product list{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if current_tab == "products" %}
|
||||
<p><a href="{{ url('counter:new_product') }}">{% trans %}New product{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
<h3>{% trans %}Product list{% endtrans %}</h3>
|
||||
{%- for product_type, products in object_list -%}
|
||||
<h4>{{ product_type or _("Uncategorized") }}</h4>
|
||||
<ul>
|
||||
{%- for product in products -%}
|
||||
<li><a href="{{ url('counter:product_edit', product_id=product.id) }}">{{ product.name }} ({{ product.code }})</a></li>
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- else -%}
|
||||
{% trans %}There is no products in this website.{% endtrans %}
|
||||
{%- endfor -%}
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/counter/components/ajax-select-index.ts") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/counter/product-list-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
<link rel="stylesheet" href="{{ static("counter/css/admin.scss") }}">
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main x-data="productList">
|
||||
<h4 class="margin-bottom">{% trans %}Filter products{% endtrans %}</h4>
|
||||
<form id="search-form" class="margin-bottom">
|
||||
<div class="row gap-4x">
|
||||
|
||||
<fieldset>
|
||||
<label for="search-input">{% trans %}Product name{% endtrans %}</label>
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
name="search"
|
||||
x-model.debounce.500ms="search"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class="grow">
|
||||
<legend>{% trans %}Product state{% endtrans %}</legend>
|
||||
<div class="row">
|
||||
<input type="radio" id="filter-active-products" x-model="productStatus" value="active">
|
||||
<label for="filter-active-products">{% trans %}Active products{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="radio" id="filter-inactive-products" x-model="productStatus" value="archived">
|
||||
<label for="filter-inactive-products">{% trans %}Archived products{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="radio" id="filter-all-products" x-model="productStatus" value="both">
|
||||
<label for="filter-all-products">{% trans %}All products{% endtrans %}</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<fieldset>
|
||||
<label for="type-search-input">{% trans %}Product type{% endtrans %}</label>
|
||||
<product-type-ajax-select
|
||||
id="type-search-input"
|
||||
name="product-type"
|
||||
x-ref="productTypesInput"
|
||||
multiple
|
||||
>
|
||||
</product-type-ajax-select>
|
||||
</fieldset>
|
||||
</form>
|
||||
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
|
||||
|
||||
<div class="row margin-bottom">
|
||||
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
|
||||
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
@click="downloadCsv()"
|
||||
:disabled="csvLoading"
|
||||
:aria-busy="csvLoading"
|
||||
>
|
||||
{% trans %}Download as cvs{% endtrans %} <i class="fa fa-file-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="aria-busy-grow" :aria-busy="loading">
|
||||
<template x-for="[category, cat_products] of Object.entries(products)" :key="category">
|
||||
<section>
|
||||
<h4 x-text="category" class="margin-bottom"></h4>
|
||||
<div class="product-group">
|
||||
<template x-for="p in cat_products" :key="p.id">
|
||||
<a class="card card-row shadow clickable" :href="p.url">
|
||||
<template x-if="p.icon">
|
||||
<img class="card-image" :src="p.icon" :alt="`icon ${p.name}`">
|
||||
</template>
|
||||
<template x-if="!p.icon">
|
||||
<i class="fa-regular fa-image fa-2x card-image"></i>
|
||||
</template>
|
||||
<span class="card-content">
|
||||
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
|
||||
<p x-text="`${p.selling_price} €`"></p>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
{{ paginate_alpine("page", "nbPages") }}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
@ -12,161 +12,584 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import make_password
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import resolve_url
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import localdate, now
|
||||
from freezegun import freeze_time
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
Customer,
|
||||
Permanency,
|
||||
Product,
|
||||
Refilling,
|
||||
Selling,
|
||||
)
|
||||
|
||||
|
||||
class TestCounter(TestCase):
|
||||
class TestFullClickBase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.skia = User.objects.filter(username="skia").first()
|
||||
cls.sli = User.objects.filter(username="sli").first()
|
||||
cls.krophil = User.objects.filter(username="krophil").first()
|
||||
cls.richard = User.objects.filter(username="rbatsbak").first()
|
||||
cls.mde = Counter.objects.filter(name="MDE").first()
|
||||
cls.foyer = Counter.objects.get(id=2)
|
||||
cls.customer = subscriber_user.make()
|
||||
cls.barmen = subscriber_user.make(password=make_password("plop"))
|
||||
cls.board_admin = board_user.make(password=make_password("plop"))
|
||||
cls.club_admin = subscriber_user.make()
|
||||
cls.root = baker.make(User, is_superuser=True)
|
||||
cls.subscriber = subscriber_user.make()
|
||||
|
||||
def test_full_click(self):
|
||||
cls.counter = baker.make(Counter, type="BAR")
|
||||
cls.counter.sellers.add(cls.barmen, cls.board_admin)
|
||||
|
||||
cls.other_counter = baker.make(Counter, type="BAR")
|
||||
cls.other_counter.sellers.add(cls.barmen)
|
||||
|
||||
cls.yet_another_counter = baker.make(Counter, type="BAR")
|
||||
|
||||
cls.customer_old_can_buy = subscriber_user.make()
|
||||
sub = cls.customer_old_can_buy.subscriptions.first()
|
||||
sub.subscription_end = localdate() - timedelta(days=89)
|
||||
sub.save()
|
||||
|
||||
cls.customer_old_can_not_buy = very_old_subscriber_user.make()
|
||||
|
||||
cls.customer_can_not_buy = baker.make(User)
|
||||
|
||||
cls.club_counter = baker.make(Counter, type="OFFICE")
|
||||
baker.make(
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club=cls.club_counter.club,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
user=cls.club_admin,
|
||||
)
|
||||
|
||||
def updated_amount(self, user: User) -> Decimal:
|
||||
user.refresh_from_db()
|
||||
user.customer.refresh_from_db()
|
||||
return user.customer.amount
|
||||
|
||||
|
||||
class TestRefilling(TestFullClickBase):
|
||||
def login_in_bar(self, barmen: User | None = None):
|
||||
used_barman = barmen if barmen is not None else self.board_admin
|
||||
self.client.post(
|
||||
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
|
||||
{"username": self.skia.username, "password": "plop"},
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("counter:details", kwargs={"counter_id": self.mde.id})
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": used_barman.username, "password": "plop"},
|
||||
)
|
||||
|
||||
assert 'class="link-button">S' Kia</button>' in str(response.content)
|
||||
|
||||
counter_token = re.search(
|
||||
r'name="counter_token" value="([^"]*)"', str(response.content)
|
||||
).group(1)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("counter:details", kwargs={"counter_id": self.mde.id}),
|
||||
{"code": self.richard.customer.account_id, "counter_token": counter_token},
|
||||
)
|
||||
counter_url = response.get("location")
|
||||
refill_url = reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": self.richard.customer.pk},
|
||||
)
|
||||
|
||||
response = self.client.get(counter_url)
|
||||
assert ">Richard Batsbak</" in str(response.content)
|
||||
|
||||
self.client.post(
|
||||
refill_url,
|
||||
def refill_user(
|
||||
self,
|
||||
user: User | Customer,
|
||||
counter: Counter,
|
||||
amount: int,
|
||||
client: Client | None = None,
|
||||
) -> HttpResponse:
|
||||
used_client = client if client is not None else self.client
|
||||
return used_client.post(
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": user.pk},
|
||||
),
|
||||
{
|
||||
"amount": "5",
|
||||
"amount": str(amount),
|
||||
"payment_method": "CASH",
|
||||
"bank": "OTHER",
|
||||
},
|
||||
HTTP_REFERER=counter_url,
|
||||
HTTP_REFERER=reverse(
|
||||
"counter:click",
|
||||
kwargs={"counter_id": counter.id, "user_id": user.pk},
|
||||
),
|
||||
)
|
||||
self.client.post(counter_url, "action=code&code=BARB", content_type="text/xml")
|
||||
|
||||
def test_refilling_office_fail(self):
|
||||
self.client.force_login(self.club_admin)
|
||||
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
|
||||
|
||||
self.client.force_login(self.root)
|
||||
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
|
||||
|
||||
self.client.force_login(self.subscriber)
|
||||
assert self.refill_user(self.customer, self.club_counter, 10).status_code == 403
|
||||
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_no_refer_fail(self):
|
||||
def refill():
|
||||
return self.client.post(
|
||||
reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={"customer_id": self.customer.pk},
|
||||
),
|
||||
{
|
||||
"amount": "10",
|
||||
"payment_method": "CASH",
|
||||
"bank": "OTHER",
|
||||
},
|
||||
)
|
||||
|
||||
self.client.force_login(self.club_admin)
|
||||
assert refill()
|
||||
|
||||
self.client.force_login(self.root)
|
||||
assert refill()
|
||||
|
||||
self.client.force_login(self.subscriber)
|
||||
assert refill()
|
||||
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_not_connected_fail(self):
|
||||
assert self.refill_user(self.customer, self.counter, 10).status_code == 403
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_counter_open_but_not_connected_fail(self):
|
||||
self.login_in_bar()
|
||||
client = Client()
|
||||
assert (
|
||||
self.refill_user(self.customer, self.counter, 10, client=client).status_code
|
||||
== 403
|
||||
)
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_counter_no_board_member(self):
|
||||
self.login_in_bar(barmen=self.barmen)
|
||||
assert self.refill_user(self.customer, self.counter, 10).status_code == 403
|
||||
assert self.updated_amount(self.customer) == 0
|
||||
|
||||
def test_refilling_user_can_not_buy(self):
|
||||
self.login_in_bar(barmen=self.barmen)
|
||||
|
||||
assert (
|
||||
self.refill_user(self.customer_can_not_buy, self.counter, 10).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
self.refill_user(
|
||||
self.customer_old_can_not_buy, self.counter, 10
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_refilling_counter_success(self):
|
||||
self.login_in_bar()
|
||||
|
||||
assert self.refill_user(self.customer, self.counter, 30).status_code == 302
|
||||
assert self.updated_amount(self.customer) == 30
|
||||
assert self.refill_user(self.customer, self.counter, 10.1).status_code == 302
|
||||
assert self.updated_amount(self.customer) == Decimal("40.1")
|
||||
|
||||
assert (
|
||||
self.refill_user(self.customer_old_can_buy, self.counter, 1).status_code
|
||||
== 302
|
||||
)
|
||||
assert self.updated_amount(self.customer_old_can_buy) == 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class BasketItem:
|
||||
id: int | None = None
|
||||
quantity: int | None = None
|
||||
|
||||
def to_form(self, index: int) -> dict[str, str]:
|
||||
return {
|
||||
f"form-{index}-{key}": str(value)
|
||||
for key, value in asdict(self).items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
|
||||
class TestCounterClick(TestFullClickBase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
||||
cls.underage_customer = subscriber_user.make()
|
||||
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)
|
||||
|
||||
cls.banned_alcohol_customer.groups.add(
|
||||
Group.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
)
|
||||
cls.banned_counter_customer.groups.add(
|
||||
Group.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
)
|
||||
|
||||
cls.beer = product_recipe.make(
|
||||
limit_age=18, selling_price="1.5", special_selling_price="1"
|
||||
)
|
||||
cls.beer_tap = product_recipe.make(
|
||||
limit_age=18,
|
||||
tray=True,
|
||||
selling_price="1.5",
|
||||
special_selling_price="1",
|
||||
)
|
||||
|
||||
cls.snack = product_recipe.make(
|
||||
limit_age=0, selling_price="1.5", special_selling_price="1"
|
||||
)
|
||||
cls.stamps = product_recipe.make(
|
||||
limit_age=0, selling_price="1.5", special_selling_price="1"
|
||||
)
|
||||
|
||||
cls.counter.products.add(cls.beer, cls.beer_tap, cls.snack)
|
||||
|
||||
cls.other_counter.products.add(cls.snack)
|
||||
|
||||
cls.club_counter.products.add(cls.stamps)
|
||||
|
||||
def login_in_bar(self, barmen: User | None = None):
|
||||
used_barman = barmen if barmen is not None else self.barmen
|
||||
self.client.post(
|
||||
counter_url, "action=add_product&product_id=4", content_type="text/xml"
|
||||
)
|
||||
self.client.post(
|
||||
counter_url, "action=del_product&product_id=4", content_type="text/xml"
|
||||
)
|
||||
self.client.post(
|
||||
counter_url, "action=code&code=2xdeco", content_type="text/xml"
|
||||
)
|
||||
self.client.post(
|
||||
counter_url, "action=code&code=1xbarb", content_type="text/xml"
|
||||
)
|
||||
response = self.client.post(
|
||||
counter_url, "action=code&code=fin", content_type="text/xml"
|
||||
reverse("counter:login", args=[self.counter.id]),
|
||||
{"username": used_barman.username, "password": "plop"},
|
||||
)
|
||||
|
||||
response_get = self.client.get(response.get("location"))
|
||||
response_content = response_get.content.decode("utf-8")
|
||||
assert "2 x Barbar" in str(response_content)
|
||||
assert "2 x Déconsigne Eco-cup" in str(response_content)
|
||||
assert "<p>Client : Richard Batsbak - Nouveau montant : 3.60" in str(
|
||||
response_content
|
||||
@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,
|
||||
basket: list[BasketItem],
|
||||
counter: Counter | None = None,
|
||||
client: Client | None = None,
|
||||
) -> HttpResponse:
|
||||
used_counter = counter if counter is not None else self.counter
|
||||
used_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(
|
||||
"counter:click",
|
||||
kwargs={"counter_id": used_counter.id, "user_id": user.id},
|
||||
),
|
||||
data,
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
reverse("counter:login", kwargs={"counter_id": self.mde.id}),
|
||||
{"username": self.sli.username, "password": "plop"},
|
||||
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)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.stamps.id, 5)],
|
||||
counter=eboutic,
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
refill_url,
|
||||
{
|
||||
"amount": "5",
|
||||
"payment_method": "CASH",
|
||||
"bank": "OTHER",
|
||||
},
|
||||
HTTP_REFERER=counter_url,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
def test_click_office_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.client.force_login(self.club_admin)
|
||||
|
||||
self.client.post(
|
||||
reverse("counter:login", kwargs={"counter_id": self.foyer.id}),
|
||||
{"username": self.krophil.username, "password": "plop"},
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[BasketItem(self.stamps.id, 5)],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
assert self.updated_amount(self.customer) == Decimal("2.5")
|
||||
|
||||
# Test no special price on office counter
|
||||
self.refill_user(self.club_admin, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.club_admin,
|
||||
[BasketItem(self.stamps.id, 1)],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("counter:details", kwargs={"counter_id": self.foyer.id})
|
||||
assert self.updated_amount(self.club_admin) == Decimal("8.5")
|
||||
|
||||
def test_click_bar_success(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer.id, 2),
|
||||
BasketItem(self.snack.id, 1),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
counter_token = re.search(
|
||||
r'name="counter_token" value="([^"]*)"', str(response.content)
|
||||
).group(1)
|
||||
assert self.updated_amount(self.customer) == Decimal("5.5")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("counter:details", kwargs={"counter_id": self.foyer.id}),
|
||||
{"code": self.richard.customer.account_id, "counter_token": counter_token},
|
||||
)
|
||||
counter_url = response.get("location")
|
||||
refill_url = reverse(
|
||||
"counter:refilling_create",
|
||||
kwargs={
|
||||
"customer_id": self.richard.customer.pk,
|
||||
},
|
||||
# Test barmen special price
|
||||
|
||||
self.refill_user(self.barmen, 10)
|
||||
|
||||
assert (
|
||||
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
|
||||
).status_code == 302
|
||||
|
||||
assert self.updated_amount(self.barmen) == Decimal("9")
|
||||
|
||||
def test_click_tray_price(self):
|
||||
self.refill_user(self.customer, 20)
|
||||
self.login_in_bar(self.barmen)
|
||||
|
||||
# Not applying tray price
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
refill_url,
|
||||
{
|
||||
"amount": "5",
|
||||
"payment_method": "CASH",
|
||||
"bank": "OTHER",
|
||||
},
|
||||
HTTP_REFERER=counter_url,
|
||||
assert self.updated_amount(self.customer) == Decimal("17")
|
||||
|
||||
# Applying tray price
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 7),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
assert response.status_code == 403 # Krophil is not board admin
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("8")
|
||||
|
||||
def test_click_alcool_unauthorized(self):
|
||||
self.login_in_bar()
|
||||
|
||||
for user in [self.underage_customer, self.banned_alcohol_customer]:
|
||||
self.refill_user(user, 10)
|
||||
|
||||
# Buy product without age limit
|
||||
assert (
|
||||
self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
assert self.updated_amount(user) == Decimal("7")
|
||||
|
||||
# Buy product without age limit
|
||||
assert (
|
||||
self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.beer.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
assert self.updated_amount(user) == Decimal("7")
|
||||
|
||||
def test_click_unauthorized_customer(self):
|
||||
self.login_in_bar()
|
||||
|
||||
for user in [
|
||||
self.banned_counter_customer,
|
||||
self.customer_old_can_not_buy,
|
||||
]:
|
||||
self.refill_user(user, 10)
|
||||
resp = self.submit_basket(
|
||||
user,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp.url == resolve_url(self.counter)
|
||||
|
||||
assert self.updated_amount(user) == Decimal("10")
|
||||
|
||||
def test_click_user_without_customer(self):
|
||||
self.login_in_bar()
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer_can_not_buy,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
|
||||
def test_click_allowed_old_subscriber(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer_old_can_buy, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer_old_can_buy,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
|
||||
|
||||
def test_click_wrong_counter(self):
|
||||
self.login_in_bar()
|
||||
self.refill_user(self.customer, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.other_counter,
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
)
|
||||
|
||||
# We want to test sending requests from another counter while
|
||||
# we are currently registered to another counter
|
||||
# so we connect to a counter and
|
||||
# we create a new client, in order to check
|
||||
# that using a client not logged to a counter
|
||||
# where another client is logged still isn't authorized.
|
||||
client = Client()
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.counter,
|
||||
client=client,
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_not_connected(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 302 # Redirect to counter main
|
||||
)
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.snack.id, 2),
|
||||
],
|
||||
counter=self.club_counter,
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_not_in_counter(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.stamps.id, 2),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_click_product_invalid(self):
|
||||
self.refill_user(self.customer, 10)
|
||||
self.login_in_bar()
|
||||
|
||||
for item in [
|
||||
BasketItem("-1", 2),
|
||||
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)
|
||||
self.login_in_bar()
|
||||
|
||||
assert (
|
||||
self.submit_basket(
|
||||
self.customer,
|
||||
[
|
||||
BasketItem(self.beer_tap.id, 5),
|
||||
BasketItem(self.beer.id, 10),
|
||||
],
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
assert self.updated_amount(self.customer) == Decimal("10")
|
||||
|
||||
def test_annotate_has_barman_queryset(self):
|
||||
"""Test if the custom queryset method `annotate_has_barman` works as intended."""
|
||||
self.sli.counters.set([self.foyer, self.mde])
|
||||
counters = Counter.objects.annotate_has_barman(self.sli)
|
||||
counters = Counter.objects.annotate_has_barman(self.barmen)
|
||||
for counter in counters:
|
||||
if counter.name in ("Foyer", "MDE"):
|
||||
if counter in (self.counter, self.other_counter):
|
||||
assert counter.has_annotated_barman
|
||||
else:
|
||||
assert not counter.has_annotated_barman
|
||||
@ -436,4 +859,4 @@ class TestClubCounterClickAccess(TestCase):
|
||||
self.counter.sellers.add(self.user)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
assert res.status_code == 403
|
||||
|
@ -16,8 +16,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from counter.views.admin import (
|
||||
ActiveProductListView,
|
||||
ArchivedProductListView,
|
||||
CounterCreateView,
|
||||
CounterDeleteView,
|
||||
CounterEditPropView,
|
||||
@ -27,6 +25,7 @@ from counter.views.admin import (
|
||||
CounterStatView,
|
||||
ProductCreateView,
|
||||
ProductEditView,
|
||||
ProductListView,
|
||||
ProductTypeCreateView,
|
||||
ProductTypeEditView,
|
||||
ProductTypeListView,
|
||||
@ -108,12 +107,7 @@ urlpatterns = [
|
||||
CashSummaryEditView.as_view(),
|
||||
name="cash_summary_edit",
|
||||
),
|
||||
path("admin/product/list/", ActiveProductListView.as_view(), name="product_list"),
|
||||
path(
|
||||
"admin/product/list_archived/",
|
||||
ArchivedProductListView.as_view(),
|
||||
name="product_list_archived",
|
||||
),
|
||||
path("admin/product/", ProductListView.as_view(), name="product_list"),
|
||||
path("admin/product/create/", ProductCreateView.as_view(), name="new_product"),
|
||||
path(
|
||||
"admin/product/<int:product_id>/",
|
||||
|
@ -12,19 +12,16 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
from operator import itemgetter
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import F
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
from django.forms.models import modelform_factory
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
@ -125,40 +122,9 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||
current_tab = "products"
|
||||
|
||||
|
||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||
model = Product
|
||||
queryset = Product.objects.values("id", "name", "code", "product_type__name")
|
||||
template_name = "counter/product_list.jinja"
|
||||
ordering = [
|
||||
F("product_type__order").asc(nulls_last=True),
|
||||
"product_type",
|
||||
"name",
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
res = super().get_context_data(**kwargs)
|
||||
res["object_list"] = itertools.groupby(
|
||||
res["object_list"], key=itemgetter("product_type__name")
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
class ArchivedProductListView(ProductListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
current_tab = "archive"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(archived=True)
|
||||
|
||||
|
||||
class ActiveProductListView(ProductListView):
|
||||
"""A list view for the admins."""
|
||||
|
||||
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||
current_tab = "products"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(archived=False)
|
||||
template_name = "counter/product_list.jinja"
|
||||
|
||||
|
||||
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||
|
@ -12,20 +12,26 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import parse_qs
|
||||
import math
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import DataError, transaction
|
||||
from django.db.models import F
|
||||
from django.http import Http404, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.db import transaction
|
||||
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_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, FormView
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from ninja.main import HttpRequest
|
||||
|
||||
from core.models import User
|
||||
from core.utils import FormFragmentTemplateData
|
||||
from core.views import CanViewMixin
|
||||
from counter.forms import RefillForm
|
||||
@ -34,11 +40,102 @@ from counter.utils import is_logged_in_counter
|
||||
from counter.views.mixins import CounterTabsMixin
|
||||
from counter.views.student_card import StudentCardFormView
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.models import User
|
||||
|
||||
def get_operator(request: HttpRequest, counter: Counter, customer: Customer) -> User:
|
||||
if counter.type != "BAR":
|
||||
return request.user
|
||||
if counter.customer_is_barman(customer):
|
||||
return customer.user
|
||||
return counter.get_random_barman()
|
||||
|
||||
|
||||
class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
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):
|
||||
super().clean()
|
||||
if len(self) == 0:
|
||||
return
|
||||
|
||||
self._check_forms_have_errors()
|
||||
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(_("Submmited basket is invalid"))
|
||||
|
||||
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"""
|
||||
self.total_recordings = 0
|
||||
for form in self:
|
||||
# form.product is stored by the clean step of each formset form
|
||||
if form.product.is_record_product:
|
||||
self.total_recordings -= form.cleaned_data["quantity"]
|
||||
if form.product.is_unrecord_product:
|
||||
self.total_recordings += form.cleaned_data["quantity"]
|
||||
|
||||
if not customer.can_record_more(self.total_recordings):
|
||||
raise ValidationError(_("This user have reached his recording limit"))
|
||||
|
||||
|
||||
BasketForm = formset_factory(
|
||||
ProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
|
||||
)
|
||||
|
||||
|
||||
class CounterClick(CounterTabsMixin, CanViewMixin, SingleObjectMixin, FormView):
|
||||
"""The click view
|
||||
This is a detail view not to have to worry about loading the counter
|
||||
Everything is made by hand in the post method.
|
||||
@ -46,346 +143,102 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
|
||||
model = Counter
|
||||
queryset = Counter.objects.annotate_is_open()
|
||||
form_class = BasketForm
|
||||
template_name = "counter/counter_click.jinja"
|
||||
pk_url_kwarg = "counter_id"
|
||||
current_tab = "counter"
|
||||
|
||||
def render_to_response(self, *args, **kwargs):
|
||||
if self.is_ajax(self.request):
|
||||
response = {"errors": []}
|
||||
status = HTTPStatus.OK
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(type="EBOUTIC").annotate_is_open()
|
||||
|
||||
if self.request.session["too_young"]:
|
||||
response["errors"].append(_("Too young for that product"))
|
||||
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
||||
if self.request.session["not_allowed"]:
|
||||
response["errors"].append(_("Not allowed for that product"))
|
||||
status = HTTPStatus.FORBIDDEN
|
||||
if self.request.session["no_age"]:
|
||||
response["errors"].append(_("No date of birth provided"))
|
||||
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
||||
if self.request.session["not_enough"]:
|
||||
response["errors"].append(_("Not enough money"))
|
||||
status = HTTPStatus.PAYMENT_REQUIRED
|
||||
|
||||
if len(response["errors"]) > 1:
|
||||
status = HTTPStatus.BAD_REQUEST
|
||||
|
||||
response["basket"] = self.request.session["basket"]
|
||||
|
||||
return JsonResponse(response, status=status)
|
||||
|
||||
else: # Standard HTML page
|
||||
return super().render_to_response(*args, **kwargs)
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["form_kwargs"] = {
|
||||
"customer": self.customer,
|
||||
"counter": self.object,
|
||||
"allowed_products": {product.id: product for product in self.products},
|
||||
}
|
||||
return kwargs
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
||||
obj: Counter = self.get_object()
|
||||
if not self.customer.can_buy:
|
||||
raise Http404
|
||||
if obj.type != "BAR" and not request.user.is_authenticated:
|
||||
raise PermissionDenied
|
||||
if obj.type == "BAR" and (
|
||||
"counter_token" not in request.session
|
||||
or request.session["counter_token"] != obj.token
|
||||
or len(obj.barmen_list) == 0
|
||||
|
||||
if not self.customer.can_buy or self.customer.user.is_banned_counter:
|
||||
return redirect(obj) # Redirect to counter
|
||||
|
||||
if obj.type == "OFFICE" and (
|
||||
obj.sellers.filter(pk=request.user.pk).exists()
|
||||
or not obj.club.has_rights_in_club(request.user)
|
||||
):
|
||||
return redirect(obj)
|
||||
raise PermissionDenied
|
||||
|
||||
if obj.type == "BAR" and (
|
||||
not obj.is_open
|
||||
or "counter_token" not in request.session
|
||||
or request.session["counter_token"] != obj.token
|
||||
):
|
||||
return redirect(obj) # Redirect to counter
|
||||
|
||||
self.products = obj.get_products_for(self.customer)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Simple get view."""
|
||||
if "basket" not in request.session: # Init the basket session entry
|
||||
request.session["basket"] = {}
|
||||
request.session["basket_total"] = 0
|
||||
request.session["not_enough"] = False # Reset every variable
|
||||
request.session["too_young"] = False
|
||||
request.session["not_allowed"] = False
|
||||
request.session["no_age"] = False
|
||||
ret = super().get(request, *args, **kwargs)
|
||||
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
|
||||
self.object.type == "BAR" and len(self.object.barmen_list) == 0
|
||||
): # Check that at least one barman is logged in
|
||||
ret = self.cancel(request) # Otherwise, go to main view
|
||||
return ret
|
||||
def form_valid(self, formset):
|
||||
ret = super().form_valid(formset)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Handle the many possibilities of the post request."""
|
||||
self.object = self.get_object()
|
||||
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
|
||||
self.object.type == "BAR" and len(self.object.barmen_list) < 1
|
||||
): # Check that at least one barman is logged in
|
||||
return self.cancel(request)
|
||||
if self.object.type == "BAR" and not (
|
||||
"counter_token" in self.request.session
|
||||
and self.request.session["counter_token"] == self.object.token
|
||||
): # Also check the token to avoid the bar to be stolen
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy(
|
||||
"counter:details",
|
||||
args=self.args,
|
||||
kwargs={"counter_id": self.object.id},
|
||||
)
|
||||
+ "?bad_location"
|
||||
)
|
||||
if "basket" not in request.session:
|
||||
request.session["basket"] = {}
|
||||
request.session["basket_total"] = 0
|
||||
request.session["not_enough"] = False # Reset every variable
|
||||
request.session["too_young"] = False
|
||||
request.session["not_allowed"] = False
|
||||
request.session["no_age"] = False
|
||||
if self.object.type != "BAR":
|
||||
self.operator = request.user
|
||||
elif self.object.customer_is_barman(self.customer):
|
||||
self.operator = self.customer.user
|
||||
else:
|
||||
self.operator = self.object.get_random_barman()
|
||||
action = self.request.POST.get("action", None)
|
||||
if action is None:
|
||||
action = parse_qs(request.body.decode()).get("action", [""])[0]
|
||||
if action == "add_product":
|
||||
self.add_product(request)
|
||||
elif action == "del_product":
|
||||
self.del_product(request)
|
||||
elif action == "code":
|
||||
return self.parse_code(request)
|
||||
elif action == "cancel":
|
||||
return self.cancel(request)
|
||||
elif action == "finish":
|
||||
return self.finish(request)
|
||||
context = self.get_context_data(object=self.object)
|
||||
return self.render_to_response(context)
|
||||
if len(formset) == 0:
|
||||
return ret
|
||||
|
||||
def get_product(self, pid):
|
||||
return Product.objects.filter(pk=int(pid)).first()
|
||||
|
||||
def get_price(self, pid):
|
||||
p = self.get_product(pid)
|
||||
if self.object.customer_is_barman(self.customer):
|
||||
price = p.special_selling_price
|
||||
else:
|
||||
price = p.selling_price
|
||||
return price
|
||||
|
||||
def sum_basket(self, request):
|
||||
total = 0
|
||||
for infos in request.session["basket"].values():
|
||||
total += infos["price"] * infos["qty"]
|
||||
return total / 100
|
||||
|
||||
def get_total_quantity_for_pid(self, request, pid):
|
||||
pid = str(pid)
|
||||
if pid not in request.session["basket"]:
|
||||
return 0
|
||||
return (
|
||||
request.session["basket"][pid]["qty"]
|
||||
+ request.session["basket"][pid]["bonus_qty"]
|
||||
)
|
||||
|
||||
def compute_record_product(self, request, product=None):
|
||||
recorded = 0
|
||||
basket = request.session["basket"]
|
||||
|
||||
if product:
|
||||
if product.is_record_product:
|
||||
recorded -= 1
|
||||
elif product.is_unrecord_product:
|
||||
recorded += 1
|
||||
|
||||
for p in basket:
|
||||
bproduct = self.get_product(str(p))
|
||||
if bproduct.is_record_product:
|
||||
recorded -= basket[p]["qty"]
|
||||
elif bproduct.is_unrecord_product:
|
||||
recorded += basket[p]["qty"]
|
||||
return recorded
|
||||
|
||||
def is_record_product_ok(self, request, product):
|
||||
return self.customer.can_record_more(
|
||||
self.compute_record_product(request, product)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_ajax(request):
|
||||
# when using the fetch API, the django request.POST dict is empty
|
||||
# this is but a wretched contrivance which strive to replace
|
||||
# the deprecated django is_ajax() method
|
||||
# and which must be replaced as soon as possible
|
||||
# by a proper separation between the api endpoints of the counter
|
||||
return len(request.POST) == 0 and len(request.body) != 0
|
||||
|
||||
def add_product(self, request, q=1, p=None):
|
||||
"""Add a product to the basket
|
||||
q is the quantity passed as integer
|
||||
p is the product id, passed as an integer.
|
||||
"""
|
||||
pid = p or parse_qs(request.body.decode())["product_id"][0]
|
||||
pid = str(pid)
|
||||
price = self.get_price(pid)
|
||||
total = self.sum_basket(request)
|
||||
product: Product = self.get_product(pid)
|
||||
user: User = self.customer.user
|
||||
buying_groups = list(product.buying_groups.values_list("pk", flat=True))
|
||||
can_buy = len(buying_groups) == 0 or any(
|
||||
user.is_in_group(pk=group_id) for group_id in buying_groups
|
||||
)
|
||||
if not can_buy:
|
||||
request.session["not_allowed"] = True
|
||||
return False
|
||||
bq = 0 # Bonus quantity, for trays
|
||||
if (
|
||||
product.tray
|
||||
): # Handle the tray to adjust the quantity q to add and the bonus quantity bq
|
||||
total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6
|
||||
bq = int((total_qty_mod_6 + q) / 6) # Integer division
|
||||
q -= bq
|
||||
if self.customer.amount < (
|
||||
total + round(q * float(price), 2)
|
||||
): # Check for enough money
|
||||
request.session["not_enough"] = True
|
||||
return False
|
||||
if product.is_unrecord_product and not self.is_record_product_ok(
|
||||
request, product
|
||||
):
|
||||
request.session["not_allowed"] = True
|
||||
return False
|
||||
if product.limit_age >= 18 and not user.date_of_birth:
|
||||
request.session["no_age"] = True
|
||||
return False
|
||||
if product.limit_age >= 18 and user.is_banned_alcohol:
|
||||
request.session["not_allowed"] = True
|
||||
return False
|
||||
if user.is_banned_counter:
|
||||
request.session["not_allowed"] = True
|
||||
return False
|
||||
if (
|
||||
user.date_of_birth and self.customer.user.get_age() < product.limit_age
|
||||
): # Check if affordable
|
||||
request.session["too_young"] = True
|
||||
return False
|
||||
if pid in request.session["basket"]: # Add if already in basket
|
||||
request.session["basket"][pid]["qty"] += q
|
||||
request.session["basket"][pid]["bonus_qty"] += bq
|
||||
else: # or create if not
|
||||
request.session["basket"][pid] = {
|
||||
"qty": q,
|
||||
"price": int(price * 100),
|
||||
"bonus_qty": bq,
|
||||
}
|
||||
request.session.modified = True
|
||||
return True
|
||||
|
||||
def del_product(self, request):
|
||||
"""Delete a product from the basket."""
|
||||
pid = parse_qs(request.body.decode())["product_id"][0]
|
||||
product = self.get_product(pid)
|
||||
if pid in request.session["basket"]:
|
||||
if (
|
||||
product.tray
|
||||
and (self.get_total_quantity_for_pid(request, pid) % 6 == 0)
|
||||
and request.session["basket"][pid]["bonus_qty"]
|
||||
):
|
||||
request.session["basket"][pid]["bonus_qty"] -= 1
|
||||
else:
|
||||
request.session["basket"][pid]["qty"] -= 1
|
||||
if request.session["basket"][pid]["qty"] <= 0:
|
||||
del request.session["basket"][pid]
|
||||
request.session.modified = True
|
||||
|
||||
def parse_code(self, request):
|
||||
"""Parse the string entered by the barman.
|
||||
|
||||
This can be of two forms :
|
||||
- `<str>`, where the string is the code of the product
|
||||
- `<int>X<str>`, where the integer is the quantity and str the code.
|
||||
"""
|
||||
string = parse_qs(request.body.decode()).get("code", [""])[0].upper()
|
||||
if string == "FIN":
|
||||
return self.finish(request)
|
||||
elif string == "ANN":
|
||||
return self.cancel(request)
|
||||
regex = re.compile(r"^((?P<nb>[0-9]+)X)?(?P<code>[A-Z0-9]+)$")
|
||||
m = regex.match(string)
|
||||
if m is not None:
|
||||
nb = m.group("nb")
|
||||
code = m.group("code")
|
||||
nb = int(nb) if nb is not None else 1
|
||||
p = self.object.products.filter(code=code).first()
|
||||
if p is not None:
|
||||
self.add_product(request, nb, p.id)
|
||||
context = self.get_context_data(object=self.object)
|
||||
return self.render_to_response(context)
|
||||
|
||||
def finish(self, request):
|
||||
"""Finish the click session, and validate the basket."""
|
||||
operator = get_operator(self.request, self.object, self.customer)
|
||||
with transaction.atomic():
|
||||
request.session["last_basket"] = []
|
||||
if self.sum_basket(request) > self.customer.amount:
|
||||
raise DataError(_("You have not enough money to buy all the basket"))
|
||||
self.request.session["last_basket"] = []
|
||||
|
||||
for pid, infos in request.session["basket"].items():
|
||||
# This duplicates code for DB optimization (prevent to load many times the same object)
|
||||
p = Product.objects.filter(pk=pid).first()
|
||||
if self.object.customer_is_barman(self.customer):
|
||||
uprice = p.special_selling_price
|
||||
else:
|
||||
uprice = p.selling_price
|
||||
request.session["last_basket"].append(
|
||||
"%d x %s" % (infos["qty"] + infos["bonus_qty"], p.name)
|
||||
for form in formset:
|
||||
self.request.session["last_basket"].append(
|
||||
f"{form.cleaned_data['quantity']} x {form.product.name}"
|
||||
)
|
||||
s = Selling(
|
||||
label=p.name,
|
||||
product=p,
|
||||
club=p.club,
|
||||
|
||||
Selling(
|
||||
label=form.product.name,
|
||||
product=form.product,
|
||||
club=form.product.club,
|
||||
counter=self.object,
|
||||
unit_price=uprice,
|
||||
quantity=infos["qty"],
|
||||
seller=self.operator,
|
||||
unit_price=form.product.price,
|
||||
quantity=form.cleaned_data["quantity"]
|
||||
- form.cleaned_data["bonus_quantity"],
|
||||
seller=operator,
|
||||
customer=self.customer,
|
||||
)
|
||||
s.save()
|
||||
if infos["bonus_qty"]:
|
||||
s = Selling(
|
||||
label=p.name + " (Plateau)",
|
||||
product=p,
|
||||
club=p.club,
|
||||
).save()
|
||||
if form.cleaned_data["bonus_quantity"] > 0:
|
||||
Selling(
|
||||
label=f"{form.product.name} (Plateau)",
|
||||
product=form.product,
|
||||
club=form.product.club,
|
||||
counter=self.object,
|
||||
unit_price=0,
|
||||
quantity=infos["bonus_qty"],
|
||||
seller=self.operator,
|
||||
quantity=form.cleaned_data["bonus_quantity"],
|
||||
seller=operator,
|
||||
customer=self.customer,
|
||||
)
|
||||
s.save()
|
||||
self.customer.recorded_products -= self.compute_record_product(request)
|
||||
self.customer.save()
|
||||
request.session["last_customer"] = self.customer.user.get_display_name()
|
||||
request.session["last_total"] = "%0.2f" % self.sum_basket(request)
|
||||
request.session["new_customer_amount"] = str(self.customer.amount)
|
||||
del request.session["basket"]
|
||||
request.session.modified = True
|
||||
kwargs = {"counter_id": self.object.id}
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
|
||||
)
|
||||
).save()
|
||||
|
||||
def cancel(self, request):
|
||||
"""Cancel the click session."""
|
||||
kwargs = {"counter_id": self.object.id}
|
||||
request.session.pop("basket", None)
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
|
||||
)
|
||||
self.customer.recorded_products -= formset.total_recordings
|
||||
self.customer.save()
|
||||
|
||||
# Add some info for the main counter view to display
|
||||
self.request.session["last_customer"] = self.customer.user.get_display_name()
|
||||
self.request.session["last_total"] = f"{formset.total_price:0.2f}"
|
||||
self.request.session["new_customer_amount"] = str(self.customer.amount)
|
||||
|
||||
return ret
|
||||
|
||||
def get_success_url(self):
|
||||
return resolve_url(self.object)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add customer to the context."""
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
products = self.object.products.select_related("product_type")
|
||||
if self.object.customer_is_barman(self.customer):
|
||||
products = products.annotate(price=F("special_selling_price"))
|
||||
else:
|
||||
products = products.annotate(price=F("selling_price"))
|
||||
kwargs["products"] = products
|
||||
kwargs["products"] = self.products
|
||||
kwargs["categories"] = {}
|
||||
for product in kwargs["products"]:
|
||||
if product.product_type:
|
||||
@ -393,8 +246,12 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
product
|
||||
)
|
||||
kwargs["customer"] = self.customer
|
||||
kwargs["basket_total"] = self.sum_basket(self.request)
|
||||
kwargs["cancel_url"] = self.get_success_url()
|
||||
|
||||
# To get all forms errors to the javascript, we create a list of error list
|
||||
kwargs["form_errors"] = [
|
||||
list(field_error.values()) for field_error in kwargs["form"].errors
|
||||
]
|
||||
if self.object.type == "BAR":
|
||||
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||
self.customer
|
||||
@ -404,6 +261,7 @@ class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||
kwargs["refilling_fragment"] = RefillingCreateView.get_template_data(
|
||||
self.customer
|
||||
).render(self.request)
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
@ -442,10 +300,7 @@ class RefillingCreateView(FormView):
|
||||
if not self.counter.can_refill():
|
||||
raise PermissionDenied
|
||||
|
||||
if self.counter.customer_is_barman(self.customer):
|
||||
self.operator = self.customer.user
|
||||
else:
|
||||
self.operator = self.counter.get_random_barman()
|
||||
self.operator = get_operator(request, self.counter, self.customer)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
@ -43,6 +43,9 @@ class CounterMain(
|
||||
)
|
||||
current_tab = "counter"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(type="EBOUTIC")
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if self.object.type == "BAR" and not (
|
||||
|
@ -93,11 +93,6 @@ class CounterAdminTabsMixin(TabedViewMixin):
|
||||
"slug": "products",
|
||||
"name": _("Products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:product_list_archived"),
|
||||
"slug": "archive",
|
||||
"name": _("Archived products"),
|
||||
},
|
||||
{
|
||||
"url": reverse_lazy("counter:product_type_list"),
|
||||
"slug": "product_types",
|
||||
|
@ -1,8 +1,12 @@
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||
from counter.models import Counter, Product
|
||||
from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from counter.schemas import (
|
||||
ProductTypeSchema,
|
||||
SimpleProductSchema,
|
||||
SimplifiedCounterSchema,
|
||||
)
|
||||
|
||||
_js = ["bundled/counter/components/ajax-select-index.ts"]
|
||||
|
||||
@ -33,3 +37,17 @@ class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple):
|
||||
model = Product
|
||||
adapter = TypeAdapter(list[SimpleProductSchema])
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectProductType(AutoCompleteSelect):
|
||||
component_name = "product-type-ajax-select"
|
||||
model = ProductType
|
||||
adapter = TypeAdapter(list[ProductTypeSchema])
|
||||
js = _js
|
||||
|
||||
|
||||
class AutoCompleteSelectMultipleProductType(AutoCompleteSelectMultiple):
|
||||
component_name = "product-type-ajax-select"
|
||||
model = ProductType
|
||||
adapter = TypeAdapter(list[ProductTypeSchema])
|
||||
js = _js
|
||||
|
@ -108,28 +108,8 @@
|
||||
column-gap: 15px;
|
||||
row-gap: 15px;
|
||||
}
|
||||
#eboutic .product-button {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 180px;
|
||||
height: fit-content;
|
||||
width: 150px;
|
||||
padding: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
row-gap: 5px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#eboutic .product-button.selected {
|
||||
animation: bg-in-out 1s ease;
|
||||
background-color: rgb(216, 236, 255);
|
||||
}
|
||||
|
||||
#eboutic .product-button.selected::after {
|
||||
#eboutic .card.selected::after {
|
||||
content: "🛒";
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@ -144,36 +124,6 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#eboutic .product-button:active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#eboutic .product-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 70px;
|
||||
max-height: 70px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
line-height: 70px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#eboutic i.product-image {
|
||||
background-color: rgba(173, 173, 173, 0.2);
|
||||
}
|
||||
|
||||
#eboutic .product-description h4 {
|
||||
font-size: .75em;
|
||||
word-break: break-word;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
#eboutic .product-button p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#eboutic .catalog-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -207,39 +157,5 @@
|
||||
justify-content: space-around;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#eboutic .product-group .product-button {
|
||||
min-height: 100px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#eboutic .product-group .product-description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#eboutic .product-description h4 {
|
||||
text-align: left;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
#eboutic .product-image {
|
||||
margin-bottom: 0;
|
||||
max-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-in-out {
|
||||
0% {
|
||||
background-color: white;
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(216, 236, 255);
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('eboutic/css/eboutic.css') }}">
|
||||
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -104,18 +105,21 @@
|
||||
{% for p in items %}
|
||||
<button
|
||||
id="{{ p.id }}"
|
||||
class="product-button"
|
||||
class="card product-button clickable shadow"
|
||||
:class="{selected: items.some((i) => i.id === {{ p.id }})}"
|
||||
@click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
|
||||
>
|
||||
{% if p.icon %}
|
||||
<img class="product-image" src="{{ p.icon.url }}"
|
||||
alt="image de {{ p.name }}">
|
||||
<img
|
||||
class="card-image"
|
||||
src="{{ p.icon.url }}"
|
||||
alt="image de {{ p.name }}"
|
||||
>
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-2x product-image"></i>
|
||||
<i class="fa-regular fa-image fa-2x card-image"></i>
|
||||
{% endif %}
|
||||
<div class="product-description">
|
||||
<h4>{{ p.name }}</h4>
|
||||
<div class="card-content">
|
||||
<h4 class="card-title">{{ p.name }}</h4>
|
||||
<p>{{ p.selling_price }} €</p>
|
||||
</div>
|
||||
</button>
|
||||
|
@ -77,8 +77,7 @@
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ billing_form }}
|
||||
<br>
|
||||
<br>
|
||||
<br />
|
||||
<div
|
||||
x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(reqState)"
|
||||
class="alert"
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
$padding: 1.5rem;
|
||||
$padding_smaller: .5rem;
|
||||
$gap: .25rem;
|
||||
@ -50,8 +52,7 @@ $min_col_width: 100px;
|
||||
position: relative;
|
||||
min-width: $min_col_width;
|
||||
|
||||
>a{
|
||||
margin-left: $padding;
|
||||
>a {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
@ -269,12 +270,12 @@ $min_col_width: 100px;
|
||||
border: none;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.18em;
|
||||
border-radius: 5px;
|
||||
box-shadow: #dfdfdf 0px 0px 1px;
|
||||
box-shadow: #dfdfdf 0 0 1px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 00:46+0100\n"
|
||||
"POT-Creation-Date: 2024-12-23 02:38+0100\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -122,11 +122,51 @@ msgstr "photos.%(extension)s"
|
||||
msgid "captured.%s"
|
||||
msgstr "capture.%s"
|
||||
|
||||
#: counter/static/bundled/counter/product-type-index.ts:36
|
||||
#: counter/static/bundled/counter/counter-click-index.ts:60
|
||||
msgid "Not enough money"
|
||||
msgstr "Pas assez d'argent"
|
||||
|
||||
#: counter/static/bundled/counter/counter-click-index.ts:113
|
||||
msgid "You can't send an empty basket."
|
||||
msgstr "Vous ne pouvez pas envoyer un panier vide."
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:40
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:43
|
||||
msgid "product type"
|
||||
msgstr "type de produit"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:45
|
||||
msgid "limit age"
|
||||
msgstr "limite d'âge"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:46
|
||||
msgid "purchase price"
|
||||
msgstr "prix d'achat"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:47
|
||||
msgid "selling price"
|
||||
msgstr "prix de vente"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:48
|
||||
msgid "archived"
|
||||
msgstr "archivé"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:125
|
||||
msgid "Uncategorized"
|
||||
msgstr "Sans catégorie"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:143
|
||||
msgid "products.csv"
|
||||
msgstr "produits.csv"
|
||||
|
||||
#: counter/static/bundled/counter/product-type-index.ts:46
|
||||
msgid "Products types reordered!"
|
||||
msgstr "Types de produits réordonnés !"
|
||||
|
||||
#: counter/static/bundled/counter/product-type-index.ts:40
|
||||
#: counter/static/bundled/counter/product-type-index.ts:50
|
||||
#, javascript-format
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
|
||||
|
6
package-lock.json
generated
6
package-lock.json
generated
@ -4608,9 +4608,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"version": "3.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -18,7 +18,8 @@
|
||||
"imports": {
|
||||
"#openapi": "./staticfiles/generated/openapi/index.ts",
|
||||
"#core:*": "./core/static/bundled/*",
|
||||
"#pedagogy:*": "./pedagogy/static/bundled/*"
|
||||
"#pedagogy:*": "./pedagogy/static/bundled/*",
|
||||
"#counter:*": "./counter/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
@ -25,7 +27,7 @@ main {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: black;
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
|
||||
@ -34,7 +36,7 @@ main {
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
#content {
|
||||
padding: 10px !important;
|
||||
}
|
||||
@ -241,7 +243,7 @@
|
||||
>div {
|
||||
>a.button {
|
||||
box-sizing: border-box;
|
||||
background-color: #f2f2f2;
|
||||
background-color: $primary-neutral-light-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -290,6 +290,7 @@ STORAGES = {
|
||||
# Auth configuration
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
AUTH_ANONYMOUS_MODEL = "core.models.AnonymousUser"
|
||||
AUTHENTICATION_BACKENDS = ["core.auth_backends.SithModelBackend"]
|
||||
LOGIN_URL = "/login"
|
||||
LOGOUT_URL = "/logout"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
@ -15,7 +15,8 @@
|
||||
"paths": {
|
||||
"#openapi": ["./staticfiles/generated/openapi/index.ts"],
|
||||
"#core:*": ["./core/static/bundled/*"],
|
||||
"#pedagogy:*": ["./pedagogy/static/bundled/*"]
|
||||
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
|
||||
"#counter:*": ["./counter/static/bundled/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user