mirror of
https://github.com/ae-utbm/sith.git
synced 2025-01-09 16:41:12 +00:00
Merge pull request #939 from ae-utbm/taiste
`dump_account`, HTMX, Subscriptions and more
This commit is contained in:
commit
35c5f96672
6
.github/actions/setup_project/action.yml
vendored
6
.github/actions/setup_project/action.yml
vendored
@ -6,7 +6,7 @@ runs:
|
|||||||
- name: Install apt packages
|
- name: Install apt packages
|
||||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||||
with:
|
with:
|
||||||
packages: gettext pipx
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
|
|
||||||
- name: Set up python
|
- name: Set up python
|
||||||
@ -19,12 +19,12 @@ runs:
|
|||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ~/.local
|
path: ~/.local
|
||||||
key: poetry-1 # increment to reset cache
|
key: poetry-3 # increment to reset cache
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install Poetry
|
||||||
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: pipx install poetry
|
run: curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
|
||||||
- name: Check pyproject.toml syntax
|
- name: Check pyproject.toml syntax
|
||||||
shell: bash
|
shell: bash
|
||||||
|
@ -4,7 +4,7 @@ from accounting.models import ClubAccount, Company
|
|||||||
from accounting.schemas import ClubAccountSchema, CompanySchema
|
from accounting.schemas import ClubAccountSchema, CompanySchema
|
||||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||||
|
|
||||||
_js = ["webpack/accounting/components/ajax-select-index.ts"]
|
_js = ["bundled/accounting/components/ajax-select-index.ts"]
|
||||||
|
|
||||||
|
|
||||||
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
|
class AutoCompleteSelectClubAccount(AutoCompleteSelect):
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"edge": "17",
|
|
||||||
"firefox": "60",
|
|
||||||
"chrome": "67",
|
|
||||||
"safari": "11.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
@ -4,7 +4,7 @@ from club.models import Club
|
|||||||
from club.schemas import ClubSchema
|
from club.schemas import ClubSchema
|
||||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||||
|
|
||||||
_js = ["webpack/club/components/ajax-select-index.ts"]
|
_js = ["bundled/club/components/ajax-select-index.ts"]
|
||||||
|
|
||||||
|
|
||||||
class AutoCompleteSelectClub(AutoCompleteSelect):
|
class AutoCompleteSelectClub(AutoCompleteSelect):
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||||
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
|
||||||
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/jquery-index.js') }}"></script>
|
||||||
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
<script src="{{ static('com/js/slideshow.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,12 @@
|
|||||||
import random
|
import random
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Count, Exists, F, Min, OuterRef, Subquery, Sum
|
from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
from django.utils.timezone import localdate, make_aware, now
|
from django.utils.timezone import localdate, make_aware, now
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
@ -268,24 +266,6 @@ class Command(BaseCommand):
|
|||||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
||||||
Counter.products.through.objects.bulk_create(selling_places)
|
Counter.products.through.objects.bulk_create(selling_places)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _update_balances():
|
|
||||||
customers = Customer.objects.annotate(
|
|
||||||
money_in=Sum(F("refillings__amount"), default=0),
|
|
||||||
money_out=Coalesce(
|
|
||||||
Subquery(
|
|
||||||
Selling.objects.filter(customer=OuterRef("pk"))
|
|
||||||
.values("customer_id") # group by customer
|
|
||||||
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
|
|
||||||
.values("res")
|
|
||||||
),
|
|
||||||
Decimal("0"),
|
|
||||||
),
|
|
||||||
).annotate(real_balance=F("money_in") - F("money_out"))
|
|
||||||
for c in customers:
|
|
||||||
c.amount = c.real_balance
|
|
||||||
Customer.objects.bulk_update(customers, fields=["amount"])
|
|
||||||
|
|
||||||
def create_sales(self, sellers: list[User]):
|
def create_sales(self, sellers: list[User]):
|
||||||
customers = list(
|
customers = list(
|
||||||
Customer.objects.annotate(
|
Customer.objects.annotate(
|
||||||
@ -355,7 +335,7 @@ class Command(BaseCommand):
|
|||||||
sales.extend(this_customer_sales)
|
sales.extend(this_customer_sales)
|
||||||
Refilling.objects.bulk_create(reloads)
|
Refilling.objects.bulk_create(reloads)
|
||||||
Selling.objects.bulk_create(sales)
|
Selling.objects.bulk_create(sales)
|
||||||
self._update_balances()
|
Customer.objects.update_amount()
|
||||||
|
|
||||||
def create_permanences(self, sellers: list[User]):
|
def create_permanences(self, sellers: list[User]):
|
||||||
counters = list(
|
counters = list(
|
||||||
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import string
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -528,13 +529,15 @@ class User(AbstractBaseUser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_create_subscription(self):
|
def can_create_subscription(self) -> bool:
|
||||||
from club.models import Club
|
from club.models import Membership
|
||||||
|
|
||||||
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
|
return (
|
||||||
if club in self.clubs_with_rights:
|
Membership.objects.board()
|
||||||
return True
|
.ongoing()
|
||||||
return False
|
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_launderette_manager(self):
|
def is_launderette_manager(self):
|
||||||
@ -688,12 +691,20 @@ class User(AbstractBaseUser):
|
|||||||
.encode("ascii", "ignore")
|
.encode("ascii", "ignore")
|
||||||
.decode("utf-8")
|
.decode("utf-8")
|
||||||
)
|
)
|
||||||
un_set = [u.username for u in User.objects.all()]
|
# load all usernames which could conflict with the new one.
|
||||||
if user_name in un_set:
|
# we need to actually load them, instead of performing a count,
|
||||||
i = 1
|
# because we cannot be sure that two usernames refer to the
|
||||||
while user_name + str(i) in un_set:
|
# actual same word (eg. tmore and tmoreau)
|
||||||
i += 1
|
possible_conflicts: list[str] = list(
|
||||||
user_name += str(i)
|
User.objects.filter(username__startswith=user_name).values_list(
|
||||||
|
"username", flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nb_conflicts = sum(
|
||||||
|
1 for name in possible_conflicts if name.rstrip(string.digits) == user_name
|
||||||
|
)
|
||||||
|
if nb_conflicts > 0:
|
||||||
|
user_name += str(nb_conflicts) # exemple => exemple1
|
||||||
self.username = user_name
|
self.username = user_name
|
||||||
return user_name
|
return user_name
|
||||||
|
|
||||||
|
42
core/static/bundled/core/components/nfc-input-index.ts
Normal file
42
core/static/bundled/core/components/nfc-input-index.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||||
|
|
||||||
|
@registerComponent("nfc-input")
|
||||||
|
export class NfcInput extends inheritHtmlElement("input") {
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
/* Disable feature if browser is not supported or if not HTTPS */
|
||||||
|
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
|
||||||
|
if (typeof NDEFReader === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
const logo = document.createElement("i");
|
||||||
|
logo.classList.add("fa-brands", "fa-nfc-symbol");
|
||||||
|
button.setAttribute("type", "button"); // Prevent form submission on click
|
||||||
|
button.appendChild(logo);
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
|
||||||
|
const ndef = new NDEFReader();
|
||||||
|
this.setAttribute("scan", "active");
|
||||||
|
await ndef.scan();
|
||||||
|
ndef.addEventListener("readingerror", () => {
|
||||||
|
this.removeAttribute("scan");
|
||||||
|
window.alert(gettext("Unsupported NFC card"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
|
||||||
|
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
|
||||||
|
this.removeAttribute("scan");
|
||||||
|
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
|
||||||
|
/* Auto submit form, we need another button to not trigger our previously defined click event */
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
this.node.appendChild(submit);
|
||||||
|
submit.click();
|
||||||
|
submit.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
1
core/static/bundled/fontawesome-index.js
Normal file
1
core/static/bundled/fontawesome-index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@fortawesome/fontawesome-free/css/all.css";
|
3
core/static/bundled/htmx-index.js
Normal file
3
core/static/bundled/htmx-index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import htmx from "htmx.org";
|
||||||
|
|
||||||
|
Object.assign(window, { htmx });
|
2
core/static/bundled/jquery-ui-index.js
vendored
Normal file
2
core/static/bundled/jquery-ui-index.js
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// This is only used to import jquery-ui css files
|
||||||
|
import "jquery-ui/themes/base/all.css";
|
106
core/static/bundled/types/web-nfc.d.ts
vendored
Normal file
106
core/static/bundled/types/web-nfc.d.ts
vendored
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
// Type definitions for Web NFC
|
||||||
|
// Project: https://github.com/w3c/web-nfc
|
||||||
|
// Definitions by: Takefumi Yoshii <https://github.com/takefumi-yoshii>
|
||||||
|
// TypeScript Version: 3.9
|
||||||
|
|
||||||
|
// This type definitions referenced to WebIDL.
|
||||||
|
// https://w3c.github.io/web-nfc/#actual-idl-index
|
||||||
|
|
||||||
|
// This has been modified to not trigger biome linting
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
||||||
|
interface Window {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
NDEFMessage: NDEFMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare class NDEFMessage {
|
||||||
|
constructor(messageInit: NDEFMessageInit);
|
||||||
|
records: readonly NDEFRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare interface NDEFMessageInit {
|
||||||
|
records: NDEFRecordInit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
||||||
|
interface Window {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
NDEFRecord: NDEFRecord;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare class NDEFRecord {
|
||||||
|
constructor(recordInit: NDEFRecordInit);
|
||||||
|
readonly recordType: string;
|
||||||
|
readonly mediaType?: string;
|
||||||
|
readonly id?: string;
|
||||||
|
readonly data?: DataView;
|
||||||
|
readonly encoding?: string;
|
||||||
|
readonly lang?: string;
|
||||||
|
toRecords?: () => NDEFRecord[];
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare interface NDEFRecordInit {
|
||||||
|
recordType: string;
|
||||||
|
mediaType?: string;
|
||||||
|
id?: string;
|
||||||
|
encoding?: string;
|
||||||
|
lang?: string;
|
||||||
|
data?: NDEFRecordDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
||||||
|
interface Window {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
NDEFReader: NDEFReader;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare class NDEFReader extends EventTarget {
|
||||||
|
constructor();
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: who am I to doubt the w3c definitions ?
|
||||||
|
onreading: (this: this, event: NDEFReadingEvent) => any;
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: who am I to doubt the w3c definitions ?
|
||||||
|
onreadingerror: (this: this, error: Event) => any;
|
||||||
|
scan: (options?: NDEFScanOptions) => Promise<void>;
|
||||||
|
write: (message: NDEFMessageSource, options?: NDEFWriteOptions) => Promise<void>;
|
||||||
|
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
|
||||||
|
interface Window {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
NDEFReadingEvent: NDEFReadingEvent;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
declare class NDEFReadingEvent extends Event {
|
||||||
|
constructor(type: string, readingEventInitDict: NDEFReadingEventInit);
|
||||||
|
serialNumber: string;
|
||||||
|
message: NDEFMessage;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
interface NDEFReadingEventInit extends EventInit {
|
||||||
|
serialNumber?: string;
|
||||||
|
message: NDEFMessageInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
interface NDEFWriteOptions {
|
||||||
|
overwrite?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
interface NDEFMakeReadOnlyOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
// biome-ignore lint/style/useNamingConvention: this is the official API name
|
||||||
|
interface NDEFScanOptions {
|
||||||
|
signal: AbortSignal;
|
||||||
|
}
|
34
core/static/core/components/nfc-input.scss
Normal file
34
core/static/core/components/nfc-input.scss
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
nfc-input[scan="active"]::before {
|
||||||
|
content: "";
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #18c89b;
|
||||||
|
box-shadow: 0 0 70px 20px #18c89b;
|
||||||
|
clip-path: inset(0);
|
||||||
|
animation:
|
||||||
|
x 1s ease-in-out infinite alternate,
|
||||||
|
y 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes x {
|
||||||
|
to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes y {
|
||||||
|
33% {
|
||||||
|
clip-path: inset(0 0 0 -100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
83% {
|
||||||
|
clip-path: inset(0 -100px 0 0);
|
||||||
|
}
|
||||||
|
}
|
89
core/static/core/forms.scss
Normal file
89
core/static/core/forms.scss
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
@import "colors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style related to forms
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
@import "colors";
|
@import "colors";
|
||||||
|
@import "forms";
|
||||||
|
|
||||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||||
$small-devices: 576px;
|
$small-devices: 576px;
|
||||||
@ -13,91 +14,6 @@ body {
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[aria-busy] {
|
[aria-busy] {
|
||||||
--loading-size: 50px;
|
--loading-size: 50px;
|
||||||
--loading-stroke: 5px;
|
--loading-stroke: 5px;
|
||||||
@ -262,8 +178,10 @@ a:not(.button) {
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
|
margin: 3px;
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
&.btn-blue {
|
&.btn-blue {
|
||||||
@ -367,6 +285,49 @@ a:not(.button) {
|
|||||||
.alert-aside {
|
.alert-aside {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
.tab-headers {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
background-color: $primary-neutral-light-color;
|
||||||
|
padding: 3px 12px 12px;
|
||||||
|
column-gap: 20px;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
border: none;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
font-size: 120%;
|
||||||
|
background-color: unset;
|
||||||
|
position: relative;
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
&:hover:after {
|
||||||
|
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||||
|
}
|
||||||
|
&.active:after {
|
||||||
|
border-bottom-color: $primary-dark-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1246,26 +1207,26 @@ u,
|
|||||||
/*-----------------------------USER PROFILE----------------------------*/
|
/*-----------------------------USER PROFILE----------------------------*/
|
||||||
|
|
||||||
.user_mini_profile {
|
.user_mini_profile {
|
||||||
height: 100%;
|
--gap-size: 1em;
|
||||||
width: 100%;
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-size);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_mini_profile_infos {
|
.user_mini_profile_infos {
|
||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
height: 20%;
|
max-height: 20%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|
||||||
div {
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user_mini_profile_infos_text {
|
.user_mini_profile_infos_text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
@ -1276,10 +1237,10 @@ u,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user_mini_profile_picture {
|
.user_mini_profile_picture {
|
||||||
height: 80%;
|
max-height: calc(80% - var(--gap-size));
|
||||||
display: flex;
|
max-width: 100%;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
margin: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ main {
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: space-evenly;
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -143,21 +143,14 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
> .user_profile_pictures_bigone {
|
> .user_profile_pictures_bigone {
|
||||||
flex-grow: 9;
|
|
||||||
flex-basis: 20em;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
> img {
|
> img {
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
max-width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
max-width: 300px;
|
width: 300px;
|
||||||
width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,7 +162,6 @@ main {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-grow: 1;
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -1 +0,0 @@
|
|||||||
require("@fortawesome/fontawesome-free/css/all.css");
|
|
25
core/static/webpack/jquery-index.js
vendored
25
core/static/webpack/jquery-index.js
vendored
@ -1,25 +0,0 @@
|
|||||||
import $ from "jquery";
|
|
||||||
import "jquery.shorten/src/jquery.shorten.min.js";
|
|
||||||
|
|
||||||
// We ship jquery-ui with jquery because when standalone with webpack
|
|
||||||
// JQuery is also included in the jquery-ui package. We do gain space by doing this
|
|
||||||
// We require jquery-ui components manually and not in a loop
|
|
||||||
// Otherwise it increases the output files by a x2 factor !
|
|
||||||
require("jquery-ui/ui/widgets/accordion.js");
|
|
||||||
require("jquery-ui/ui/widgets/autocomplete.js");
|
|
||||||
require("jquery-ui/ui/widgets/button.js");
|
|
||||||
require("jquery-ui/ui/widgets/dialog.js");
|
|
||||||
require("jquery-ui/ui/widgets/tabs.js");
|
|
||||||
|
|
||||||
require("jquery-ui/themes/base/all.css");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple wrapper to solve shorten not being able on legacy pages
|
|
||||||
* @param {string} selector to be passed to jQuery
|
|
||||||
* @param {Object} options object to pass to the shorten function
|
|
||||||
**/
|
|
||||||
function shorten(selector, options) {
|
|
||||||
$(selector).shorten(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.shorten = shorten;
|
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
{% if settings.SENTRY_DSN %}
|
{% if settings.SENTRY_DSN %}
|
||||||
<script src="{{ static('webpack/sentry-popup-index.ts') }}" defer ></script>
|
<script type="module" src="{{ static('bundled/sentry-popup-index.ts') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock additional_js %}
|
{% endblock additional_js %}
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@
|
|||||||
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
|
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
|
||||||
<link rel="stylesheet" href="{{ static('user/user_stats.scss') }}">
|
|
||||||
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
<link rel="stylesheet" href="{{ static('core/base.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||||
@ -14,18 +13,20 @@
|
|||||||
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
||||||
|
|
||||||
{% block jquery_css %}
|
{% block jquery_css %}
|
||||||
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
|
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
|
||||||
<link rel="stylesheet" href="{{ static('webpack/jquery-index.css') }}">
|
<link rel="stylesheet" href="{{ static('bundled/jquery-ui-index.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
<link rel="preload" as="style" href="{{ static('bundled/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
|
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
||||||
|
|
||||||
<script src="{{ url('javascript-catalog') }}"></script>
|
<script src="{{ url('javascript-catalog') }}"></script>
|
||||||
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
|
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
||||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||||
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
|
||||||
<!-- Put here to always have access to those functions on django widgets -->
|
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||||
|
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||||
|
<script src="{{ static('bundled/vendored/jquery-ui.min.js') }}"></script>
|
||||||
<script src="{{ static('core/js/script.js') }}"></script>
|
<script src="{{ static('core/js/script.js') }}"></script>
|
||||||
|
|
||||||
|
|
||||||
@ -37,148 +38,13 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- The token is always passed here to be accessible from the dom -->
|
<!-- The token is always passed here to be accessible from the dom -->
|
||||||
<!-- See this workaround https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true -->
|
<!-- See this workaround https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true -->
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<!-- BEGIN HEADER -->
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% if not popup %}
|
{% if not popup %}
|
||||||
<header class="header">
|
{% include "core/base/header.jinja" %}
|
||||||
<div class="header-logo">
|
|
||||||
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
|
|
||||||
|
|
||||||
</a>
|
|
||||||
<a class="header-logo-text" href="{{ url('core:index') }}">
|
|
||||||
<span>Association des Étudiants</span>
|
|
||||||
<span>de l'Université de Technologie de Belfort-Montbéliard</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% if not user.is_authenticated %}
|
|
||||||
<div class="header-disconnected">
|
|
||||||
<a class="button" href="{{ url('core:login') }}">{% trans %}Login{% endtrans %}</a>
|
|
||||||
<a class="button" href="{{ url('core:register') }}">{% trans %}Register{% endtrans %}</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="header-connected">
|
|
||||||
<div class="left">
|
|
||||||
<form class="search" action="{{ url('core:search') }}" method="GET" id="header_search">
|
|
||||||
<input class="header-input" type="text" placeholder="{% trans %}Search{% endtrans %}" name="query" id="search" />
|
|
||||||
<input type="submit" value="{% trans %}Search{% endtrans %}" style="display: none;" />
|
|
||||||
</form>
|
|
||||||
<ul class="bars">
|
|
||||||
{% cache 100 "counters_activity" %}
|
|
||||||
{# The sith has no periodic tasks manager
|
|
||||||
and using cron jobs would be way too overkill here.
|
|
||||||
Thus the barmen timeout is handled in the only place that
|
|
||||||
is loaded on every page : the header bar.
|
|
||||||
However, let's be clear : this has nothing to do here.
|
|
||||||
It's' merely a contrived workaround that should
|
|
||||||
replaced by a proper task manager as soon as possible. #}
|
|
||||||
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
|
|
||||||
{% endcache %}
|
|
||||||
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
|
||||||
<li>
|
|
||||||
{# If the user is a barman, we redirect him directly to the barman page
|
|
||||||
else we redirect him to the activity page #}
|
|
||||||
{% if bar.has_annotated_barman %}
|
|
||||||
<a href="{{ url('counter:details', counter_id=bar.id) }}">
|
|
||||||
{% else %}
|
|
||||||
<a href="{{ url('counter:activity', counter_id=bar.id) }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if bar.is_open %}
|
|
||||||
<i class="fa fa-check" style="color: #2ecc71"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fa fa-times" style="color: #eb2f06"></i>
|
|
||||||
{% endif %}
|
|
||||||
<span>{{ bar }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="right">
|
|
||||||
<div class="user">
|
|
||||||
<div class="options">
|
|
||||||
<div class="username">
|
|
||||||
<a href="{{ url('core:user_profile', user_id=user.id) }}">{{ user.get_display_name() }}</a>
|
|
||||||
</div>
|
|
||||||
<div class="links">
|
|
||||||
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
|
|
||||||
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="{{ url('core:user_profile', user_id=user.id) }}"
|
|
||||||
{% if user.profile_pict %}
|
|
||||||
style="background-image: url('{{ user.profile_pict.get_download_url() }}')"
|
|
||||||
{% else %}
|
|
||||||
style="background-image: url('{{ static('core/img/unknown.jpg') }}')"
|
|
||||||
{% endif %}
|
|
||||||
></a>
|
|
||||||
</div>
|
|
||||||
<div class="notification">
|
|
||||||
<a href="#" onclick="displayNotif()">
|
|
||||||
<i class="fa-regular fa-bell"></i>
|
|
||||||
{% set notification_count = user.notifications.filter(viewed=False).count() %}
|
|
||||||
|
|
||||||
{% if notification_count > 0 %}
|
|
||||||
<span>
|
|
||||||
{% if notification_count < 100 %}
|
|
||||||
{{ notification_count }}
|
|
||||||
{% else %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div id="header_notif">
|
|
||||||
<ul>
|
|
||||||
{% if user.notifications.filter(viewed=False).count() > 0 %}
|
|
||||||
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ url("core:notification", notif_id=n.id) }}">
|
|
||||||
<div class="datetime">
|
|
||||||
<span class="header_notif_date">
|
|
||||||
{{ n.date|localtime|date(DATE_FORMAT) }}
|
|
||||||
</span>
|
|
||||||
<span class="header_notif_time">
|
|
||||||
{{ n.date|localtime|time(DATETIME_FORMAT) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="reason">
|
|
||||||
{{ n }}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
<div class="options">
|
|
||||||
<a href="{{ url('core:notification_list') }}">
|
|
||||||
{% trans %}View more{% endtrans %}
|
|
||||||
</a>
|
|
||||||
<a href="{{ url('core:notification_list') }}?see_all">
|
|
||||||
{% trans %}Mark all as read{% endtrans %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="header-lang">
|
|
||||||
{% for language in LANGUAGES %}
|
|
||||||
<form action="{{ url('set_language') }}" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input name="next" value="{{ request.path }}" type="hidden" />
|
|
||||||
<input name="language" value="{{ language[0] }}" type="hidden" />
|
|
||||||
<input type="submit" value="{% if language[0] == 'en' %}🇬🇧{% else %}🇫🇷{% endif %}" />
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% block info_boxes %}
|
{% block info_boxes %}
|
||||||
<div id="info_boxes">
|
<div id="info_boxes">
|
||||||
@ -201,58 +67,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<!-- END HEADER -->
|
|
||||||
|
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
{% if not popup %}
|
{% if not popup %}
|
||||||
<nav class="navbar">
|
{% include "core/base/navbar.jinja" %}
|
||||||
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
|
|
||||||
<div id="navbar-content" class="content" style="display: none;">
|
|
||||||
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
|
|
||||||
<div class="menu">
|
|
||||||
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
|
|
||||||
<ul class="content">
|
|
||||||
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="menu">
|
|
||||||
<span class="head">{% trans %}Events{% endtrans %}</span>
|
|
||||||
<ul class="content">
|
|
||||||
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
|
|
||||||
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
|
|
||||||
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
|
|
||||||
<div class="menu">
|
|
||||||
<span class="head">{% trans %}Services{% endtrans %}</span>
|
|
||||||
<ul class="content">
|
|
||||||
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
|
||||||
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="menu">
|
|
||||||
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
|
|
||||||
<ul class="content">
|
|
||||||
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="menu">
|
|
||||||
<span class="head">{% trans %}Help{% endtrans %}</span>
|
|
||||||
<ul class="content">
|
|
||||||
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
|
|
||||||
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -265,19 +83,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id="content">
|
<div id="content">
|
||||||
{% if list_of_tabs %}
|
{% block tabs %}
|
||||||
<div class="tool_bar">
|
{% include "core/base/tabs.jinja" %}
|
||||||
<div class="tools">
|
{% endblock %}
|
||||||
{% for t in list_of_tabs -%}
|
|
||||||
<a href="{{ t.url }}" {%- if current_tab==t.slug %} class="selected_tab" {%- endif -%}>{{ t.name }}</a>
|
{% block errors%}
|
||||||
{%- endfor %}
|
{% if error %}
|
||||||
</div>
|
{{ error }}
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
{% endblock %}
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
{{ error }}
|
|
||||||
{% endif %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
136
core/templates/core/base/header.jinja
Normal file
136
core/templates/core/base/header.jinja
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<header class="header">
|
||||||
|
<div class="header-logo">
|
||||||
|
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')">
|
||||||
|
|
||||||
|
</a>
|
||||||
|
<a class="header-logo-text" href="{{ url('core:index') }}">
|
||||||
|
<span>Association des Étudiants</span>
|
||||||
|
<span>de l'Université de Technologie de Belfort-Montbéliard</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if not user.is_authenticated %}
|
||||||
|
<div class="header-disconnected">
|
||||||
|
<a class="button" href="{{ url('core:login') }}">{% trans %}Login{% endtrans %}</a>
|
||||||
|
<a class="button" href="{{ url('core:register') }}">{% trans %}Register{% endtrans %}</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="header-connected">
|
||||||
|
<div class="left">
|
||||||
|
<form class="search" action="{{ url('core:search') }}" method="GET" id="header_search">
|
||||||
|
<input class="header-input" type="text" placeholder="{% trans %}Search{% endtrans %}" name="query" id="search" />
|
||||||
|
<input type="submit" value="{% trans %}Search{% endtrans %}" style="display: none;" />
|
||||||
|
</form>
|
||||||
|
<ul class="bars">
|
||||||
|
{% cache 100 "counters_activity" %}
|
||||||
|
{# The sith has no periodic tasks manager
|
||||||
|
and using cron jobs would be way too overkill here.
|
||||||
|
Thus the barmen timeout is handled in the only place that
|
||||||
|
is loaded on every page : the header bar.
|
||||||
|
However, let's be clear : this has nothing to do here.
|
||||||
|
It's' merely a contrived workaround that should
|
||||||
|
replaced by a proper task manager as soon as possible. #}
|
||||||
|
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
|
||||||
|
{% endcache %}
|
||||||
|
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
|
||||||
|
<li>
|
||||||
|
{# If the user is a barman, we redirect him directly to the barman page
|
||||||
|
else we redirect him to the activity page #}
|
||||||
|
{% if bar.has_annotated_barman %}
|
||||||
|
<a href="{{ url('counter:details', counter_id=bar.id) }}">
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url('counter:activity', counter_id=bar.id) }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if bar.is_open %}
|
||||||
|
<i class="fa fa-check" style="color: #2ecc71"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa fa-times" style="color: #eb2f06"></i>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ bar }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div class="user">
|
||||||
|
<div class="options">
|
||||||
|
<div class="username">
|
||||||
|
<a href="{{ url('core:user_profile', user_id=user.id) }}">{{ user.get_display_name() }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="links">
|
||||||
|
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
|
||||||
|
<a href="{{ url('core:logout') }}">{% trans %}Logout{% endtrans %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="{{ url('core:user_profile', user_id=user.id) }}"
|
||||||
|
{% if user.profile_pict %}
|
||||||
|
style="background-image: url('{{ user.profile_pict.get_download_url() }}')"
|
||||||
|
{% else %}
|
||||||
|
style="background-image: url('{{ static('core/img/unknown.jpg') }}')"
|
||||||
|
{% endif %}
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
<div class="notification">
|
||||||
|
<a href="#" onclick="displayNotif()">
|
||||||
|
<i class="fa-regular fa-bell"></i>
|
||||||
|
{% set notification_count = user.notifications.filter(viewed=False).count() %}
|
||||||
|
|
||||||
|
{% if notification_count > 0 %}
|
||||||
|
<span>
|
||||||
|
{% if notification_count < 100 %}
|
||||||
|
{{ notification_count }}
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div id="header_notif">
|
||||||
|
<ul>
|
||||||
|
{% if user.notifications.filter(viewed=False).count() > 0 %}
|
||||||
|
{% for n in user.notifications.filter(viewed=False).order_by('-date') %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url("core:notification", notif_id=n.id) }}">
|
||||||
|
<div class="datetime">
|
||||||
|
<span class="header_notif_date">
|
||||||
|
{{ n.date|localtime|date(DATE_FORMAT) }}
|
||||||
|
</span>
|
||||||
|
<span class="header_notif_time">
|
||||||
|
{{ n.date|localtime|time(DATETIME_FORMAT) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="reason">
|
||||||
|
{{ n }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<li class="empty-notification">{% trans %}You do not have any unread notification{% endtrans %}</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<div class="options">
|
||||||
|
<a href="{{ url('core:notification_list') }}">
|
||||||
|
{% trans %}View more{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url('core:notification_list') }}?see_all">
|
||||||
|
{% trans %}Mark all as read{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="header-lang">
|
||||||
|
{% for language in LANGUAGES %}
|
||||||
|
<form action="{{ url('set_language') }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input name="next" value="{{ request.path }}" type="hidden" />
|
||||||
|
<input name="language" value="{{ language[0] }}" type="hidden" />
|
||||||
|
<input type="submit" value="{% if language[0] == 'en' %}🇬🇧{% else %}🇫🇷{% endif %}" />
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</header>
|
48
core/templates/core/base/navbar.jinja
Normal file
48
core/templates/core/base/navbar.jinja
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<nav class="navbar">
|
||||||
|
<button class="expand-button" onclick="showMenu()"><i class="fa fa-bars"></i></button>
|
||||||
|
<div id="navbar-content" class="content" style="display: none;">
|
||||||
|
<a class="link" href="{{ url('core:index') }}">{% trans %}Main{% endtrans %}</a>
|
||||||
|
<div class="menu">
|
||||||
|
<span class="head">{% trans %}Associations & Clubs{% endtrans %}</span>
|
||||||
|
<ul class="content">
|
||||||
|
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="menu">
|
||||||
|
<span class="head">{% trans %}Events{% endtrans %}</span>
|
||||||
|
<ul class="content">
|
||||||
|
<li><a href="{{ url('election:list') }}">{% trans %}Elections{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', page_name='ga') }}">{% trans %}Big event{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a class="link" href="{{ url('forum:main') }}">{% trans %}Forum{% endtrans %}</a>
|
||||||
|
<a class="link" href="{{ url('sas:main') }}">{% trans %}Gallery{% endtrans %}</a>
|
||||||
|
<a class="link" href="{{ url('eboutic:main') }}">{% trans %}Eboutic{% endtrans %}</a>
|
||||||
|
<div class="menu">
|
||||||
|
<span class="head">{% trans %}Services{% endtrans %}</span>
|
||||||
|
<ul class="content">
|
||||||
|
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
|
||||||
|
<li><a href="/launderette">{% trans %}Launderette{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="menu">
|
||||||
|
<span class="head">{% trans %}My Benefits{% endtrans %}</span>
|
||||||
|
<ul class="content">
|
||||||
|
<li><a href="{{ url('core:page', page_name='partenaires')}}">{% trans %}Sponsors{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', page_name='avantages') }}">{% trans %}Subscriber benefits{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="menu">
|
||||||
|
<span class="head">{% trans %}Help{% endtrans %}</span>
|
||||||
|
<ul class="content">
|
||||||
|
<li><a href="{{ url('core:page', page_name='FAQ') }}">{% trans %}FAQ{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a></li>
|
||||||
|
<li><a href="{{ url('core:page', page_name='Index') }}">{% trans %}Wiki{% endtrans %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
9
core/templates/core/base/tabs.jinja
Normal file
9
core/templates/core/base/tabs.jinja
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{% if list_of_tabs %}
|
||||||
|
<div class="tool_bar">
|
||||||
|
<div class="tools">
|
||||||
|
{% for t in list_of_tabs -%}
|
||||||
|
<a href="{{ t.url }}" {%- if current_tab==t.slug %} class="selected_tab" {%- endif -%}>{{ t.name }}</a>
|
||||||
|
{%- endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
20
core/templates/core/base_fragment.jinja
Normal file
20
core/templates/core/base_fragment.jinja
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% block additional_css %}{% endblock %}
|
||||||
|
{% block additional_js %}{% endblock %}
|
||||||
|
|
||||||
|
<div id="fragment-content">
|
||||||
|
{% block tabs %}
|
||||||
|
{% include "core/base/tabs.jinja" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block errors %}
|
||||||
|
{% if error %}
|
||||||
|
{{ error }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
{% endblock %}
|
@ -1,4 +1,8 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% if is_fragment %}
|
||||||
|
{% extends "core/base_fragment.jinja" %}
|
||||||
|
{% else %}
|
||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if file %}
|
{% if file %}
|
||||||
@ -21,7 +25,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block tabs %}
|
||||||
{{ print_file_name(file) }}
|
{{ print_file_name(file) }}
|
||||||
|
|
||||||
<div class="tool_bar">
|
<div class="tool_bar">
|
||||||
@ -44,6 +48,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
{% if file %}
|
{% if file %}
|
||||||
{% block file %}
|
{% block file %}
|
||||||
|
@ -4,15 +4,49 @@
|
|||||||
{% trans %}Delete confirmation{% endtrans %}
|
{% trans %}Delete confirmation{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if is_fragment %}
|
||||||
|
|
||||||
|
{# Don't display tabs and errors #}
|
||||||
|
{% block tabs %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block errors %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block file %}
|
{% block file %}
|
||||||
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
|
<h2>{% trans %}Delete confirmation{% endtrans %}</h2>
|
||||||
<form action="" method="post">{% csrf_token %}
|
|
||||||
|
{% if next %}
|
||||||
|
{% set action = current + "?next=" + next %}
|
||||||
|
{% else %}
|
||||||
|
{% set action = current %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="{{ action }}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
|
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
|
||||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
<button
|
||||||
</form>
|
{% if is_fragment %}
|
||||||
<form method="GET" action="javascript:history.back();">
|
hx-post="{{ action }}"
|
||||||
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
|
hx-target="#content"
|
||||||
|
hx-swap="outerHtml"
|
||||||
|
{% endif %}
|
||||||
|
>{% trans %}Confirm{% endtrans %}</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{% if is_fragment %}
|
||||||
|
hx-get="{{ previous }}"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHtml"
|
||||||
|
{% else %}
|
||||||
|
action="window.history.back()"
|
||||||
|
{% endif %}
|
||||||
|
>{% trans %}Cancel{% endtrans %}</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,16 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% if is_fragment %}
|
||||||
|
{% extends "core/base_fragment.jinja" %}
|
||||||
|
|
||||||
|
{# Don't display tabs and errors #}
|
||||||
|
{% block tabs %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block errors %}
|
||||||
|
{% endblock %}
|
||||||
|
{% else %}
|
||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% from "core/macros.jinja" import paginate_htmx %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}File moderation{% endtrans %}
|
{% trans %}File moderation{% endtrans %}
|
||||||
@ -7,8 +19,11 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}File moderation{% endtrans %}</h3>
|
<h3>{% trans %}File moderation{% endtrans %}</h3>
|
||||||
<div>
|
<div>
|
||||||
{% for f in files %}
|
{% for f in object_list %}
|
||||||
<div style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center">
|
<div
|
||||||
|
id="file-{{ loop.index }}"
|
||||||
|
style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center"
|
||||||
|
>
|
||||||
{% if f.is_folder %}
|
{% if f.is_folder %}
|
||||||
<strong>Folder</strong>
|
<strong>Folder</strong>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -20,9 +35,19 @@
|
|||||||
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
||||||
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
||||||
</p>
|
</p>
|
||||||
<p><a href="{{ url('core:file_moderate', file_id=f.id) }}">{% trans %}Moderate{% endtrans %}</a> -
|
<p><button
|
||||||
<a href="{{ url('core:file_delete', file_id=f.id) }}?next={{ url('core:file_moderation') }}">{% trans %}Delete{% endtrans %}</a></p>
|
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-swap="outerHtml"
|
||||||
|
>{% trans %}Moderate{% endtrans %}</button> -
|
||||||
|
{% set current_page = url('core:file_moderation') + "?page=" + page_obj.number | string %}
|
||||||
|
<button
|
||||||
|
hx-get="{{ url('core:file_delete', file_id=f.id) }}?next={{ current_page | urlencode }}&previous={{ current_page | urlencode }}"
|
||||||
|
hx-target="#file-{{ loop.index }}"
|
||||||
|
hx-swap="outerHtml"
|
||||||
|
>{% trans %}Delete{% endtrans %}</button></p>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{{ paginate_htmx(page_obj, paginator) }}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -66,7 +66,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if user.promo and user.promo_has_logo() %}
|
{% if user.promo and user.promo_has_logo() %}
|
||||||
<div class="user_mini_profile_promo">
|
<div class="user_mini_profile_promo">
|
||||||
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
|
<img
|
||||||
|
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
|
||||||
|
title="Promo {{ user.promo }}"
|
||||||
|
alt="Promo {{ user.promo }}"
|
||||||
|
class="promo_pict"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -74,8 +79,11 @@
|
|||||||
{% if user.profile_pict %}
|
{% if user.profile_pict %}
|
||||||
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
|
<img
|
||||||
title="{% trans %}Profile{% endtrans %}" />
|
src="{{ static('core/img/unknown.jpg') }}"
|
||||||
|
alt="{% trans %}Profile{% endtrans %}"
|
||||||
|
title="{% trans %}Profile{% endtrans %}"
|
||||||
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -166,9 +174,37 @@
|
|||||||
current_page (django.core.paginator.Page): the current page object
|
current_page (django.core.paginator.Page): the current page object
|
||||||
paginator (django.core.paginator.Paginator): the paginator object
|
paginator (django.core.paginator.Paginator): the paginator object
|
||||||
#}
|
#}
|
||||||
|
{{ paginate_server_side(current_page, paginator, False) }}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro paginate_htmx(current_page, paginator) %}
|
||||||
|
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||||
|
|
||||||
|
This must be coupled with a view that handles pagination
|
||||||
|
with the Django Paginator object and supports fragments.
|
||||||
|
|
||||||
|
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
current_page (django.core.paginator.Page): the current page object
|
||||||
|
paginator (django.core.paginator.Paginator): the paginator object
|
||||||
|
#}
|
||||||
|
{{ paginate_server_side(current_page, paginator, True) }}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
{% if current_page.has_previous() %}
|
{% if current_page.has_previous() %}
|
||||||
<a href="?page={{ current_page.previous_page_number() }}">
|
<a
|
||||||
|
{% if use_htmx -%}
|
||||||
|
hx-get="?page={{ current_page.previous_page_number() }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
{%- else -%}
|
||||||
|
href="?page={{ current_page.previous_page_number() }}"
|
||||||
|
{%- endif -%}
|
||||||
|
>
|
||||||
<button>
|
<button>
|
||||||
<i class="fa fa-caret-left"></i>
|
<i class="fa fa-caret-left"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -182,16 +218,33 @@
|
|||||||
{% elif i == paginator.ELLIPSIS %}
|
{% elif i == paginator.ELLIPSIS %}
|
||||||
<strong>{{ paginator.ELLIPSIS }}</strong>
|
<strong>{{ paginator.ELLIPSIS }}</strong>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="?page={{ i }}">
|
<a
|
||||||
|
{% if use_htmx -%}
|
||||||
|
hx-get="?page={{ i }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
{%- else -%}
|
||||||
|
href="?page={{ i }}"
|
||||||
|
{%- endif -%}
|
||||||
|
>
|
||||||
<button>{{ i }}</button>
|
<button>{{ i }}</button>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current_page.has_next() %}
|
{% if current_page.has_next() %}
|
||||||
<a href="?page={{ current_page.next_page_number() }}">
|
<a
|
||||||
<button>
|
{% if use_htmx -%}
|
||||||
<i class="fa fa-caret-right"></i>
|
hx-get="?page={{ current_page.next_page_number() }}"
|
||||||
</button>
|
hx-swap="innerHTML"
|
||||||
|
hx-target="#content"
|
||||||
|
hx-push-url="true"
|
||||||
|
{%- else -%}
|
||||||
|
href="?page={{ current_page.next_page_number() }}"
|
||||||
|
{%- endif -%}
|
||||||
|
><button>
|
||||||
|
<i class="fa fa-caret-right"></i>
|
||||||
|
</button>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
||||||
@ -202,9 +255,9 @@
|
|||||||
{% macro select_all_checkbox(form_id) %}
|
{% macro select_all_checkbox(form_id) %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function checkbox_{{form_id}}(value) {
|
function checkbox_{{form_id}}(value) {
|
||||||
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||||
for (let element of list){
|
for (let element of inputs){
|
||||||
if (element.type == "checkbox"){
|
if (element.type === "checkbox"){
|
||||||
element.checked = value;
|
element.checked = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -213,3 +266,65 @@
|
|||||||
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
||||||
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro tabs(tab_list, attrs = "") %}
|
||||||
|
{# Tab component
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
tab_list: list[tuple[str, str]] The list of tabs to display.
|
||||||
|
Each element of the list is a tuple which first element
|
||||||
|
is the title of the tab and the second element its content
|
||||||
|
attrs: str Additional attributes to put on the enclosing div
|
||||||
|
|
||||||
|
Example:
|
||||||
|
A basic usage would be as follow :
|
||||||
|
|
||||||
|
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
|
||||||
|
|
||||||
|
If you want to display more complex logic, you can define macros
|
||||||
|
and use those macros in parameters :
|
||||||
|
|
||||||
|
{{ tabs([("title", my_macro())]) }}
|
||||||
|
|
||||||
|
It's also possible to get and set the currently selected tab using Alpine.
|
||||||
|
Here, the title of the currently selected tab will be displayed.
|
||||||
|
Moreover, on page load, the tab will be opened on "tab 2".
|
||||||
|
|
||||||
|
<div x-data="{current_tab: 'tab 2'}">
|
||||||
|
<p x-text="current_tab"></p>
|
||||||
|
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
If you want to have translated tab titles, you can enclose the macro call
|
||||||
|
in a with block :
|
||||||
|
|
||||||
|
{% with title=_("title"), content=_("Content") %}
|
||||||
|
{{ tabs([(tab1, content)]) }}
|
||||||
|
{% endwith %}
|
||||||
|
#}
|
||||||
|
<div
|
||||||
|
class="tabs shadow"
|
||||||
|
x-data="{selected: '{{ tab_list[0][0] }}'}"
|
||||||
|
x-modelable="selected"
|
||||||
|
{{ attrs }}
|
||||||
|
>
|
||||||
|
<div class="tab-headers">
|
||||||
|
{% for title, _ in tab_list %}
|
||||||
|
<button
|
||||||
|
class="tab-header clickable"
|
||||||
|
:class="{active: selected === '{{ title }}'}"
|
||||||
|
@click="selected = '{{ title }}'"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-content">
|
||||||
|
{% for title, content in tab_list %}
|
||||||
|
<section x-show="selected === '{{ title }}'">
|
||||||
|
{{ content }}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
@ -77,7 +77,7 @@
|
|||||||
{% set default_picture = this_picture.get_download_url()|tojson %}
|
{% set default_picture = this_picture.get_download_url()|tojson %}
|
||||||
{% set delete_url = (
|
{% set delete_url = (
|
||||||
url('core:file_delete', file_id=this_picture.id, popup='')
|
url('core:file_delete', file_id=this_picture.id, popup='')
|
||||||
+"?next=" + profile.get_absolute_url()
|
+ "?next=" + url('core:user_edit', user_id=profile.id)
|
||||||
)|tojson %}
|
)|tojson %}
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
{% set default_picture = static('core/img/unknown.jpg')|tojson %}
|
{% set default_picture = static('core/img/unknown.jpg')|tojson %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script src="{{ static("webpack/user/family-graph-index.js") }}" defer></script>
|
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script src="{{ static('webpack/user/pictures-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/user/pictures-index.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% for js in statics.js %}
|
{% for js in statics.js %}
|
||||||
<script-once src="{{ js }}" defer></script-once>
|
<script-once type="module" src="{{ js }}"></script-once>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for css in statics.css %}
|
{% for css in statics.css %}
|
||||||
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<div>
|
<div>
|
||||||
<script-once src="{{ statics.js }}" defer></script-once>
|
<script-once type="module" src="{{ statics.js }}"></script-once>
|
||||||
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
||||||
|
|
||||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||||
|
@ -1,33 +1,6 @@
|
|||||||
<span>
|
<script-once type="module" src="{{ statics.js }}"></script-once>
|
||||||
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
|
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
||||||
<!-- NFC icon not available in fontawesome 4.7 -->
|
|
||||||
<button type="button" id="{{ widget.attrs.id }}_button"><i class="fa-brands fa-nfc-symbol"></i></button>
|
|
||||||
</span>
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function(event) {
|
|
||||||
let button = document.getElementById("{{ widget.attrs.id }}_button");
|
|
||||||
button.addEventListener("click", async () => {
|
|
||||||
let input = document.getElementById("{{ widget.attrs.id }}");
|
|
||||||
const ndef = new NDEFReader();
|
|
||||||
await ndef.scan();
|
|
||||||
|
|
||||||
ndef.addEventListener("readingerror", () => {
|
<span>
|
||||||
alert("{{ translations.unsupported }}")
|
<nfc-input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}></nfc-input>
|
||||||
});
|
</span>
|
||||||
|
|
||||||
ndef.addEventListener("reading", ({ message, serialNumber }) => {
|
|
||||||
input.value = serialNumber.replaceAll(":", "").toUpperCase();
|
|
||||||
/* Auto submit form */
|
|
||||||
b = document.createElement("button");
|
|
||||||
input.appendChild(b)
|
|
||||||
b.click()
|
|
||||||
b.remove()
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
/* Disable feature if browser is not supported or if not HTTPS */
|
|
||||||
if (typeof NDEFReader === "undefined") {
|
|
||||||
button.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
@ -21,9 +21,11 @@ import pytest
|
|||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, RequestFactory, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from django.views.generic import View
|
||||||
|
from django.views.generic.base import ContextMixin
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||||
|
|
||||||
@ -32,6 +34,7 @@ from club.models import Membership
|
|||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Group, Page, User
|
from core.models import AnonymousUser, Group, Page, User
|
||||||
from core.utils import get_semester_code, get_start_of_semester
|
from core.utils import get_semester_code, get_start_of_semester
|
||||||
|
from core.views import AllowFragment
|
||||||
from sith import settings
|
from sith import settings
|
||||||
|
|
||||||
|
|
||||||
@ -538,3 +541,18 @@ class TestDateUtils(TestCase):
|
|||||||
# forward time to the middle of the next semester
|
# forward time to the middle of the next semester
|
||||||
frozen_time.move_to(mid_autumn)
|
frozen_time.move_to(mid_autumn)
|
||||||
assert get_start_of_semester() == autumn_2023
|
assert get_start_of_semester() == autumn_2023
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_fragment_mixin():
|
||||||
|
class TestAllowFragmentView(AllowFragment, ContextMixin, View):
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
context = self.get_context_data(**kwargs)
|
||||||
|
return context["is_fragment"]
|
||||||
|
|
||||||
|
request = RequestFactory().get("/test")
|
||||||
|
base_headers = request.headers
|
||||||
|
assert not TestAllowFragmentView.as_view()(request)
|
||||||
|
request.headers = {"HX-Request": False, **base_headers}
|
||||||
|
assert not TestAllowFragmentView.as_view()(request)
|
||||||
|
request.headers = {"HX-Request": True, **base_headers}
|
||||||
|
assert TestAllowFragmentView.as_view()(request)
|
||||||
|
@ -142,6 +142,30 @@ class TestFileHandling(TestCase):
|
|||||||
assert "ls</a>" in str(response.content)
|
assert "ls</a>" in str(response.content)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestFileModerationView:
|
||||||
|
"""Test access to file moderation view"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("user_factory", "status_code"),
|
||||||
|
[
|
||||||
|
(lambda: None, 403), # Anonymous user
|
||||||
|
(lambda: baker.make(User, is_superuser=True), 200),
|
||||||
|
(lambda: baker.make(User), 403),
|
||||||
|
(lambda: subscriber_user.make(), 403),
|
||||||
|
(lambda: old_subscriber_user.make(), 403),
|
||||||
|
(lambda: board_user.make(), 403),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_view_access(
|
||||||
|
self, client: Client, user_factory: Callable[[], User | None], status_code: int
|
||||||
|
):
|
||||||
|
user = user_factory()
|
||||||
|
if user: # if None, then it's an anonymous user
|
||||||
|
client.force_login(user_factory())
|
||||||
|
assert client.get(reverse("core:file_moderation")).status_code == status_code
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestUserProfilePicture:
|
class TestUserProfilePicture:
|
||||||
"""Test interactions with user's profile picture."""
|
"""Test interactions with user's profile picture."""
|
||||||
|
@ -166,3 +166,24 @@ def test_user_invoice_with_multiple_items():
|
|||||||
.values_list("total", flat=True)
|
.values_list("total", flat=True)
|
||||||
)
|
)
|
||||||
assert res == [15, 13, 5]
|
assert res == [15, 13, 5]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("first_name", "last_name", "expected"),
|
||||||
|
[
|
||||||
|
("Auguste", "Bartholdi", "abartholdi2"), # ville du lion rpz
|
||||||
|
("Aristide", "Denfert-Rochereau", "adenfertrochereau"),
|
||||||
|
("John", "Dôe", "jdoe"), # with an accent
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_generate_username(first_name: str, last_name: str, expected: str):
|
||||||
|
baker.make(
|
||||||
|
User,
|
||||||
|
username=iter(["abar", "abartholdi", "abartholdi1", "abar1"]),
|
||||||
|
_quantity=4,
|
||||||
|
_bulk_create=True,
|
||||||
|
)
|
||||||
|
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
|
||||||
|
new_user.generate_username()
|
||||||
|
assert new_user.username == expected
|
||||||
|
@ -326,6 +326,14 @@ class DetailFormView(SingleObjectMixin, FormView):
|
|||||||
return super().get_object()
|
return super().get_object()
|
||||||
|
|
||||||
|
|
||||||
|
class AllowFragment:
|
||||||
|
"""Add `is_fragment` to templates. It's only True if the request is emitted by htmx"""
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
kwargs["is_fragment"] = self.request.headers.get("HX-Request", False)
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
# F403: those star-imports would be hellish to refactor
|
# F403: those star-imports would be hellish to refactor
|
||||||
# E402: putting those import at the top of the file would also be difficult
|
# E402: putting those import at the top of the file would also be difficult
|
||||||
from .files import * # noqa: F403 E402
|
from .files import * # noqa: F403 E402
|
||||||
|
@ -27,12 +27,13 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import http_date
|
from django.utils.http import http_date
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, TemplateView
|
from django.views.generic import DetailView, ListView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
from django.views.generic.edit import DeleteView, FormMixin, UpdateView
|
||||||
|
|
||||||
from core.models import Notification, RealGroup, SithFile
|
from core.models import Notification, RealGroup, SithFile, User
|
||||||
from core.views import (
|
from core.views import (
|
||||||
|
AllowFragment,
|
||||||
CanEditMixin,
|
CanEditMixin,
|
||||||
CanEditPropMixin,
|
CanEditPropMixin,
|
||||||
CanViewMixin,
|
CanViewMixin,
|
||||||
@ -352,7 +353,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
|||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class FileDeleteView(CanEditPropMixin, DeleteView):
|
class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||||
model = SithFile
|
model = SithFile
|
||||||
pk_url_kwarg = "file_id"
|
pk_url_kwarg = "file_id"
|
||||||
template_name = "core/file_delete_confirm.jinja"
|
template_name = "core/file_delete_confirm.jinja"
|
||||||
@ -376,19 +377,24 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["popup"] = ""
|
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
|
||||||
if self.kwargs.get("popup") is not None:
|
kwargs["next"] = self.request.GET.get("next", None)
|
||||||
kwargs["popup"] = "popup"
|
kwargs["previous"] = self.request.GET.get("previous", None)
|
||||||
|
kwargs["current"] = self.request.path
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
class FileModerationView(TemplateView):
|
class FileModerationView(AllowFragment, ListView):
|
||||||
|
model = SithFile
|
||||||
template_name = "core/file_moderation.jinja"
|
template_name = "core/file_moderation.jinja"
|
||||||
|
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||||
|
paginate_by = 100
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
user: User = request.user
|
||||||
kwargs["files"] = SithFile.objects.filter(is_moderated=False)[:100]
|
if user.is_root:
|
||||||
return kwargs
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
class FileModerateView(CanEditPropMixin, SingleObjectMixin):
|
class FileModerateView(CanEditPropMixin, SingleObjectMixin):
|
||||||
|
@ -27,6 +27,9 @@ from captcha.fields import CaptchaField
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||||
|
from django.contrib.staticfiles.management.commands.collectstatic import (
|
||||||
|
staticfiles_storage,
|
||||||
|
)
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.forms import (
|
from django.forms import (
|
||||||
@ -72,7 +75,10 @@ class NFCTextInput(TextInput):
|
|||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
context["translations"] = {"unsupported": _("Unsupported NFC card")}
|
context["statics"] = {
|
||||||
|
"js": staticfiles_storage.url("bundled/core/components/nfc-input-index.ts"),
|
||||||
|
"css": staticfiles_storage.url("core/components/nfc-input.scss"),
|
||||||
|
}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ class MarkdownInput(Textarea):
|
|||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
|
|
||||||
context["statics"] = {
|
context["statics"] = {
|
||||||
"js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"),
|
"js": staticfiles_storage.url("bundled/core/components/easymde-index.ts"),
|
||||||
"css": staticfiles_storage.url("webpack/core/components/easymde-index.css"),
|
"css": staticfiles_storage.url("bundled/core/components/easymde-index.css"),
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
|
@ -19,10 +19,10 @@ class AutoCompleteSelectMixin:
|
|||||||
pk = "id"
|
pk = "id"
|
||||||
|
|
||||||
js = [
|
js = [
|
||||||
"webpack/core/components/ajax-select-index.ts",
|
"bundled/core/components/ajax-select-index.ts",
|
||||||
]
|
]
|
||||||
css = [
|
css = [
|
||||||
"webpack/core/components/ajax-select-index.css",
|
"bundled/core/components/ajax-select-index.css",
|
||||||
"core/components/ajax-select.scss",
|
"core/components/ajax-select.scss",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -63,15 +63,25 @@ class BillingInfoAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(AccountDump)
|
@admin.register(AccountDump)
|
||||||
class AccountDumpAdmin(admin.ModelAdmin):
|
class AccountDumpAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "warning_mail_sent_at"
|
||||||
list_display = (
|
list_display = (
|
||||||
"customer",
|
"customer",
|
||||||
"warning_mail_sent_at",
|
"warning_mail_sent_at",
|
||||||
"warning_mail_error",
|
"warning_mail_error",
|
||||||
"dump_operation",
|
"dump_operation",
|
||||||
|
"amount",
|
||||||
)
|
)
|
||||||
autocomplete_fields = ("customer",)
|
autocomplete_fields = ("customer", "dump_operation")
|
||||||
list_filter = ("warning_mail_error",)
|
list_filter = ("warning_mail_error",)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
# the `amount` property requires to know the customer and the dump_operation
|
||||||
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset(request)
|
||||||
|
.select_related("customer", "customer__user", "dump_operation")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Counter)
|
@admin.register(Counter)
|
||||||
class CounterAdmin(admin.ModelAdmin):
|
class CounterAdmin(admin.ModelAdmin):
|
||||||
|
18
counter/baker_recipes.py
Normal file
18
counter/baker_recipes.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from model_bakery.recipe import Recipe, foreign_key
|
||||||
|
|
||||||
|
from club.models import Club
|
||||||
|
from core.models import User
|
||||||
|
from counter.models import Counter, Product, Refilling, Selling
|
||||||
|
|
||||||
|
counter_recipe = Recipe(Counter)
|
||||||
|
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
|
||||||
|
sale_recipe = Recipe(
|
||||||
|
Selling,
|
||||||
|
product=foreign_key(product_recipe),
|
||||||
|
counter=foreign_key(counter_recipe),
|
||||||
|
seller=foreign_key(Recipe(User)),
|
||||||
|
club=foreign_key(Recipe(Club)),
|
||||||
|
)
|
||||||
|
refill_recipe = Recipe(
|
||||||
|
Refilling, counter=foreign_key(counter_recipe), operator=foreign_key(Recipe(User))
|
||||||
|
)
|
148
counter/management/commands/dump_accounts.py
Normal file
148
counter/management/commands/dump_accounts.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.mail import send_mass_mail
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Exists, OuterRef, QuerySet
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from core.models import User, UserQuerySet
|
||||||
|
from counter.models import AccountDump, Counter, Customer, Selling
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Effectively dump the inactive users.
|
||||||
|
|
||||||
|
Users who received a warning mail enough time ago will
|
||||||
|
have their account emptied, unless they reactivated their
|
||||||
|
account in the meantime (e.g. by resubscribing).
|
||||||
|
|
||||||
|
This command should be automated with a cron task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Don't do anything, just display the number of users concerned",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
users = self._get_users()
|
||||||
|
# some users may have resubscribed or performed a purchase
|
||||||
|
# (which reactivates the account).
|
||||||
|
# Those reactivated users are not to be mailed about their account dump.
|
||||||
|
# Instead, the related AccountDump row will be dropped,
|
||||||
|
# as if nothing ever happened.
|
||||||
|
# Refunding a user implies a transaction, so refunded users
|
||||||
|
# count as reactivated users
|
||||||
|
users_to_dump_qs = users.filter_inactive()
|
||||||
|
reactivated_users = list(users.difference(users_to_dump_qs))
|
||||||
|
users_to_dump = list(users_to_dump_qs)
|
||||||
|
self.stdout.write(
|
||||||
|
f"{len(reactivated_users)} users have reactivated their account"
|
||||||
|
)
|
||||||
|
self.stdout.write(f"{len(users_to_dump)} users will see their account dumped")
|
||||||
|
|
||||||
|
if options["dry_run"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
AccountDump.objects.ongoing().filter(
|
||||||
|
customer__user__in=reactivated_users
|
||||||
|
).delete()
|
||||||
|
self._dump_accounts({u.customer for u in users_to_dump})
|
||||||
|
self._send_mails(users_to_dump)
|
||||||
|
self.stdout.write("Finished !")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_users() -> UserQuerySet:
|
||||||
|
"""Fetch the users which have a pending account dump."""
|
||||||
|
threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA
|
||||||
|
ongoing_dump_operations: QuerySet[AccountDump] = (
|
||||||
|
AccountDump.objects.ongoing()
|
||||||
|
.filter(customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold)
|
||||||
|
) # fmt: off
|
||||||
|
# cf. https://github.com/astral-sh/ruff/issues/14103
|
||||||
|
return (
|
||||||
|
User.objects.filter(Exists(ongoing_dump_operations))
|
||||||
|
.annotate(
|
||||||
|
warning_date=ongoing_dump_operations.values("warning_mail_sent_at")
|
||||||
|
)
|
||||||
|
.select_related("customer")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def _dump_accounts(accounts: set[Customer]):
|
||||||
|
"""Perform the actual db operations to dump the accounts.
|
||||||
|
|
||||||
|
An account dump completion is a two steps process:
|
||||||
|
- create a special sale which price is equal
|
||||||
|
to the money in the account
|
||||||
|
- update the pending account dump operation
|
||||||
|
by linking it to the aforementioned sale
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accounts: the customer accounts which must be emptied
|
||||||
|
"""
|
||||||
|
# Dump operations are special sales,
|
||||||
|
# which price is equal to the money the user has.
|
||||||
|
# They are made in a special counter (which should belong to the AE).
|
||||||
|
# However, they are not linked to a product, because it would
|
||||||
|
# make no sense to have a product without price.
|
||||||
|
customer_ids = [account.pk for account in accounts]
|
||||||
|
pending_dumps: list[AccountDump] = list(
|
||||||
|
AccountDump.objects.ongoing()
|
||||||
|
.filter(customer_id__in=customer_ids)
|
||||||
|
.order_by("customer_id")
|
||||||
|
)
|
||||||
|
if len(pending_dumps) != len(customer_ids):
|
||||||
|
raise ValueError("One or more accounts were not engaged in a dump process")
|
||||||
|
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
|
||||||
|
sales = Selling.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Selling(
|
||||||
|
label="Vidange compte inactif",
|
||||||
|
club=counter.club,
|
||||||
|
counter=counter,
|
||||||
|
seller=None,
|
||||||
|
product=None,
|
||||||
|
customer=account,
|
||||||
|
quantity=1,
|
||||||
|
unit_price=account.amount,
|
||||||
|
date=now(),
|
||||||
|
is_validated=True,
|
||||||
|
)
|
||||||
|
for account in accounts
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sales.sort(key=attrgetter("customer_id"))
|
||||||
|
|
||||||
|
# dumps and sales are linked to the same customers
|
||||||
|
# and or both ordered with the same key, so zipping them is valid
|
||||||
|
for dump, sale in zip(pending_dumps, sales):
|
||||||
|
dump.dump_operation = sale
|
||||||
|
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
|
||||||
|
|
||||||
|
# Because the sales were created with a bull_create,
|
||||||
|
# the account amounts haven't been updated,
|
||||||
|
# which mean we must do it explicitly
|
||||||
|
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_mails(users: Iterable[User]):
|
||||||
|
"""Send the mails informing users that their account has been dumped."""
|
||||||
|
mails = [
|
||||||
|
(
|
||||||
|
_("Your AE account has been emptied"),
|
||||||
|
render_to_string("counter/mails/account_dump.jinja", {"user": user}),
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[user.email],
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
send_mass_mail(mails)
|
@ -5,12 +5,12 @@ from smtplib import SMTPException
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db.models import Exists, OuterRef, QuerySet, Subquery
|
from django.db.models import Exists, OuterRef, Subquery
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.timezone import localdate, now
|
from django.utils.timezone import localdate, now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User, UserQuerySet
|
||||||
from counter.models import AccountDump
|
from counter.models import AccountDump
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("Finished !")
|
self.stdout.write("Finished !")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_users() -> QuerySet[User]:
|
def _get_users() -> UserQuerySet:
|
||||||
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
|
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
|
||||||
customer__user=OuterRef("pk")
|
customer__user=OuterRef("pk")
|
||||||
)
|
)
|
||||||
@ -97,7 +97,7 @@ class Command(BaseCommand):
|
|||||||
True if the mail was successfully sent, else False
|
True if the mail was successfully sent, else False
|
||||||
"""
|
"""
|
||||||
message = render_to_string(
|
message = render_to_string(
|
||||||
"counter/account_dump_warning_mail.jinja",
|
"counter/mails/account_dump_warning.jinja",
|
||||||
{
|
{
|
||||||
"balance": user.customer.amount,
|
"balance": user.customer.amount,
|
||||||
"last_subscription_date": user.last_subscription_date,
|
"last_subscription_date": user.last_subscription_date,
|
||||||
|
@ -20,14 +20,15 @@ import random
|
|||||||
import string
|
import string
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Self, Tuple
|
from typing import Self, Tuple
|
||||||
|
|
||||||
from dict2xml import dict2xml
|
from dict2xml import dict2xml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value
|
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||||
from django.db.models.functions import Concat, Length
|
from django.db.models.functions import Coalesce, Concat, Length
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -45,6 +46,39 @@ from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
|||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerQuerySet(models.QuerySet):
|
||||||
|
def update_amount(self) -> int:
|
||||||
|
"""Update the amount of all customers selected by this queryset.
|
||||||
|
|
||||||
|
The result is given as the sum of all refills minus the sum of all purchases.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of updated rows.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
The execution time of this query grows really quickly.
|
||||||
|
When updating 500 customers, it may take around a second.
|
||||||
|
If you try to update all customers at once, the execution time
|
||||||
|
goes up to tens of seconds.
|
||||||
|
Use this either on a small subset of the `Customer` table,
|
||||||
|
or execute it inside an independent task
|
||||||
|
(like a Celery task or a management command).
|
||||||
|
"""
|
||||||
|
money_in = Subquery(
|
||||||
|
Refilling.objects.filter(customer=OuterRef("pk"))
|
||||||
|
.values("customer_id") # group by customer
|
||||||
|
.annotate(res=Sum(F("amount"), default=0))
|
||||||
|
.values("res")
|
||||||
|
)
|
||||||
|
money_out = Subquery(
|
||||||
|
Selling.objects.filter(customer=OuterRef("pk"))
|
||||||
|
.values("customer_id")
|
||||||
|
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
|
||||||
|
.values("res")
|
||||||
|
)
|
||||||
|
return self.update(amount=Coalesce(money_in - money_out, Decimal("0")))
|
||||||
|
|
||||||
|
|
||||||
class Customer(models.Model):
|
class Customer(models.Model):
|
||||||
"""Customer data of a User.
|
"""Customer data of a User.
|
||||||
|
|
||||||
@ -57,6 +91,8 @@ class Customer(models.Model):
|
|||||||
amount = CurrencyField(_("amount"), default=0)
|
amount = CurrencyField(_("amount"), default=0)
|
||||||
recorded_products = models.IntegerField(_("recorded product"), default=0)
|
recorded_products = models.IntegerField(_("recorded product"), default=0)
|
||||||
|
|
||||||
|
objects = CustomerQuerySet.as_manager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("customer")
|
verbose_name = _("customer")
|
||||||
verbose_name_plural = _("customers")
|
verbose_name_plural = _("customers")
|
||||||
@ -141,18 +177,6 @@ class Customer(models.Model):
|
|||||||
account = cls.objects.create(user=user, account_id=account_id)
|
account = cls.objects.create(user=user, account_id=account_id)
|
||||||
return account, True
|
return account, True
|
||||||
|
|
||||||
def recompute_amount(self):
|
|
||||||
refillings = self.refillings.aggregate(sum=Sum(F("amount")))["sum"]
|
|
||||||
self.amount = refillings if refillings is not None else 0
|
|
||||||
purchases = (
|
|
||||||
self.buyings.filter(payment_method="SITH_ACCOUNT")
|
|
||||||
.annotate(amount=F("quantity") * F("unit_price"))
|
|
||||||
.aggregate(sum=Sum(F("amount")))
|
|
||||||
)["sum"]
|
|
||||||
if purchases is not None:
|
|
||||||
self.amount -= purchases
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def get_full_url(self):
|
def get_full_url(self):
|
||||||
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
|
return f"https://{settings.SITH_URL}{self.get_absolute_url()}"
|
||||||
|
|
||||||
@ -255,6 +279,14 @@ class AccountDump(models.Model):
|
|||||||
status = "ongoing" if self.dump_operation is None else "finished"
|
status = "ongoing" if self.dump_operation is None else "finished"
|
||||||
return f"{self.customer} - {status}"
|
return f"{self.customer} - {status}"
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def amount(self):
|
||||||
|
return (
|
||||||
|
self.dump_operation.unit_price
|
||||||
|
if self.dump_operation
|
||||||
|
else self.customer.amount
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProductType(models.Model):
|
class ProductType(models.Model):
|
||||||
"""A product type.
|
"""A product type.
|
||||||
|
22
counter/templates/counter/mails/account_dump.jinja
Normal file
22
counter/templates/counter/mails/account_dump.jinja
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{% trans %}Hello{% endtrans %},
|
||||||
|
|
||||||
|
{% trans trimmed amount=user.customer.amount, date=user.warning_date|date(DATETIME_FORMAT) -%}
|
||||||
|
Following the email we sent you on {{ date }},
|
||||||
|
the money of your AE account ({{ amount }} €) has been recovered by the AE.
|
||||||
|
{%- endtrans %}
|
||||||
|
|
||||||
|
{% trans trimmed -%}
|
||||||
|
If you think this was a mistake, please mail us at ae@utbm.fr.
|
||||||
|
{%- endtrans %}
|
||||||
|
|
||||||
|
{% trans trimmed -%}
|
||||||
|
Please mind that this is not a closure of your account.
|
||||||
|
You can still access it via the AE website.
|
||||||
|
You are also still able to renew your subscription.
|
||||||
|
{%- endtrans %}
|
||||||
|
|
||||||
|
{% trans %}Sincerely{% endtrans %},
|
||||||
|
|
||||||
|
L'association des étudiants de l'UTBM
|
||||||
|
6, Boulevard Anatole France
|
||||||
|
90000 Belfort
|
@ -1,5 +1,8 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import freezegun
|
||||||
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
@ -9,25 +12,29 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from core.baker_recipes import subscriber_user, very_old_subscriber_user
|
from core.baker_recipes import subscriber_user, very_old_subscriber_user
|
||||||
from counter.management.commands.dump_warning_mail import Command
|
from counter.management.commands.dump_accounts import Command as DumpCommand
|
||||||
from counter.models import AccountDump, Customer, Refilling
|
from counter.management.commands.dump_warning_mail import Command as WarningCommand
|
||||||
|
from counter.models import AccountDump, Customer, Refilling, Selling
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
class TestAccountDumpWarningMailCommand(TestCase):
|
class TestAccountDump(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def set_up_notified_users(cls):
|
||||||
# delete existing customers to avoid side effect
|
"""Create the users which should be considered as dumpable"""
|
||||||
Customer.objects.all().delete()
|
|
||||||
refill_recipe = Recipe(Refilling, amount=10)
|
|
||||||
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
|
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
|
||||||
inactive_date = (
|
baker.make(
|
||||||
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
|
Refilling,
|
||||||
)
|
amount=10,
|
||||||
refill_recipe.make(
|
|
||||||
customer=(u.customer for u in cls.notified_users),
|
customer=(u.customer for u in cls.notified_users),
|
||||||
date=inactive_date,
|
date=now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1),
|
||||||
_quantity=len(cls.notified_users),
|
_quantity=len(cls.notified_users),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_up_not_notified_users(cls):
|
||||||
|
"""Create the users which should not be considered as dumpable"""
|
||||||
|
refill_recipe = Recipe(Refilling, amount=10)
|
||||||
cls.not_notified_users = [
|
cls.not_notified_users = [
|
||||||
subscriber_user.make(),
|
subscriber_user.make(),
|
||||||
very_old_subscriber_user.make(), # inactive, but account already empty
|
very_old_subscriber_user.make(), # inactive, but account already empty
|
||||||
@ -38,7 +45,8 @@ class TestAccountDumpWarningMailCommand(TestCase):
|
|||||||
customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1)
|
customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1)
|
||||||
)
|
)
|
||||||
refill_recipe.make(
|
refill_recipe.make(
|
||||||
customer=cls.not_notified_users[3].customer, date=inactive_date
|
customer=cls.not_notified_users[3].customer,
|
||||||
|
date=now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1),
|
||||||
)
|
)
|
||||||
baker.make(
|
baker.make(
|
||||||
AccountDump,
|
AccountDump,
|
||||||
@ -46,10 +54,19 @@ class TestAccountDumpWarningMailCommand(TestCase):
|
|||||||
dump_operation=None,
|
dump_operation=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountDumpWarningMailCommand(TestAccountDump):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# delete existing accounts to avoid side effect
|
||||||
|
Customer.objects.all().delete()
|
||||||
|
cls.set_up_notified_users()
|
||||||
|
cls.set_up_not_notified_users()
|
||||||
|
|
||||||
def test_user_selection(self):
|
def test_user_selection(self):
|
||||||
"""Test that the user to warn are well selected."""
|
"""Test that the user to warn are well selected."""
|
||||||
users = list(Command._get_users())
|
users = list(WarningCommand._get_users())
|
||||||
assert len(users) == 3
|
assert len(users) == len(self.notified_users)
|
||||||
assert set(users) == set(self.notified_users)
|
assert set(users) == set(self.notified_users)
|
||||||
|
|
||||||
def test_command(self):
|
def test_command(self):
|
||||||
@ -63,3 +80,89 @@ class TestAccountDumpWarningMailCommand(TestCase):
|
|||||||
for sent in sent_mails:
|
for sent in sent_mails:
|
||||||
assert len(sent.to) == 1
|
assert len(sent.to) == 1
|
||||||
assert sent.to[0] in target_emails
|
assert sent.to[0] in target_emails
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountDumpCommand(TestAccountDump):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
with freezegun.freeze_time(
|
||||||
|
now() - settings.SITH_ACCOUNT_DUMP_DELTA - timedelta(hours=1)
|
||||||
|
):
|
||||||
|
# pretend the notifications happened enough time ago
|
||||||
|
# to make sure the accounts are dumpable right now
|
||||||
|
cls.set_up_notified_users()
|
||||||
|
AccountDump.objects.bulk_create(
|
||||||
|
[
|
||||||
|
AccountDump(customer=u.customer, warning_mail_sent_at=now())
|
||||||
|
for u in cls.notified_users
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# One of the users reactivated its account
|
||||||
|
baker.make(
|
||||||
|
Subscription,
|
||||||
|
member=cls.notified_users[0],
|
||||||
|
subscription_start=now() - timedelta(days=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_accounts_dumped(self, accounts: Iterable[Customer]):
|
||||||
|
"""Assert that the given accounts have been dumped"""
|
||||||
|
assert not (
|
||||||
|
AccountDump.objects.ongoing().filter(customer__in=accounts).exists()
|
||||||
|
)
|
||||||
|
for customer in accounts:
|
||||||
|
initial_amount = customer.amount
|
||||||
|
customer.refresh_from_db()
|
||||||
|
assert customer.amount == 0
|
||||||
|
operation: Selling = customer.buyings.order_by("date").last()
|
||||||
|
assert operation.unit_price == initial_amount
|
||||||
|
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
|
||||||
|
assert operation.is_validated is True
|
||||||
|
dump = customer.dumps.last()
|
||||||
|
assert dump.dump_operation == operation
|
||||||
|
|
||||||
|
def test_user_selection(self):
|
||||||
|
"""Test that users to dump are well selected"""
|
||||||
|
# even reactivated users should be selected,
|
||||||
|
# because their pending AccountDump must be dealt with
|
||||||
|
users = list(DumpCommand._get_users())
|
||||||
|
assert len(users) == len(self.notified_users)
|
||||||
|
assert set(users) == set(self.notified_users)
|
||||||
|
|
||||||
|
def test_dump_accounts(self):
|
||||||
|
"""Test the _dump_accounts method"""
|
||||||
|
# the first user reactivated its account, thus should not be dumped
|
||||||
|
to_dump: set[Customer] = {u.customer for u in self.notified_users[1:]}
|
||||||
|
DumpCommand._dump_accounts(to_dump)
|
||||||
|
self.assert_accounts_dumped(to_dump)
|
||||||
|
|
||||||
|
def test_dump_account_with_active_users(self):
|
||||||
|
"""Test that the dump account method failed if given active users."""
|
||||||
|
active_user = subscriber_user.make()
|
||||||
|
active_user.customer.amount = 10
|
||||||
|
active_user.customer.save()
|
||||||
|
customers = {u.customer for u in self.notified_users}
|
||||||
|
customers.add(active_user.customer)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
DumpCommand._dump_accounts(customers)
|
||||||
|
for customer in customers:
|
||||||
|
# all users should have kept their money
|
||||||
|
initial_amount = customer.amount
|
||||||
|
customer.refresh_from_db()
|
||||||
|
assert customer.amount == initial_amount
|
||||||
|
|
||||||
|
def test_command(self):
|
||||||
|
"""test the actual command"""
|
||||||
|
call_command("dump_accounts")
|
||||||
|
reactivated_user = self.notified_users[0]
|
||||||
|
# the pending operation should be deleted for reactivated users
|
||||||
|
assert not reactivated_user.customer.dumps.exists()
|
||||||
|
assert reactivated_user.customer.amount == 10
|
||||||
|
|
||||||
|
dumped_users = self.notified_users[1:]
|
||||||
|
self.assert_accounts_dumped([u.customer for u in dumped_users])
|
||||||
|
sent_mails = list(mail.outbox)
|
||||||
|
assert len(sent_mails) == 2
|
||||||
|
target_emails = {u.email for u in dumped_users}
|
||||||
|
for sent in sent_mails:
|
||||||
|
assert len(sent.to) == 1
|
||||||
|
assert sent.to[0] in target_emails
|
||||||
|
@ -12,15 +12,13 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import string
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import Client, TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@ -31,7 +29,6 @@ from club.models import Club, Membership
|
|||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
BillingInfo,
|
|
||||||
Counter,
|
Counter,
|
||||||
Customer,
|
Customer,
|
||||||
Permanency,
|
Permanency,
|
||||||
@ -46,6 +43,7 @@ class TestCounter(TestCase):
|
|||||||
cls.skia = User.objects.filter(username="skia").first()
|
cls.skia = User.objects.filter(username="skia").first()
|
||||||
cls.sli = User.objects.filter(username="sli").first()
|
cls.sli = User.objects.filter(username="sli").first()
|
||||||
cls.krophil = User.objects.filter(username="krophil").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.mde = Counter.objects.filter(name="MDE").first()
|
||||||
cls.foyer = Counter.objects.get(id=2)
|
cls.foyer = Counter.objects.get(id=2)
|
||||||
|
|
||||||
@ -66,7 +64,7 @@ class TestCounter(TestCase):
|
|||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("counter:details", kwargs={"counter_id": self.mde.id}),
|
reverse("counter:details", kwargs={"counter_id": self.mde.id}),
|
||||||
{"code": "4000k", "counter_token": counter_token},
|
{"code": self.richard.customer.account_id, "counter_token": counter_token},
|
||||||
)
|
)
|
||||||
counter_url = response.get("location")
|
counter_url = response.get("location")
|
||||||
response = self.client.get(response.get("location"))
|
response = self.client.get(response.get("location"))
|
||||||
@ -137,7 +135,7 @@ class TestCounter(TestCase):
|
|||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("counter:details", kwargs={"counter_id": self.foyer.id}),
|
reverse("counter:details", kwargs={"counter_id": self.foyer.id}),
|
||||||
{"code": "4000k", "counter_token": counter_token},
|
{"code": self.richard.customer.account_id, "counter_token": counter_token},
|
||||||
)
|
)
|
||||||
counter_url = response.get("location")
|
counter_url = response.get("location")
|
||||||
|
|
||||||
@ -313,149 +311,6 @@ class TestCounterStats(TestCase):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestBillingInfo:
|
|
||||||
@pytest.fixture
|
|
||||||
def payload(self):
|
|
||||||
return {
|
|
||||||
"first_name": "Subscribed",
|
|
||||||
"last_name": "User",
|
|
||||||
"address_1": "3, rue de Troyes",
|
|
||||||
"zip_code": "34301",
|
|
||||||
"city": "Sète",
|
|
||||||
"country": "FR",
|
|
||||||
"phone_number": "0612345678",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_edit_infos(self, client: Client, payload: dict):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
baker.make(BillingInfo, customer=user.customer)
|
|
||||||
client.force_login(user)
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
user.refresh_from_db()
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert hasattr(user.customer, "billing_infos")
|
|
||||||
assert infos.customer == user.customer
|
|
||||||
for key, val in payload.items():
|
|
||||||
assert getattr(infos, key) == val
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
|
|
||||||
)
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_create_infos(self, client: Client, user_maker, payload):
|
|
||||||
user = user_maker()
|
|
||||||
client.force_login(user)
|
|
||||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert hasattr(user, "customer")
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
assert hasattr(user.customer, "billing_infos")
|
|
||||||
assert infos.customer == user.customer
|
|
||||||
for key, val in payload.items():
|
|
||||||
assert getattr(infos, key) == val
|
|
||||||
|
|
||||||
def test_invalid_data(self, client: Client, payload: dict[str, str]):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(user)
|
|
||||||
# address_1, zip_code and country are missing
|
|
||||||
del payload["city"]
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
user.customer.refresh_from_db()
|
|
||||||
assert not hasattr(user.customer, "billing_infos")
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("operator_maker", "expected_code"),
|
|
||||||
[
|
|
||||||
(subscriber_user.make, 403),
|
|
||||||
(lambda: baker.make(User), 403),
|
|
||||||
(lambda: baker.make(User, is_superuser=True), 200),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_edit_other_user(
|
|
||||||
self, client: Client, operator_maker, expected_code: int, payload: dict
|
|
||||||
):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(operator_maker())
|
|
||||||
baker.make(BillingInfo, customer=user.customer)
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == expected_code
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"phone_number",
|
|
||||||
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
|
|
||||||
)
|
|
||||||
def test_phone_number_format(
|
|
||||||
self, client: Client, payload: dict, phone_number: str
|
|
||||||
):
|
|
||||||
"""Test that various formats of phone numbers are accepted."""
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(user)
|
|
||||||
payload["phone_number"] = phone_number
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
assert infos.phone_number == "0612345678"
|
|
||||||
assert infos.phone_number.country_code == 33
|
|
||||||
|
|
||||||
def test_foreign_phone_number(self, client: Client, payload: dict):
|
|
||||||
"""Test that a foreign phone number is accepted."""
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(user)
|
|
||||||
payload["phone_number"] = "+49612345678"
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
infos = BillingInfo.objects.get(customer__user=user)
|
|
||||||
assert infos.phone_number.as_national == "06123 45678"
|
|
||||||
assert infos.phone_number.country_code == 49
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
|
|
||||||
)
|
|
||||||
def test_invalid_phone_number(
|
|
||||||
self, client: Client, payload: dict, phone_number: str
|
|
||||||
):
|
|
||||||
"""Test that invalid phone numbers are rejected."""
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(user)
|
|
||||||
payload["phone_number"] = phone_number
|
|
||||||
response = client.put(
|
|
||||||
reverse("api:put_billing_info", args=[user.id]),
|
|
||||||
json.dumps(payload),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBarmanConnection(TestCase):
|
class TestBarmanConnection(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -529,341 +384,6 @@ def test_barman_timeout():
|
|||||||
assert bar.barmen_list == []
|
assert bar.barmen_list == []
|
||||||
|
|
||||||
|
|
||||||
class TestStudentCard(TestCase):
|
|
||||||
"""Tests for adding and deleting Stundent Cards
|
|
||||||
Test that an user can be found with it's student card.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.krophil = User.objects.get(username="krophil")
|
|
||||||
cls.sli = User.objects.get(username="sli")
|
|
||||||
cls.skia = User.objects.get(username="skia")
|
|
||||||
cls.root = User.objects.get(username="root")
|
|
||||||
|
|
||||||
cls.counter = Counter.objects.get(id=2)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
# Auto login on counter
|
|
||||||
self.client.post(
|
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
|
||||||
{"username": "krophil", "password": "plop"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_search_user_with_student_card(self):
|
|
||||||
response = self.client.post(
|
|
||||||
reverse("counter:details", args=[self.counter.id]),
|
|
||||||
{"code": "9A89B82018B0A0"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.url == reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_add_student_card_from_counter(self):
|
|
||||||
# Test card with mixed letters and numbers
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "8B90734A802A8F", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="8B90734A802A8F")
|
|
||||||
|
|
||||||
# Test card with only numbers
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "04786547890123", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="04786547890123")
|
|
||||||
|
|
||||||
# Test card with only letters
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "ABCAAAFAAFAAAB", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="ABCAAAFAAFAAAB")
|
|
||||||
|
|
||||||
def test_add_student_card_from_counter_fail(self):
|
|
||||||
# UID too short
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "8B90734A802A8", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
|
||||||
)
|
|
||||||
|
|
||||||
# UID too long
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "8B90734A802A8FA", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test with already existing card
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "9A89B82018B0A0", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test with lowercase
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": "8b90734a802a9f", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test with white spaces
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:click",
|
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
|
||||||
),
|
|
||||||
{"student_card_uid": " ", "action": "add_student_card"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_delete_student_card_with_owner(self):
|
|
||||||
self.client.force_login(self.sli)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:delete_student_card",
|
|
||||||
kwargs={
|
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert not self.sli.customer.student_cards.exists()
|
|
||||||
|
|
||||||
def test_delete_student_card_with_board_member(self):
|
|
||||||
self.client.force_login(self.skia)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:delete_student_card",
|
|
||||||
kwargs={
|
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert not self.sli.customer.student_cards.exists()
|
|
||||||
|
|
||||||
def test_delete_student_card_with_root(self):
|
|
||||||
self.client.force_login(self.root)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:delete_student_card",
|
|
||||||
kwargs={
|
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert not self.sli.customer.student_cards.exists()
|
|
||||||
|
|
||||||
def test_delete_student_card_fail(self):
|
|
||||||
self.client.force_login(self.krophil)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:delete_student_card",
|
|
||||||
kwargs={
|
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert self.sli.customer.student_cards.exists()
|
|
||||||
|
|
||||||
def test_add_student_card_from_user_preferences(self):
|
|
||||||
# Test with owner of the card
|
|
||||||
self.client.force_login(self.sli)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8F"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="8B90734A802A8F")
|
|
||||||
|
|
||||||
# Test with board member
|
|
||||||
self.client.force_login(self.skia)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8A"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="8B90734A802A8A")
|
|
||||||
|
|
||||||
# Test card with only numbers
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "04786547890123"},
|
|
||||||
)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="04786547890123")
|
|
||||||
|
|
||||||
# Test card with only letters
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "ABCAAAFAAFAAAB"},
|
|
||||||
)
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="ABCAAAFAAFAAAB")
|
|
||||||
|
|
||||||
# Test with root
|
|
||||||
self.client.force_login(self.root)
|
|
||||||
self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8B"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="8B90734A802A8B")
|
|
||||||
|
|
||||||
def test_add_student_card_from_user_preferences_fail(self):
|
|
||||||
self.client.force_login(self.sli)
|
|
||||||
# UID too short
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertContains(response, text="Cet UID est invalide")
|
|
||||||
|
|
||||||
# UID too long
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8FA"},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="Cet UID est invalide")
|
|
||||||
|
|
||||||
# Test with already existing card
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "9A89B82018B0A0"},
|
|
||||||
)
|
|
||||||
self.assertContains(
|
|
||||||
response, text="Un objet Student card avec ce champ Uid existe déjà."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test with lowercase
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8b90734a802a9f"},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="Cet UID est invalide")
|
|
||||||
|
|
||||||
# Test with white spaces
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": " " * 14},
|
|
||||||
)
|
|
||||||
self.assertContains(response, text="Cet UID est invalide")
|
|
||||||
|
|
||||||
# Test with unauthorized user
|
|
||||||
self.client.force_login(self.krophil)
|
|
||||||
response = self.client.post(
|
|
||||||
reverse(
|
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
|
||||||
),
|
|
||||||
{"uid": "8B90734A802A8F"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
class TestCustomerAccountId(TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpTestData(cls):
|
|
||||||
cls.user_a = User.objects.create(
|
|
||||||
username="a", password="plop", email="a.a@a.fr"
|
|
||||||
)
|
|
||||||
user_b = User.objects.create(username="b", password="plop", email="b.b@b.fr")
|
|
||||||
user_c = User.objects.create(username="c", password="plop", email="c.c@c.fr")
|
|
||||||
Customer.objects.create(user=cls.user_a, amount=10, account_id="1111a")
|
|
||||||
Customer.objects.create(user=user_b, amount=0, account_id="9999z")
|
|
||||||
Customer.objects.create(user=user_c, amount=0, account_id="12345f")
|
|
||||||
|
|
||||||
def test_create_customer(self):
|
|
||||||
user_d = User.objects.create(username="d", password="plop")
|
|
||||||
customer, created = Customer.get_or_create(user_d)
|
|
||||||
account_id = customer.account_id
|
|
||||||
number = account_id[:-1]
|
|
||||||
assert created is True
|
|
||||||
assert number == "12346"
|
|
||||||
assert len(account_id) == 6
|
|
||||||
assert account_id[-1] in string.ascii_lowercase
|
|
||||||
assert customer.amount == 0
|
|
||||||
|
|
||||||
def test_get_existing_account(self):
|
|
||||||
account, created = Customer.get_or_create(self.user_a)
|
|
||||||
assert created is False
|
|
||||||
assert account.account_id == "1111a"
|
|
||||||
assert account.amount == 10
|
|
||||||
|
|
||||||
|
|
||||||
class TestClubCounterClickAccess(TestCase):
|
class TestClubCounterClickAccess(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
535
counter/tests/test_customer.py
Normal file
535
counter/tests/test_customer.py
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
import json
|
||||||
|
import string
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import User
|
||||||
|
from counter.baker_recipes import refill_recipe, sale_recipe
|
||||||
|
from counter.models import BillingInfo, Counter, Customer, Refilling, Selling
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestBillingInfo:
|
||||||
|
@pytest.fixture
|
||||||
|
def payload(self):
|
||||||
|
return {
|
||||||
|
"first_name": "Subscribed",
|
||||||
|
"last_name": "User",
|
||||||
|
"address_1": "3, rue de Troyes",
|
||||||
|
"zip_code": "34301",
|
||||||
|
"city": "Sète",
|
||||||
|
"country": "FR",
|
||||||
|
"phone_number": "0612345678",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_edit_infos(self, client: Client, payload: dict):
|
||||||
|
user = subscriber_user.make()
|
||||||
|
baker.make(BillingInfo, customer=user.customer)
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
user.refresh_from_db()
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert hasattr(user.customer, "billing_infos")
|
||||||
|
assert infos.customer == user.customer
|
||||||
|
for key, val in payload.items():
|
||||||
|
assert getattr(infos, key) == val
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_maker", [subscriber_user.make, lambda: baker.make(User)]
|
||||||
|
)
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_infos(self, client: Client, user_maker, payload):
|
||||||
|
user = user_maker()
|
||||||
|
client.force_login(user)
|
||||||
|
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert hasattr(user, "customer")
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
assert hasattr(user.customer, "billing_infos")
|
||||||
|
assert infos.customer == user.customer
|
||||||
|
for key, val in payload.items():
|
||||||
|
assert getattr(infos, key) == val
|
||||||
|
|
||||||
|
def test_invalid_data(self, client: Client, payload: dict[str, str]):
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(user)
|
||||||
|
# address_1, zip_code and country are missing
|
||||||
|
del payload["city"]
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
user.customer.refresh_from_db()
|
||||||
|
assert not hasattr(user.customer, "billing_infos")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("operator_maker", "expected_code"),
|
||||||
|
[
|
||||||
|
(subscriber_user.make, 403),
|
||||||
|
(lambda: baker.make(User), 403),
|
||||||
|
(lambda: baker.make(User, is_superuser=True), 200),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_edit_other_user(
|
||||||
|
self, client: Client, operator_maker, expected_code: int, payload: dict
|
||||||
|
):
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(operator_maker())
|
||||||
|
baker.make(BillingInfo, customer=user.customer)
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == expected_code
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"phone_number",
|
||||||
|
["+33612345678", "0612345678", "06 12 34 56 78", "06-12-34-56-78"],
|
||||||
|
)
|
||||||
|
def test_phone_number_format(
|
||||||
|
self, client: Client, payload: dict, phone_number: str
|
||||||
|
):
|
||||||
|
"""Test that various formats of phone numbers are accepted."""
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(user)
|
||||||
|
payload["phone_number"] = phone_number
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
assert infos.phone_number == "0612345678"
|
||||||
|
assert infos.phone_number.country_code == 33
|
||||||
|
|
||||||
|
def test_foreign_phone_number(self, client: Client, payload: dict):
|
||||||
|
"""Test that a foreign phone number is accepted."""
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(user)
|
||||||
|
payload["phone_number"] = "+49612345678"
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
infos = BillingInfo.objects.get(customer__user=user)
|
||||||
|
assert infos.phone_number.as_national == "06123 45678"
|
||||||
|
assert infos.phone_number.country_code == 49
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"phone_number", ["061234567a", "06 12 34 56", "061234567879", "azertyuiop"]
|
||||||
|
)
|
||||||
|
def test_invalid_phone_number(
|
||||||
|
self, client: Client, payload: dict, phone_number: str
|
||||||
|
):
|
||||||
|
"""Test that invalid phone numbers are rejected."""
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(user)
|
||||||
|
payload["phone_number"] = phone_number
|
||||||
|
response = client.put(
|
||||||
|
reverse("api:put_billing_info", args=[user.id]),
|
||||||
|
json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert not BillingInfo.objects.filter(customer__user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStudentCard(TestCase):
|
||||||
|
"""Tests for adding and deleting Stundent Cards
|
||||||
|
Test that an user can be found with it's student card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.krophil = User.objects.get(username="krophil")
|
||||||
|
cls.sli = User.objects.get(username="sli")
|
||||||
|
cls.skia = User.objects.get(username="skia")
|
||||||
|
cls.root = User.objects.get(username="root")
|
||||||
|
|
||||||
|
cls.counter = Counter.objects.get(id=2)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Auto login on counter
|
||||||
|
self.client.post(
|
||||||
|
reverse("counter:login", args=[self.counter.id]),
|
||||||
|
{"username": "krophil", "password": "plop"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_user_with_student_card(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("counter:details", args=[self.counter.id]),
|
||||||
|
{"code": "9A89B82018B0A0"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.url == reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_student_card_from_counter(self):
|
||||||
|
# Test card with mixed letters and numbers
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "8B90734A802A8F", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="8B90734A802A8F")
|
||||||
|
|
||||||
|
# Test card with only numbers
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "04786547890123", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="04786547890123")
|
||||||
|
|
||||||
|
# Test card with only letters
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "ABCAAAFAAFAAAB", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="ABCAAAFAAFAAAB")
|
||||||
|
|
||||||
|
def test_add_student_card_from_counter_fail(self):
|
||||||
|
# UID too short
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "8B90734A802A8", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Ce n'est pas un UID de carte étudiante valide"
|
||||||
|
)
|
||||||
|
|
||||||
|
# UID too long
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "8B90734A802A8FA", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Ce n'est pas un UID de carte étudiante valide"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with already existing card
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "9A89B82018B0A0", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Ce n'est pas un UID de carte étudiante valide"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with lowercase
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": "8b90734a802a9f", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Ce n'est pas un UID de carte étudiante valide"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with white spaces
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:click",
|
||||||
|
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
||||||
|
),
|
||||||
|
{"student_card_uid": " ", "action": "add_student_card"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Ce n'est pas un UID de carte étudiante valide"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_student_card_with_owner(self):
|
||||||
|
self.client.force_login(self.sli)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:delete_student_card",
|
||||||
|
kwargs={
|
||||||
|
"customer_id": self.sli.customer.pk,
|
||||||
|
"card_id": self.sli.customer.student_cards.first().id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert not self.sli.customer.student_cards.exists()
|
||||||
|
|
||||||
|
def test_delete_student_card_with_board_member(self):
|
||||||
|
self.client.force_login(self.skia)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:delete_student_card",
|
||||||
|
kwargs={
|
||||||
|
"customer_id": self.sli.customer.pk,
|
||||||
|
"card_id": self.sli.customer.student_cards.first().id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert not self.sli.customer.student_cards.exists()
|
||||||
|
|
||||||
|
def test_delete_student_card_with_root(self):
|
||||||
|
self.client.force_login(self.root)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:delete_student_card",
|
||||||
|
kwargs={
|
||||||
|
"customer_id": self.sli.customer.pk,
|
||||||
|
"card_id": self.sli.customer.student_cards.first().id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert not self.sli.customer.student_cards.exists()
|
||||||
|
|
||||||
|
def test_delete_student_card_fail(self):
|
||||||
|
self.client.force_login(self.krophil)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:delete_student_card",
|
||||||
|
kwargs={
|
||||||
|
"customer_id": self.sli.customer.pk,
|
||||||
|
"card_id": self.sli.customer.student_cards.first().id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert self.sli.customer.student_cards.exists()
|
||||||
|
|
||||||
|
def test_add_student_card_from_user_preferences(self):
|
||||||
|
# Test with owner of the card
|
||||||
|
self.client.force_login(self.sli)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8F"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="8B90734A802A8F")
|
||||||
|
|
||||||
|
# Test with board member
|
||||||
|
self.client.force_login(self.skia)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8A"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="8B90734A802A8A")
|
||||||
|
|
||||||
|
# Test card with only numbers
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "04786547890123"},
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="04786547890123")
|
||||||
|
|
||||||
|
# Test card with only letters
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "ABCAAAFAAFAAAB"},
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="ABCAAAFAAFAAAB")
|
||||||
|
|
||||||
|
# Test with root
|
||||||
|
self.client.force_login(self.root)
|
||||||
|
self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8B"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="8B90734A802A8B")
|
||||||
|
|
||||||
|
def test_add_student_card_from_user_preferences_fail(self):
|
||||||
|
self.client.force_login(self.sli)
|
||||||
|
# UID too short
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
|
||||||
|
# UID too long
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8FA"},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
|
||||||
|
# Test with already existing card
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "9A89B82018B0A0"},
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
response, text="Un objet Student card avec ce champ Uid existe déjà."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test with lowercase
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8b90734a802a9f"},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
|
||||||
|
# Test with white spaces
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": " " * 14},
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
|
||||||
|
# Test with unauthorized user
|
||||||
|
self.client.force_login(self.krophil)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
||||||
|
),
|
||||||
|
{"uid": "8B90734A802A8F"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomerAccountId(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.user_a = User.objects.create(
|
||||||
|
username="a", password="plop", email="a.a@a.fr"
|
||||||
|
)
|
||||||
|
user_b = User.objects.create(username="b", password="plop", email="b.b@b.fr")
|
||||||
|
user_c = User.objects.create(username="c", password="plop", email="c.c@c.fr")
|
||||||
|
Customer.objects.create(user=cls.user_a, amount=10, account_id="1111a")
|
||||||
|
Customer.objects.create(user=user_b, amount=0, account_id="9999z")
|
||||||
|
Customer.objects.create(user=user_c, amount=0, account_id="12345f")
|
||||||
|
|
||||||
|
def test_create_customer(self):
|
||||||
|
user_d = User.objects.create(username="d", password="plop")
|
||||||
|
customer, created = Customer.get_or_create(user_d)
|
||||||
|
account_id = customer.account_id
|
||||||
|
number = account_id[:-1]
|
||||||
|
assert created is True
|
||||||
|
assert number == "12346"
|
||||||
|
assert len(account_id) == 6
|
||||||
|
assert account_id[-1] in string.ascii_lowercase
|
||||||
|
assert customer.amount == 0
|
||||||
|
|
||||||
|
def test_get_existing_account(self):
|
||||||
|
account, created = Customer.get_or_create(self.user_a)
|
||||||
|
assert created is False
|
||||||
|
assert account.account_id == "1111a"
|
||||||
|
assert account.amount == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_update_balance():
|
||||||
|
customers = baker.make(Customer, _quantity=5, _bulk_create=True)
|
||||||
|
refills = [
|
||||||
|
*refill_recipe.prepare(
|
||||||
|
customer=iter(customers),
|
||||||
|
amount=iter([30, 30, 40, 50, 50]),
|
||||||
|
_quantity=len(customers),
|
||||||
|
_save_related=True,
|
||||||
|
),
|
||||||
|
refill_recipe.prepare(customer=customers[0], amount=30, _save_related=True),
|
||||||
|
refill_recipe.prepare(customer=customers[4], amount=10, _save_related=True),
|
||||||
|
]
|
||||||
|
Refilling.objects.bulk_create(refills)
|
||||||
|
sales = [
|
||||||
|
*sale_recipe.prepare(
|
||||||
|
customer=iter(customers),
|
||||||
|
_quantity=len(customers),
|
||||||
|
unit_price=10,
|
||||||
|
quantity=1,
|
||||||
|
_save_related=True,
|
||||||
|
),
|
||||||
|
*sale_recipe.prepare(
|
||||||
|
customer=iter(customers[:3]),
|
||||||
|
_quantity=3,
|
||||||
|
unit_price=5,
|
||||||
|
quantity=2,
|
||||||
|
_save_related=True,
|
||||||
|
),
|
||||||
|
sale_recipe.prepare(
|
||||||
|
customer=customers[4], quantity=1, unit_price=50, _save_related=True
|
||||||
|
),
|
||||||
|
]
|
||||||
|
Selling.objects.bulk_create(sales)
|
||||||
|
# customer 0 = 40, customer 1 = 10€, customer 2 = 20€,
|
||||||
|
# customer 3 = 40€, customer 4 = 0€
|
||||||
|
customers_qs = Customer.objects.filter(pk__in={c.pk for c in customers})
|
||||||
|
# put everything at zero to be sure the amounts were wrong beforehand
|
||||||
|
customers_qs.update(amount=0)
|
||||||
|
customers_qs.update_amount()
|
||||||
|
for customer, amount in zip(customers, [40, 10, 20, 40, 0]):
|
||||||
|
customer.refresh_from_db()
|
||||||
|
assert customer.amount == amount
|
@ -4,7 +4,7 @@ from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMult
|
|||||||
from counter.models import Counter, Product
|
from counter.models import Counter, Product
|
||||||
from counter.schemas import ProductSchema, SimplifiedCounterSchema
|
from counter.schemas import ProductSchema, SimplifiedCounterSchema
|
||||||
|
|
||||||
_js = ["webpack/counter/components/ajax-select-index.ts"]
|
_js = ["bundled/counter/components/ajax-select-index.ts"]
|
||||||
|
|
||||||
|
|
||||||
class AutoCompleteSelectCounter(AutoCompleteSelect):
|
class AutoCompleteSelectCounter(AutoCompleteSelect):
|
||||||
|
@ -200,6 +200,19 @@ Grâce à son architecture, il est extrêmement
|
|||||||
bien adapté pour un usage dans un site multipage.
|
bien adapté pour un usage dans un site multipage.
|
||||||
C'est une technologie simple et puissante qui se veut comme le jQuery du web moderne.
|
C'est une technologie simple et puissante qui se veut comme le jQuery du web moderne.
|
||||||
|
|
||||||
|
### Htmx
|
||||||
|
|
||||||
|
[Site officiel](https://htmx.org/)
|
||||||
|
|
||||||
|
En plus de AlpineJS, l’interactivité sur le site est augmentée via Htmx.
|
||||||
|
C'est une librairie js qui s'utilise également au moyen d'attributs HTML à
|
||||||
|
ajouter directement dans les templates.
|
||||||
|
|
||||||
|
Son principe est de remplacer certains éléments du html par un fragment de
|
||||||
|
HTML renvoyé par le serveur backend. Cela se marie très bien avec le
|
||||||
|
fonctionnement de django et en particulier de ses formulaires afin d'éviter
|
||||||
|
de doubler le travail pour la vérification des données.
|
||||||
|
|
||||||
### Sass
|
### Sass
|
||||||
|
|
||||||
[Site officiel](https://sass-lang.com/)
|
[Site officiel](https://sass-lang.com/)
|
||||||
@ -388,24 +401,16 @@ Npm possède, tout comme Poetry, la capacité de locker les dépendances au moye
|
|||||||
|
|
||||||
Nous l'utilisons ici pour gérer les dépendances JavaScript. Celle-ci sont déclarées dans le fichier `package.json` situé à la racine du projet.
|
Nous l'utilisons ici pour gérer les dépendances JavaScript. Celle-ci sont déclarées dans le fichier `package.json` situé à la racine du projet.
|
||||||
|
|
||||||
### Webpack
|
### Vite
|
||||||
|
|
||||||
[Utiliser webpack](https://webpack.js.org/concepts/)
|
[Utiliser vite](https://vite.dev)
|
||||||
|
|
||||||
Webpack est un bundler de fichiers static. Il nous sert ici à mettre à disposition les dépendances frontend gérées par npm.
|
Vite est un bundler de fichiers static. Il nous sert ici à mettre à disposition les dépendances frontend gérées par npm.
|
||||||
|
|
||||||
Il sert également à intégrer les autres outils JavaScript au workflow du Sith de manière transparente.
|
Il sert également à intégrer les autres outils JavaScript au workflow du Sith de manière transparente.
|
||||||
|
|
||||||
Webpack a été choisi pour sa versatilité et sa popularité. C'est un des plus anciens bundler et il est là pour rester.
|
Vite a été choisi pour sa versatilité et sa popularité. Il est moderne et très rapide avec un fort soutien de la communauté.
|
||||||
|
|
||||||
Le logiciel se configure au moyen du fichier `webpack.config.js` à la racine du projet.
|
Il intègre aussi tout le nécessaire pour la rétro-compatibilité et le Typescript.
|
||||||
|
|
||||||
### Babel
|
Le logiciel se configure au moyen du fichier `vite.config.mts` à la racine du projet.
|
||||||
|
|
||||||
[Babel](https://babeljs.io/)
|
|
||||||
|
|
||||||
Babel est un outil qui offre la promesse de convertir le code JavaScript moderne en code JavaScript plus ancien sans action de la part du développeur. Il permet de ne pas se soucier de la compatibilité avec les navigateurs et de coder comme si on était toujours sur la dernière version du langage.
|
|
||||||
|
|
||||||
Babel est intégré dans Webpack et tout code bundlé par celui-ci est automatiquement converti.
|
|
||||||
|
|
||||||
Le logiciel se configure au moyen du fichier `babel.config.json` à la racine du projet.
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
Vous avez ajouté une application et vous voulez y mettre du javascript ?
|
Vous avez ajouté une application et vous voulez y mettre du javascript ?
|
||||||
|
|
||||||
Vous voulez importer depuis cette nouvelle application dans votre script géré par webpack ?
|
Vous voulez importer depuis cette nouvelle application dans votre script géré par le bundler ?
|
||||||
|
|
||||||
Eh bien il faut manuellement enregistrer dans node où les trouver et c'est très simple.
|
Eh bien il faut manuellement enregistrer dans node où les trouver et c'est très simple.
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ D'abord, il faut ajouter dans node via `package.json`:
|
|||||||
// ...
|
// ...
|
||||||
"imports": {
|
"imports": {
|
||||||
// ...
|
// ...
|
||||||
"#mon_app:*": "./mon_app/static/webpack/*"
|
"#mon_app:*": "./mon_app/static/bundled/*"
|
||||||
}
|
}
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
@ -25,7 +25,7 @@ Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.
|
|||||||
// ...
|
// ...
|
||||||
"paths": {
|
"paths": {
|
||||||
// ...
|
// ...
|
||||||
"#mon_app:*": ["./mon_app/static/webpack/*"]
|
"#mon_app:*": ["./mon_app/static/bundled/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,28 +27,33 @@ le système se débrouille automatiquement pour les transformer en `.css`
|
|||||||
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
|
||||||
```
|
```
|
||||||
|
|
||||||
## L'intégration webpack
|
## L'intégration avec le bundler javascript
|
||||||
|
|
||||||
Webpack est intégré un peu différement. Le principe est très similaire mais
|
Le bundler javascript est intégré un peu différement. Le principe est très similaire mais
|
||||||
les fichiers sont à mettre dans un dossier `static/webpack` de l'application à la place.
|
les fichiers sont à mettre dans un dossier `static/bundled` de l'application à la place.
|
||||||
|
|
||||||
Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `webpack/` comme prefix.
|
Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `bundled/` comme prefix.
|
||||||
|
|
||||||
```jinja
|
```jinja
|
||||||
{# Example pour ajouter sith/core/webpack/alpine-index.js #}
|
{# Example pour ajouter sith/core/bundled/alpine-index.js #}
|
||||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||||
<script src="{{ static('webpack/other-index.ts') }}" defer></script>
|
<script type="module" src="{{ static('bundled/other-index.ts') }}"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
!!!note
|
!!!note
|
||||||
|
|
||||||
Seuls les fichiers se terminant par `index.js` sont exportés par webpack.
|
Seuls les fichiers se terminant par `index.js` sont exportés par le bundler.
|
||||||
Les autres fichiers sont disponibles à l'import dans le JavaScript comme
|
Les autres fichiers sont disponibles à l'import dans le JavaScript comme
|
||||||
si ils étaient tous au même niveau.
|
si ils étaient tous au même niveau.
|
||||||
|
|
||||||
### Les imports au sein des fichiers de webpack
|
!!!warning
|
||||||
|
|
||||||
Pour importer au sein de webpack, il faut préfixer ses imports de `#app:`.
|
Le bundler ne génère que des modules javascript.
|
||||||
|
Ajouter `type="module"` n'est pas optionnel !
|
||||||
|
|
||||||
|
### Les imports au sein des fichiers des fichiers javascript bundlés
|
||||||
|
|
||||||
|
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.
|
||||||
|
|
||||||
Exemple:
|
Exemple:
|
||||||
|
|
||||||
|
40
docs/tutorial/fragments.md
Normal file
40
docs/tutorial/fragments.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
Pour utiliser HTMX, on a besoin de renvoyer des fragments depuis le backend.
|
||||||
|
Le truc, c'est que tout est optimisé pour utiliser `base.jinja` qui est assez gros.
|
||||||
|
|
||||||
|
Dans beaucoup de scénario, on veut pouvoir renvoyer soit la vue complète, soit
|
||||||
|
juste le fragment. En particulier quand on utilise l'attribut `hx-history` de htmx.
|
||||||
|
|
||||||
|
Pour remédier à cela, il existe le mixin [AllowFragment][core.views.AllowFragment].
|
||||||
|
|
||||||
|
Une fois ajouté à une vue Django, il ajoute le boolean `is_fragment` dans les
|
||||||
|
templates jinja. Sa valeur est `True` uniquement si HTMX envoie la requête.
|
||||||
|
Il est ensuite très simple de faire un if/else pour hériter de
|
||||||
|
`core/base_fragment.jinja` au lieu de `core/base.jinja` dans cette situation.
|
||||||
|
|
||||||
|
Exemple d'utilisation d'une vue avec fragment:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from core.views import AllowFragment
|
||||||
|
|
||||||
|
class FragmentView(AllowFragment, TemplateView):
|
||||||
|
template_name = "my_template.jinja"
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple de template (`my_template.jinja`)
|
||||||
|
```jinja
|
||||||
|
{% if is_fragment %}
|
||||||
|
{% extends "core/base_fragment.jinja" %}
|
||||||
|
{% else %}
|
||||||
|
{% extends "core/base.jinja" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans %}My view with a fragment{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>{% trans %}This will be a fragment when is_fragment is True{% endtrans %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
@ -116,7 +116,7 @@ sith/
|
|||||||
21. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
21. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||||
22. Fonctionnalités pour gérer le spam.
|
22. Fonctionnalités pour gérer le spam.
|
||||||
23. Gestion des statics du site. Override le système de statics de Django.
|
23. Gestion des statics du site. Override le système de statics de Django.
|
||||||
Ajoute l'intégration du scss et de webpack
|
Ajoute l'intégration du scss et du bundler js
|
||||||
de manière transparente pour l'utilisateur.
|
de manière transparente pour l'utilisateur.
|
||||||
24. Fichier de configuration de coverage.
|
24. Fichier de configuration de coverage.
|
||||||
25. Fichier de configuration de direnv.
|
25. Fichier de configuration de direnv.
|
||||||
@ -178,7 +178,7 @@ comme suit :
|
|||||||
├── templates/ (2)
|
├── templates/ (2)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── static/ (3)
|
├── static/ (3)
|
||||||
│ └── webpack/ (4)
|
│ └── bundled/ (4)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── api.py (5)
|
├── api.py (5)
|
||||||
├── admin.py (6)
|
├── admin.py (6)
|
||||||
@ -196,7 +196,7 @@ comme suit :
|
|||||||
cf. [Gestion des migrations](../howto/migrations.md)
|
cf. [Gestion des migrations](../howto/migrations.md)
|
||||||
2. Dossier contenant les templates jinja utilisés par cette application.
|
2. Dossier contenant les templates jinja utilisés par cette application.
|
||||||
3. Dossier contenant les fichiers statics (js, css, scss) qui sont récpérée par Django.
|
3. Dossier contenant les fichiers statics (js, css, scss) qui sont récpérée par Django.
|
||||||
4. Dossier contenant du js qui sera process avec webpack. Le contenu sera automatiquement process et accessible comme si ça avait été placé dans le dossier `static/webpack`.
|
4. Dossier contenant du js qui sera process avec le bundler javascript. Le contenu sera automatiquement process et accessible comme si ça avait été placé dans le dossier `static/bundled`.
|
||||||
5. Fichier contenant les routes d'API liées à cette application
|
5. Fichier contenant les routes d'API liées à cette application
|
||||||
6. Fichier de configuration de l'interface d'administration.
|
6. Fichier de configuration de l'interface d'administration.
|
||||||
Ce fichier permet de déclarer les modèles de l'application
|
Ce fichier permet de déclarer les modèles de l'application
|
||||||
|
@ -1,21 +1,24 @@
|
|||||||
/**
|
export {};
|
||||||
* @typedef {Object} BasketItem An item in the basket
|
|
||||||
* @property {number} id The id of the product
|
|
||||||
* @property {string} name The name of the product
|
|
||||||
* @property {number} quantity The quantity of the product
|
|
||||||
* @property {number} unit_price The unit price of the product
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BASKET_ITEMS_COOKIE_NAME = "basket_items";
|
interface BasketItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
||||||
|
unit_price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASKET_ITEMS_COOKIE_NAME: string = "basket_items";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for a cookie by name
|
* Search for a cookie by name
|
||||||
* @param {string} name Name of the cookie to get
|
* @param name Name of the cookie to get
|
||||||
* @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
|
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
||||||
*/
|
*/
|
||||||
function getCookie(name) {
|
function getCookie(name: string): string | null | undefined {
|
||||||
// biome-ignore lint/style/useBlockStatements: <explanation>
|
if (!document.cookie || document.cookie.length === 0) {
|
||||||
if (!document.cookie || document.cookie.length === 0) return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const found = document.cookie
|
const found = document.cookie
|
||||||
.split(";")
|
.split(";")
|
||||||
@ -27,9 +30,9 @@ function getCookie(name) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the basket items from the associated cookie
|
* Fetch the basket items from the associated cookie
|
||||||
* @returns {BasketItem[]|[]} the items in the basket
|
* @returns the items in the basket
|
||||||
*/
|
*/
|
||||||
function getStartingItems() {
|
function getStartingItems(): BasketItem[] {
|
||||||
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
return [];
|
return [];
|
||||||
@ -46,31 +49,34 @@ function getStartingItems() {
|
|||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("basket", () => ({
|
Alpine.data("basket", () => ({
|
||||||
items: getStartingItems(),
|
items: getStartingItems() as BasketItem[],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total price of the basket
|
* Get the total price of the basket
|
||||||
* @returns {number} The total price of the basket
|
* @returns {number} The total price of the basket
|
||||||
*/
|
*/
|
||||||
getTotal() {
|
getTotal() {
|
||||||
return this.items.reduce((acc, item) => acc + item.quantity * item.unit_price, 0);
|
return this.items.reduce(
|
||||||
|
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
|
||||||
|
0,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add 1 to the quantity of an item in the basket
|
* Add 1 to the quantity of an item in the basket
|
||||||
* @param {BasketItem} item
|
* @param {BasketItem} item
|
||||||
*/
|
*/
|
||||||
add(item) {
|
add(item: BasketItem) {
|
||||||
item.quantity++;
|
item.quantity++;
|
||||||
this.setCookies();
|
this.setCookies();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove 1 to the quantity of an item in the basket
|
* Remove 1 to the quantity of an item in the basket
|
||||||
* @param {BasketItem} item_id
|
* @param itemId the id of the item to remove
|
||||||
*/
|
*/
|
||||||
remove(itemId) {
|
remove(itemId: number) {
|
||||||
const index = this.items.findIndex((e) => e.id === itemId);
|
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
||||||
|
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return;
|
return;
|
||||||
@ -78,7 +84,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.items[index].quantity -= 1;
|
this.items[index].quantity -= 1;
|
||||||
|
|
||||||
if (this.items[index].quantity === 0) {
|
if (this.items[index].quantity === 0) {
|
||||||
this.items = this.items.filter((e) => e.id !== this.items[index].id);
|
this.items = this.items.filter(
|
||||||
|
(e: BasketItem) => e.id !== this.items[index].id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.setCookies();
|
this.setCookies();
|
||||||
},
|
},
|
||||||
@ -105,19 +113,19 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an item in the basket if it was not already in
|
* Create an item in the basket if it was not already in
|
||||||
* @param {number} id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param {string} name The name of the product
|
* @param name The name of the product
|
||||||
* @param {number} price The unit price of the product
|
* @param price The unit price of the product
|
||||||
* @returns {BasketItem} The created item
|
* @returns The created item
|
||||||
*/
|
*/
|
||||||
createItem(id, name, price) {
|
createItem(id: number, name: string, price: number): BasketItem {
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
// biome-ignore lint/style/useNamingConvention: used by django backend
|
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
|
||||||
unit_price: price,
|
unit_price: price,
|
||||||
};
|
} as BasketItem;
|
||||||
|
|
||||||
this.items.push(newItem);
|
this.items.push(newItem);
|
||||||
this.add(newItem);
|
this.add(newItem);
|
||||||
@ -128,12 +136,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
/**
|
/**
|
||||||
* Add an item to the basket.
|
* Add an item to the basket.
|
||||||
* This is called when the user click on a button in the catalog
|
* This is called when the user click on a button in the catalog
|
||||||
* @param {number} id The id of the product to add
|
* @param id The id of the product to add
|
||||||
* @param {string} name The name of the product
|
* @param name The name of the product
|
||||||
* @param {number} price The unit price of the product
|
* @param price The unit price of the product
|
||||||
*/
|
*/
|
||||||
addFromCatalog(id, name, price) {
|
addFromCatalog(id: number, name: string, price: number) {
|
||||||
let item = this.items.find((e) => e.id === id);
|
let item = this.items.find((e: BasketItem) => e.id === id);
|
||||||
|
|
||||||
// if the item is not in the basket, we create it
|
// if the item is not in the basket, we create it
|
||||||
// else we add + 1 to it
|
// else we add + 1 to it
|
@ -11,7 +11,7 @@
|
|||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
{# This script contains the code to perform requests to manipulate the
|
{# This script contains the code to perform requests to manipulate the
|
||||||
user basket without having to reload the page #}
|
user basket without having to reload the page #}
|
||||||
<script src="{{ static('eboutic/js/eboutic.js') }}"></script>
|
<script type="module" src="{{ static('bundled/eboutic/eboutic-index.ts') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
|
@ -9,6 +9,10 @@
|
|||||||
<link rel="stylesheet" href="{{ static('election/css/election.scss') }}">
|
<link rel="stylesheet" href="{{ static('election/css/election.scss') }}">
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|
||||||
|
{% block additional_css %}
|
||||||
|
<script src="{{ static('bundled/vendored/jquery.shorten.min.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3 class="election__title">{{ election.title }}</h3>
|
<h3 class="election__title">{{ election.title }}</h3>
|
||||||
<p class="election__description">{{ election.description }}</p>
|
<p class="election__description">{{ election.description }}</p>
|
||||||
@ -197,12 +201,12 @@
|
|||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
shorten('.role_description', {
|
$('.role_description').shorten({
|
||||||
moreText: "{% trans %}Show more{% endtrans %}",
|
moreText: "{% trans %}Show more{% endtrans %}",
|
||||||
lessText: "{% trans %}Show less{% endtrans %}",
|
lessText: "{% trans %}Show less{% endtrans %}",
|
||||||
showChars: 50
|
showChars: 50
|
||||||
});
|
});
|
||||||
shorten('.candidate_program', {
|
$('.candidate_program').shorten({
|
||||||
moreText: "{% trans %}Show more{% endtrans %}",
|
moreText: "{% trans %}Show more{% endtrans %}",
|
||||||
lessText: "{% trans %}Show less{% endtrans %}",
|
lessText: "{% trans %}Show less{% endtrans %}",
|
||||||
showChars: 200
|
showChars: 200
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script src="{{ static('webpack/galaxy/galaxy-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/galaxy/galaxy-index.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2024-11-10 16:00+0100\n"
|
"POT-Creation-Date: 2024-11-14 10:24+0100\n"
|
||||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@ -23,17 +23,14 @@ msgid "captured.%s"
|
|||||||
msgstr "capture.%s"
|
msgstr "capture.%s"
|
||||||
|
|
||||||
#: core/static/webpack/core/components/ajax-select-base.ts:68
|
#: core/static/webpack/core/components/ajax-select-base.ts:68
|
||||||
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:57
|
|
||||||
msgid "Remove"
|
msgid "Remove"
|
||||||
msgstr "Retirer"
|
msgstr "Retirer"
|
||||||
|
|
||||||
#: core/static/webpack/core/components/ajax-select-base.ts:88
|
#: core/static/webpack/core/components/ajax-select-base.ts:88
|
||||||
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:77
|
|
||||||
msgid "You need to type %(number)s more characters"
|
msgid "You need to type %(number)s more characters"
|
||||||
msgstr "Vous devez taper %(number)s caractères de plus"
|
msgstr "Vous devez taper %(number)s caractères de plus"
|
||||||
|
|
||||||
#: core/static/webpack/core/components/ajax-select-base.ts:92
|
#: core/static/webpack/core/components/ajax-select-base.ts:92
|
||||||
#: staticfiles/generated/webpack/core/static/webpack/core/components/ajax-select-base.js:81
|
|
||||||
msgid "No results found"
|
msgid "No results found"
|
||||||
msgstr "Aucun résultat trouvé"
|
msgstr "Aucun résultat trouvé"
|
||||||
|
|
||||||
@ -113,6 +110,10 @@ msgstr "Activer le plein écran"
|
|||||||
msgid "Markdown guide"
|
msgid "Markdown guide"
|
||||||
msgstr "Guide markdown"
|
msgstr "Guide markdown"
|
||||||
|
|
||||||
|
#: core/static/webpack/core/components/nfc-input-index.ts:24
|
||||||
|
msgid "Unsupported NFC card"
|
||||||
|
msgstr "Carte NFC non supportée"
|
||||||
|
|
||||||
#: core/static/webpack/user/family-graph-index.js:233
|
#: core/static/webpack/user/family-graph-index.js:233
|
||||||
msgid "family_tree.%(extension)s"
|
msgid "family_tree.%(extension)s"
|
||||||
msgstr "arbre_genealogique.%(extension)s"
|
msgstr "arbre_genealogique.%(extension)s"
|
||||||
@ -126,11 +127,9 @@ msgid "Incorrect value"
|
|||||||
msgstr "Valeur incorrecte"
|
msgstr "Valeur incorrecte"
|
||||||
|
|
||||||
#: sas/static/webpack/sas/viewer-index.ts:271
|
#: sas/static/webpack/sas/viewer-index.ts:271
|
||||||
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:234
|
|
||||||
msgid "Couldn't moderate picture"
|
msgid "Couldn't moderate picture"
|
||||||
msgstr "Il n'a pas été possible de modérer l'image"
|
msgstr "Il n'a pas été possible de modérer l'image"
|
||||||
|
|
||||||
#: sas/static/webpack/sas/viewer-index.ts:284
|
#: sas/static/webpack/sas/viewer-index.ts:284
|
||||||
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:248
|
|
||||||
msgid "Couldn't delete picture"
|
msgid "Couldn't delete picture"
|
||||||
msgstr "Il n'a pas été possible de supprimer l'image"
|
msgstr "Il n'a pas été possible de supprimer l'image"
|
||||||
|
@ -66,6 +66,7 @@ nav:
|
|||||||
- Structure du projet: tutorial/structure.md
|
- Structure du projet: tutorial/structure.md
|
||||||
- Gestion des permissions: tutorial/perms.md
|
- Gestion des permissions: tutorial/perms.md
|
||||||
- Gestion des groupes: tutorial/groups.md
|
- Gestion des groupes: tutorial/groups.md
|
||||||
|
- Créer des fragments: tutorial/fragments.md
|
||||||
- Etransactions: tutorial/etransaction.md
|
- Etransactions: tutorial/etransaction.md
|
||||||
- How-to:
|
- How-to:
|
||||||
- L'ORM de Django: howto/querysets.md
|
- L'ORM de Django: howto/querysets.md
|
||||||
|
4034
package-lock.json
generated
4034
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -4,11 +4,11 @@
|
|||||||
"description": "Le web Sith de l'AE",
|
"description": "Le web Sith de l'AE",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "webpack --mode production",
|
"compile": "vite build --mode production",
|
||||||
"compile-dev": "webpack --mode development",
|
"compile-dev": "vite build --mode development",
|
||||||
"serve": "webpack --mode development --watch",
|
"serve": "vite build --mode development --watch",
|
||||||
"analyse-dev": "webpack --config webpack.analyze.config.js --mode development",
|
"analyse-dev": "vite-bundle-visualizer --mode development",
|
||||||
"analyse-prod": "webpack --config webpack.analyze.config.js --mode production",
|
"analyse-prod": "vite-bundle-visualizer --mode production",
|
||||||
"check": "biome check --write"
|
"check": "biome check --write"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@ -17,28 +17,20 @@
|
|||||||
"sideEffects": [".css"],
|
"sideEffects": [".css"],
|
||||||
"imports": {
|
"imports": {
|
||||||
"#openapi": "./staticfiles/generated/openapi/index.ts",
|
"#openapi": "./staticfiles/generated/openapi/index.ts",
|
||||||
"#core:*": "./core/static/webpack/*",
|
"#core:*": "./core/static/bundled/*",
|
||||||
"#pedagogy:*": "./pedagogy/static/webpack/*"
|
"#pedagogy:*": "./pedagogy/static/bundled/*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
"@babel/preset-env": "^7.25.4",
|
"@babel/preset-env": "^7.25.4",
|
||||||
"@biomejs/biome": "1.9.3",
|
"@biomejs/biome": "1.9.3",
|
||||||
"@hey-api/openapi-ts": "^0.53.8",
|
"@hey-api/openapi-ts": "^0.53.8",
|
||||||
|
"@rollup/plugin-inject": "^5.0.5",
|
||||||
"@types/alpinejs": "^3.13.10",
|
"@types/alpinejs": "^3.13.10",
|
||||||
"@types/jquery": "^3.5.31",
|
"@types/jquery": "^3.5.31",
|
||||||
"babel-loader": "^9.2.1",
|
"vite": "^5.4.11",
|
||||||
"css-loader": "^7.1.2",
|
"vite-bundle-visualizer": "^1.2.1",
|
||||||
"css-minimizer-webpack-plugin": "^7.0.0",
|
"vite-plugin-static-copy": "^2.1.0"
|
||||||
"expose-loader": "^5.0.0",
|
|
||||||
"mini-css-extract-plugin": "^2.9.1",
|
|
||||||
"source-map-loader": "^5.0.0",
|
|
||||||
"terser-webpack-plugin": "^5.3.10",
|
|
||||||
"ts-loader": "^9.5.1",
|
|
||||||
"typescript": "^5.6.3",
|
|
||||||
"webpack": "^5.94.0",
|
|
||||||
"webpack-bundle-analyzer": "^4.10.2",
|
|
||||||
"webpack-cli": "^5.1.4"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
@ -54,6 +46,7 @@
|
|||||||
"d3-force-3d": "^3.0.5",
|
"d3-force-3d": "^3.0.5",
|
||||||
"easymde": "^2.18.0",
|
"easymde": "^2.18.0",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
|
"htmx.org": "^2.0.3",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^3.7.1",
|
||||||
"jquery-ui": "^1.14.0",
|
"jquery-ui": "^1.14.0",
|
||||||
"jquery.shorten": "^1.0.0",
|
"jquery.shorten": "^1.0.0",
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script src="{{ static('webpack/pedagogy/guide-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/pedagogy/guide-index.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
@ -123,7 +123,7 @@ def merge_users(u1: User, u2: User) -> User:
|
|||||||
c_dest, created = Customer.get_or_create(u1)
|
c_dest, created = Customer.get_or_create(u1)
|
||||||
c_src.refillings.update(customer=c_dest)
|
c_src.refillings.update(customer=c_dest)
|
||||||
c_src.buyings.update(customer=c_dest)
|
c_src.buyings.update(customer=c_dest)
|
||||||
c_dest.recompute_amount()
|
Customer.objects.filter(pk=c_dest.pk).update_amount()
|
||||||
if created:
|
if created:
|
||||||
# swap the account numbers, so that the user keep
|
# swap the account numbers, so that the user keep
|
||||||
# the id he is accustomed to
|
# the id he is accustomed to
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
<script src="{{ static('webpack/sas/album-index.js') }}" defer></script>
|
<script type="module" src="{{ static('bundled/sas/album-index.js') }}"></script>
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link defer rel="stylesheet" href="{{ static('webpack/core/components/ajax-select-index.css') }}">
|
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
|
||||||
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
|
||||||
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_js -%}
|
{%- block additional_js -%}
|
||||||
<script defer src="{{ static('webpack/core/components/ajax-select-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/core/components/ajax-select-index.ts') }}"></script>
|
||||||
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
|
<script type="module" src="{{ static("bundled/sas/viewer-index.ts") }}"></script>
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
from core.views.widgets.select import (
|
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||||
AutoCompleteSelect,
|
|
||||||
AutoCompleteSelectMultiple,
|
|
||||||
)
|
|
||||||
from sas.models import Album
|
from sas.models import Album
|
||||||
from sas.schemas import AlbumSchema
|
from sas.schemas import AlbumSchema
|
||||||
|
|
||||||
_js = ["webpack/sas/components/ajax-select-index.ts"]
|
_js = ["bundled/sas/components/ajax-select-index.ts"]
|
||||||
|
|
||||||
|
|
||||||
class AutoCompleteSelectAlbum(AutoCompleteSelect):
|
class AutoCompleteSelectAlbum(AutoCompleteSelect):
|
||||||
|
@ -95,7 +95,6 @@ INSTALLED_APPS = (
|
|||||||
"com",
|
"com",
|
||||||
"election",
|
"election",
|
||||||
"forum",
|
"forum",
|
||||||
"stock",
|
|
||||||
"trombi",
|
"trombi",
|
||||||
"matmat",
|
"matmat",
|
||||||
"pedagogy",
|
"pedagogy",
|
||||||
@ -370,6 +369,8 @@ SITH_CLUB_REFOUND_ID = 89
|
|||||||
SITH_COUNTER_REFOUND_ID = 38
|
SITH_COUNTER_REFOUND_ID = 38
|
||||||
SITH_PRODUCT_REFOUND_ID = 5
|
SITH_PRODUCT_REFOUND_ID = 5
|
||||||
|
|
||||||
|
SITH_COUNTER_ACCOUNT_DUMP_ID = 39
|
||||||
|
|
||||||
# Pages
|
# Pages
|
||||||
SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"
|
SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"
|
||||||
|
|
||||||
|
@ -3,13 +3,15 @@ from pathlib import Path
|
|||||||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||||
|
|
||||||
GENERATED_ROOT = Path(__file__).parent.resolve() / "generated"
|
GENERATED_ROOT = Path(__file__).parent.resolve() / "generated"
|
||||||
IGNORE_PATTERNS_WEBPACK = ["webpack/*"]
|
BUNDLED_FOLDER_NAME = "bundled"
|
||||||
|
BUNDLED_ROOT = GENERATED_ROOT / BUNDLED_FOLDER_NAME
|
||||||
|
IGNORE_PATTERNS_BUNDLED = [f"{BUNDLED_FOLDER_NAME}/*"]
|
||||||
IGNORE_PATTERNS_SCSS = ["*.scss"]
|
IGNORE_PATTERNS_SCSS = ["*.scss"]
|
||||||
IGNORE_PATTERNS_TYPESCRIPT = ["*.ts"]
|
IGNORE_PATTERNS_TYPESCRIPT = ["*.ts"]
|
||||||
IGNORE_PATTERNS = [
|
IGNORE_PATTERNS = [
|
||||||
*StaticFilesConfig.ignore_patterns,
|
*StaticFilesConfig.ignore_patterns,
|
||||||
*IGNORE_PATTERNS_TYPESCRIPT,
|
*IGNORE_PATTERNS_TYPESCRIPT,
|
||||||
*IGNORE_PATTERNS_WEBPACK,
|
*IGNORE_PATTERNS_BUNDLED,
|
||||||
*IGNORE_PATTERNS_SCSS,
|
*IGNORE_PATTERNS_SCSS,
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -25,7 +27,7 @@ class StaticFilesConfig(StaticFilesConfig):
|
|||||||
"""
|
"""
|
||||||
Application in charge of processing statics files.
|
Application in charge of processing statics files.
|
||||||
It replaces the original django staticfiles
|
It replaces the original django staticfiles
|
||||||
It integrates scss files and webpack.
|
It integrates scss files and javascript bundling.
|
||||||
It makes sure that statics are properly collected and that they are automatically
|
It makes sure that statics are properly collected and that they are automatically
|
||||||
when using the development server.
|
when using the development server.
|
||||||
"""
|
"""
|
||||||
|
@ -4,7 +4,7 @@ from django.contrib.staticfiles import utils
|
|||||||
from django.contrib.staticfiles.finders import FileSystemFinder
|
from django.contrib.staticfiles.finders import FileSystemFinder
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
from staticfiles.apps import GENERATED_ROOT, IGNORE_PATTERNS_WEBPACK
|
from staticfiles.apps import GENERATED_ROOT, IGNORE_PATTERNS_BUNDLED
|
||||||
|
|
||||||
|
|
||||||
class GeneratedFilesFinder(FileSystemFinder):
|
class GeneratedFilesFinder(FileSystemFinder):
|
||||||
@ -27,9 +27,9 @@ class GeneratedFilesFinder(FileSystemFinder):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ignored = ignore_patterns
|
ignored = ignore_patterns
|
||||||
# We don't want to ignore webpack files in the generated folder
|
# We don't want to ignore bundled files in the generated folder
|
||||||
if root == GENERATED_ROOT:
|
if root == GENERATED_ROOT:
|
||||||
ignored = list(set(ignored) - set(IGNORE_PATTERNS_WEBPACK))
|
ignored = list(set(ignored) - set(IGNORE_PATTERNS_BUNDLED))
|
||||||
|
|
||||||
storage = self.storages[root]
|
storage = self.storages[root]
|
||||||
for path in utils.get_files(storage, ignored):
|
for path in utils.get_files(storage, ignored):
|
||||||
|
@ -7,11 +7,11 @@ from django.contrib.staticfiles.management.commands.collectstatic import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from staticfiles.apps import GENERATED_ROOT, IGNORE_PATTERNS_SCSS
|
from staticfiles.apps import GENERATED_ROOT, IGNORE_PATTERNS_SCSS
|
||||||
from staticfiles.processors import OpenApi, Scss, Webpack
|
from staticfiles.processors import JSBundler, OpenApi, Scss
|
||||||
|
|
||||||
|
|
||||||
class Command(CollectStatic):
|
class Command(CollectStatic):
|
||||||
"""Integrate webpack and css compilation to collectstatic"""
|
"""Integrate js bundling and css compilation to collectstatic"""
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
super().add_arguments(parser)
|
super().add_arguments(parser)
|
||||||
@ -50,8 +50,8 @@ class Command(CollectStatic):
|
|||||||
return Path(location)
|
return Path(location)
|
||||||
|
|
||||||
Scss.compile(self.collect_scss())
|
Scss.compile(self.collect_scss())
|
||||||
OpenApi.compile() # This needs to be prior to webpack
|
OpenApi.compile() # This needs to be prior to javascript bundling
|
||||||
Webpack.compile()
|
JSBundler.compile()
|
||||||
|
|
||||||
collected = super().collect()
|
collected = super().collect()
|
||||||
|
|
||||||
|
@ -6,19 +6,19 @@ from django.contrib.staticfiles.management.commands.runserver import (
|
|||||||
)
|
)
|
||||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||||
|
|
||||||
from staticfiles.processors import OpenApi, Webpack
|
from staticfiles.processors import JSBundler, OpenApi
|
||||||
|
|
||||||
|
|
||||||
class Command(Runserver):
|
class Command(Runserver):
|
||||||
"""Light wrapper around default runserver that integrates webpack auto bundling."""
|
"""Light wrapper around default runserver that integrates javascirpt auto bundling."""
|
||||||
|
|
||||||
def run(self, **options):
|
def run(self, **options):
|
||||||
# OpenApi generation needs to be before webpack
|
# OpenApi generation needs to be before the bundler
|
||||||
OpenApi.compile()
|
OpenApi.compile()
|
||||||
# Only run webpack server when debug is enabled
|
# Only run the bundling server when debug is enabled
|
||||||
# Also protects from re-launching the server if django reloads it
|
# Also protects from re-launching the server if django reloads it
|
||||||
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and settings.DEBUG:
|
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and settings.DEBUG:
|
||||||
with Webpack.runserver():
|
with JSBundler.runserver():
|
||||||
super().run(**options)
|
super().run(**options)
|
||||||
return
|
return
|
||||||
super().run(**options)
|
super().run(**options)
|
||||||
|
@ -1,33 +1,120 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
|
from itertools import chain
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable, Self
|
||||||
|
|
||||||
import rjsmin
|
import rjsmin
|
||||||
import sass
|
import sass
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from sith.urls import api
|
from sith.urls import api
|
||||||
from staticfiles.apps import GENERATED_ROOT
|
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT
|
||||||
|
|
||||||
|
|
||||||
class Webpack:
|
@dataclass
|
||||||
|
class JsBundlerManifestEntry:
|
||||||
|
src: str
|
||||||
|
out: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json_entry(cls, entry: dict[str, any]) -> list[Self]:
|
||||||
|
# We have two parts for a manifest entry
|
||||||
|
# The `src` element which is what the user asks django as a static
|
||||||
|
# The `out` element which is it's real name in the output static folder
|
||||||
|
|
||||||
|
# For the src part:
|
||||||
|
# The manifest file contains the path of the file relative to the project root
|
||||||
|
# We want the relative path of the file inside their respective static folder
|
||||||
|
# because that's what the user types when importing statics and that's what django gives us
|
||||||
|
# This is really similar to what we are doing in the bundler, it uses a similar algorithm
|
||||||
|
# Example:
|
||||||
|
# core/static/bundled/alpine-index.js -> bundled/alpine-index.js
|
||||||
|
# core/static/bundled/components/include-index.ts -> core/static/bundled/components/include-index.ts
|
||||||
|
def get_relative_src_name(name: str) -> str:
|
||||||
|
original_path = Path(name)
|
||||||
|
relative_path: list[str] = []
|
||||||
|
for directory in reversed(original_path.parts):
|
||||||
|
relative_path.append(directory)
|
||||||
|
# Contrary to the bundler algorithm, we do want to keep the bundled prefix
|
||||||
|
if directory == BUNDLED_FOLDER_NAME:
|
||||||
|
break
|
||||||
|
return str(Path(*reversed(relative_path)))
|
||||||
|
|
||||||
|
# For the out part:
|
||||||
|
# The bundler is configured to output files in generated/bundled and considers this folders as it's root
|
||||||
|
# Thus, the output name doesn't contain the `bundled` prefix that we need, we add it ourselves
|
||||||
|
ret = [
|
||||||
|
cls(
|
||||||
|
src=get_relative_src_name(entry["src"]),
|
||||||
|
out=str(Path(BUNDLED_FOLDER_NAME) / entry["file"]),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def remove_hash(path: Path) -> str:
|
||||||
|
# Hashes are configured to be surrounded by `.`
|
||||||
|
# Filenames are like this path/to/file.hash.ext
|
||||||
|
unhashed = ".".join(path.stem.split(".")[:-1])
|
||||||
|
return str(path.with_stem(unhashed))
|
||||||
|
|
||||||
|
# CSS files generated by entrypoints don't have their own entry in the manifest
|
||||||
|
# They are however listed as an attribute of the entry point that generates them
|
||||||
|
# Their listed name is the one that has been generated inside the generated/bundled folder
|
||||||
|
# We prefix it with `bundled` and then generate an `src` name by removing the hash
|
||||||
|
for css in entry.get("css", []):
|
||||||
|
path = Path(BUNDLED_FOLDER_NAME) / css
|
||||||
|
ret.append(
|
||||||
|
cls(
|
||||||
|
src=remove_hash(path),
|
||||||
|
out=str(path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class JSBundlerManifest:
|
||||||
|
def __init__(self, manifest: Path):
|
||||||
|
with open(manifest, "r") as f:
|
||||||
|
self._manifest = json.load(f)
|
||||||
|
|
||||||
|
self._files = chain(
|
||||||
|
*[
|
||||||
|
JsBundlerManifestEntry.from_json_entry(value)
|
||||||
|
for value in self._manifest.values()
|
||||||
|
if value.get("isEntry", False)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.mapping = {file.src: file.out for file in self._files}
|
||||||
|
|
||||||
|
|
||||||
|
class JSBundler:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compile():
|
def compile():
|
||||||
"""Bundle js files with webpack for production."""
|
"""Bundle js files with the javascript bundler for production."""
|
||||||
process = subprocess.Popen(["npm", "run", "compile"])
|
process = subprocess.Popen(["npm", "run", "compile"])
|
||||||
process.wait()
|
process.wait()
|
||||||
if process.returncode:
|
if process.returncode:
|
||||||
raise RuntimeError(f"Webpack failed with returncode {process.returncode}")
|
raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def runserver() -> subprocess.Popen:
|
def runserver() -> subprocess.Popen:
|
||||||
"""Bundle js files automatically in background when called in debug mode."""
|
"""Bundle js files automatically in background when called in debug mode."""
|
||||||
logging.getLogger("django").info("Running webpack server")
|
logging.getLogger("django").info("Running javascript bundling server")
|
||||||
return subprocess.Popen(["npm", "run", "serve"])
|
return subprocess.Popen(["npm", "run", "serve"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_manifest() -> JSBundlerManifest:
|
||||||
|
return JSBundlerManifest(BUNDLED_ROOT / ".vite" / "manifest.json")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_in_bundle(name: str | None) -> bool:
|
||||||
|
if name is None:
|
||||||
|
return False
|
||||||
|
return Path(name).parts[0] == BUNDLED_FOLDER_NAME
|
||||||
|
|
||||||
|
|
||||||
class Scss:
|
class Scss:
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -69,7 +156,7 @@ class JS:
|
|||||||
p
|
p
|
||||||
for p in settings.STATIC_ROOT.rglob("*.js")
|
for p in settings.STATIC_ROOT.rglob("*.js")
|
||||||
if ".min" not in p.suffixes
|
if ".min" not in p.suffixes
|
||||||
and (settings.STATIC_ROOT / "webpack") not in p.parents
|
and (settings.STATIC_ROOT / BUNDLED_FOLDER_NAME) not in p.parents
|
||||||
]
|
]
|
||||||
for path in to_exec:
|
for path in to_exec:
|
||||||
p = path.resolve()
|
p = path.resolve()
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user