mirror of
https://github.com/ae-utbm/sith.git
synced 2025-01-08 16:11:17 +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
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: gettext pipx
|
||||
packages: gettext
|
||||
version: 1.0 # increment to reset cache
|
||||
|
||||
- name: Set up python
|
||||
@ -19,12 +19,12 @@ runs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.local
|
||||
key: poetry-1 # increment to reset cache
|
||||
key: poetry-3 # increment to reset cache
|
||||
|
||||
- name: Install Poetry
|
||||
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: pipx install poetry
|
||||
run: curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
- name: Check pyproject.toml syntax
|
||||
shell: bash
|
||||
|
@ -4,7 +4,7 @@ from accounting.models import ClubAccount, Company
|
||||
from accounting.schemas import ClubAccountSchema, CompanySchema
|
||||
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):
|
||||
|
@ -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 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):
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||
<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>
|
||||
</head>
|
||||
<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
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Iterator
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count, Exists, F, Min, OuterRef, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
||||
from django.utils.timezone import localdate, make_aware, now
|
||||
from faker import Faker
|
||||
|
||||
@ -268,24 +266,6 @@ class Command(BaseCommand):
|
||||
Product.buying_groups.through.objects.bulk_create(buying_groups)
|
||||
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]):
|
||||
customers = list(
|
||||
Customer.objects.annotate(
|
||||
@ -355,7 +335,7 @@ class Command(BaseCommand):
|
||||
sales.extend(this_customer_sales)
|
||||
Refilling.objects.bulk_create(reloads)
|
||||
Selling.objects.bulk_create(sales)
|
||||
self._update_balances()
|
||||
Customer.objects.update_amount()
|
||||
|
||||
def create_permanences(self, sellers: list[User]):
|
||||
counters = list(
|
||||
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import string
|
||||
import unicodedata
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@ -528,13 +529,15 @@ class User(AbstractBaseUser):
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def can_create_subscription(self):
|
||||
from club.models import Club
|
||||
def can_create_subscription(self) -> bool:
|
||||
from club.models import Membership
|
||||
|
||||
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
|
||||
if club in self.clubs_with_rights:
|
||||
return True
|
||||
return False
|
||||
return (
|
||||
Membership.objects.board()
|
||||
.ongoing()
|
||||
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def is_launderette_manager(self):
|
||||
@ -688,12 +691,20 @@ class User(AbstractBaseUser):
|
||||
.encode("ascii", "ignore")
|
||||
.decode("utf-8")
|
||||
)
|
||||
un_set = [u.username for u in User.objects.all()]
|
||||
if user_name in un_set:
|
||||
i = 1
|
||||
while user_name + str(i) in un_set:
|
||||
i += 1
|
||||
user_name += str(i)
|
||||
# load all usernames which could conflict with the new one.
|
||||
# we need to actually load them, instead of performing a count,
|
||||
# because we cannot be sure that two usernames refer to the
|
||||
# actual same word (eg. tmore and tmoreau)
|
||||
possible_conflicts: list[str] = list(
|
||||
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
|
||||
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 "forms";
|
||||
|
||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||
$small-devices: 576px;
|
||||
@ -13,91 +14,6 @@ body {
|
||||
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] {
|
||||
--loading-size: 50px;
|
||||
--loading-stroke: 5px;
|
||||
@ -262,8 +178,10 @@ a:not(.button) {
|
||||
font-weight: normal;
|
||||
color: white;
|
||||
padding: 9px 13px;
|
||||
margin: 3px;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
@ -367,6 +285,49 @@ a:not(.button) {
|
||||
.alert-aside {
|
||||
display: flex;
|
||||
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_mini_profile {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--gap-size: 1em;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-size);
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user_mini_profile_infos {
|
||||
padding: 0.2em;
|
||||
height: 20%;
|
||||
max-height: 20%;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-around;
|
||||
font-size: 0.9em;
|
||||
|
||||
div {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.user_mini_profile_infos_text {
|
||||
text-align: center;
|
||||
|
||||
@ -1276,10 +1237,10 @@ u,
|
||||
}
|
||||
|
||||
.user_mini_profile_picture {
|
||||
height: 80%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-height: calc(80% - var(--gap-size));
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,7 +130,7 @@ main {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-evenly;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
width: 100%;
|
||||
@ -143,21 +143,14 @@ main {
|
||||
}
|
||||
|
||||
> .user_profile_pictures_bigone {
|
||||
flex-grow: 9;
|
||||
flex-basis: 20em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> img {
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -169,7 +162,6 @@ main {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
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" %}
|
||||
{% block additional_js %}
|
||||
{% 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 %}
|
||||
{% endblock additional_js %}
|
||||
|
||||
|
@ -5,7 +5,6 @@
|
||||
<title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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/style.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
@ -14,18 +13,20 @@
|
||||
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
||||
|
||||
{% block jquery_css %}
|
||||
{# 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') }}">
|
||||
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
|
||||
<link rel="stylesheet" href="{{ static('bundled/jquery-ui-index.css') }}">
|
||||
{% endblock %}
|
||||
<link rel="preload" as="style" href="{{ static('webpack/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="{{ static('webpack/fontawesome-index.css') }}"></noscript>
|
||||
<link rel="preload" as="style" href="{{ static('bundled/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
||||
|
||||
<script src="{{ url('javascript-catalog') }}"></script>
|
||||
<script src={{ static("webpack/core/components/include-index.ts") }}></script>
|
||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||
<script src="{{ static('webpack/jquery-index.js') }}"></script>
|
||||
<!-- Put here to always have access to those functions on django widgets -->
|
||||
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
@ -37,148 +38,13 @@
|
||||
|
||||
<body>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- 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 -->
|
||||
{% csrf_token %}
|
||||
<!-- BEGIN HEADER -->
|
||||
|
||||
{% block header %}
|
||||
{% if not popup %}
|
||||
<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>
|
||||
{% include "core/base/header.jinja" %}
|
||||
|
||||
{% block info_boxes %}
|
||||
<div id="info_boxes">
|
||||
@ -201,58 +67,10 @@
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
<!-- END HEADER -->
|
||||
|
||||
{% block nav %}
|
||||
{% if not popup %}
|
||||
<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>
|
||||
{% include "core/base/navbar.jinja" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -265,19 +83,16 @@
|
||||
</ul>
|
||||
|
||||
<div id="content">
|
||||
{% 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 %}
|
||||
{% block tabs %}
|
||||
{% include "core/base/tabs.jinja" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block errors%}
|
||||
{% if error %}
|
||||
{{ error }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% if error %}
|
||||
{{ error }}
|
||||
{% endif %}
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</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 %}
|
||||
{% if file %}
|
||||
@ -21,7 +25,7 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% block tabs %}
|
||||
{{ print_file_name(file) }}
|
||||
|
||||
<div class="tool_bar">
|
||||
@ -44,6 +48,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if file %}
|
||||
{% block file %}
|
||||
|
@ -4,15 +4,49 @@
|
||||
{% trans %}Delete confirmation{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% if is_fragment %}
|
||||
|
||||
{# Don't display tabs and errors #}
|
||||
{% block tabs %}
|
||||
{% endblock %}
|
||||
{% block errors %}
|
||||
{% endblock %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block file %}
|
||||
<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>
|
||||
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
||||
</form>
|
||||
<form method="GET" action="javascript:history.back();">
|
||||
<input type="submit" name="cancel" value="{% trans %}Cancel{% endtrans %}" />
|
||||
<button
|
||||
{% if is_fragment %}
|
||||
hx-post="{{ action }}"
|
||||
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>
|
||||
|
||||
{% 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 %}
|
||||
{% trans %}File moderation{% endtrans %}
|
||||
@ -7,8 +19,11 @@
|
||||
{% block content %}
|
||||
<h3>{% trans %}File moderation{% endtrans %}</h3>
|
||||
<div>
|
||||
{% for f in files %}
|
||||
<div style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center">
|
||||
{% for f in object_list %}
|
||||
<div
|
||||
id="file-{{ loop.index }}"
|
||||
style="margin: 2px; padding: 2px; border: solid 1px red; text-align: center"
|
||||
>
|
||||
{% if f.is_folder %}
|
||||
<strong>Folder</strong>
|
||||
{% else %}
|
||||
@ -20,9 +35,19 @@
|
||||
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
|
||||
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
|
||||
</p>
|
||||
<p><a href="{{ url('core:file_moderate', file_id=f.id) }}">{% trans %}Moderate{% endtrans %}</a> -
|
||||
<a href="{{ url('core:file_delete', file_id=f.id) }}?next={{ url('core:file_moderation') }}">{% trans %}Delete{% endtrans %}</a></p>
|
||||
<p><button
|
||||
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>
|
||||
{% endfor %}
|
||||
{{ paginate_htmx(page_obj, paginator) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -66,7 +66,12 @@
|
||||
</div>
|
||||
{% if user.promo and user.promo_has_logo() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -74,8 +79,11 @@
|
||||
{% if user.profile_pict %}
|
||||
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
||||
{% else %}
|
||||
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
|
||||
title="{% trans %}Profile{% endtrans %}" />
|
||||
<img
|
||||
src="{{ static('core/img/unknown.jpg') }}"
|
||||
alt="{% trans %}Profile{% endtrans %}"
|
||||
title="{% trans %}Profile{% endtrans %}"
|
||||
/>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -166,9 +174,37 @@
|
||||
current_page (django.core.paginator.Page): the current page 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">
|
||||
{% 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>
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
@ -182,16 +218,33 @@
|
||||
{% elif i == paginator.ELLIPSIS %}
|
||||
<strong>{{ paginator.ELLIPSIS }}</strong>
|
||||
{% 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>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if current_page.has_next() %}
|
||||
<a href="?page={{ current_page.next_page_number() }}">
|
||||
<button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?page={{ current_page.next_page_number() }}"
|
||||
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>
|
||||
{% else %}
|
||||
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
||||
@ -202,9 +255,9 @@
|
||||
{% macro select_all_checkbox(form_id) %}
|
||||
<script type="text/javascript">
|
||||
function checkbox_{{form_id}}(value) {
|
||||
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||
for (let element of list){
|
||||
if (element.type == "checkbox"){
|
||||
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||
for (let element of inputs){
|
||||
if (element.type === "checkbox"){
|
||||
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}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||
{% 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 delete_url = (
|
||||
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 %}
|
||||
{%- else -%}
|
||||
{% set default_picture = static('core/img/unknown.jpg')|tojson %}
|
||||
|
@ -7,7 +7,7 @@
|
||||
{%- endblock -%}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block title %}
|
||||
|
@ -5,7 +5,7 @@
|
||||
{%- endblock -%}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block title %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% for js in statics.js %}
|
||||
<script-once src="{{ js }}" defer></script-once>
|
||||
<script-once type="module" src="{{ js }}"></script-once>
|
||||
{% endfor %}
|
||||
{% for css in statics.css %}
|
||||
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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>
|
||||
|
||||
<markdown-input name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% if widget.value %}{{ widget.value }}{% endif %}</markdown-input>
|
||||
|
@ -1,33 +1,6 @@
|
||||
<span>
|
||||
<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value }}"{% endif %}{% include "django/forms/widgets/attrs.html" %}>
|
||||
<!-- 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();
|
||||
<script-once type="module" src="{{ statics.js }}"></script-once>
|
||||
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
|
||||
|
||||
ndef.addEventListener("readingerror", () => {
|
||||
alert("{{ translations.unsupported }}")
|
||||
});
|
||||
|
||||
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>
|
||||
<span>
|
||||
<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>
|
||||
|
@ -21,9 +21,11 @@ import pytest
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
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.utils.timezone import now
|
||||
from django.views.generic import View
|
||||
from django.views.generic.base import ContextMixin
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
|
||||
@ -32,6 +34,7 @@ from club.models import Membership
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
from core.views import AllowFragment
|
||||
from sith import settings
|
||||
|
||||
|
||||
@ -538,3 +541,18 @@ class TestDateUtils(TestCase):
|
||||
# forward time to the middle of the next semester
|
||||
frozen_time.move_to(mid_autumn)
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
class TestUserProfilePicture:
|
||||
"""Test interactions with user's profile picture."""
|
||||
|
@ -166,3 +166,24 @@ def test_user_invoice_with_multiple_items():
|
||||
.values_list("total", flat=True)
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
# E402: putting those import at the top of the file would also be difficult
|
||||
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.utils.http import http_date
|
||||
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.edit import DeleteView, FormMixin, UpdateView
|
||||
|
||||
from core.models import Notification, RealGroup, SithFile
|
||||
from core.models import Notification, RealGroup, SithFile, User
|
||||
from core.views import (
|
||||
AllowFragment,
|
||||
CanEditMixin,
|
||||
CanEditPropMixin,
|
||||
CanViewMixin,
|
||||
@ -352,7 +353,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
return kwargs
|
||||
|
||||
|
||||
class FileDeleteView(CanEditPropMixin, DeleteView):
|
||||
class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
model = SithFile
|
||||
pk_url_kwarg = "file_id"
|
||||
template_name = "core/file_delete_confirm.jinja"
|
||||
@ -376,19 +377,24 @@ class FileDeleteView(CanEditPropMixin, DeleteView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = ""
|
||||
if self.kwargs.get("popup") is not None:
|
||||
kwargs["popup"] = "popup"
|
||||
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
|
||||
kwargs["next"] = self.request.GET.get("next", None)
|
||||
kwargs["previous"] = self.request.GET.get("previous", None)
|
||||
kwargs["current"] = self.request.path
|
||||
return kwargs
|
||||
|
||||
|
||||
class FileModerationView(TemplateView):
|
||||
class FileModerationView(AllowFragment, ListView):
|
||||
model = SithFile
|
||||
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):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["files"] = SithFile.objects.filter(is_moderated=False)[:100]
|
||||
return kwargs
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||
user: User = request.user
|
||||
if user.is_root:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
class FileModerateView(CanEditPropMixin, SingleObjectMixin):
|
||||
|
@ -27,6 +27,9 @@ from captcha.fields import CaptchaField
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
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.db import transaction
|
||||
from django.forms import (
|
||||
@ -72,7 +75,10 @@ class NFCTextInput(TextInput):
|
||||
|
||||
def get_context(self, 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
|
||||
|
||||
|
||||
|
@ -9,7 +9,7 @@ class MarkdownInput(Textarea):
|
||||
context = super().get_context(name, value, attrs)
|
||||
|
||||
context["statics"] = {
|
||||
"js": staticfiles_storage.url("webpack/core/components/easymde-index.ts"),
|
||||
"css": staticfiles_storage.url("webpack/core/components/easymde-index.css"),
|
||||
"js": staticfiles_storage.url("bundled/core/components/easymde-index.ts"),
|
||||
"css": staticfiles_storage.url("bundled/core/components/easymde-index.css"),
|
||||
}
|
||||
return context
|
||||
|
@ -19,10 +19,10 @@ class AutoCompleteSelectMixin:
|
||||
pk = "id"
|
||||
|
||||
js = [
|
||||
"webpack/core/components/ajax-select-index.ts",
|
||||
"bundled/core/components/ajax-select-index.ts",
|
||||
]
|
||||
css = [
|
||||
"webpack/core/components/ajax-select-index.css",
|
||||
"bundled/core/components/ajax-select-index.css",
|
||||
"core/components/ajax-select.scss",
|
||||
]
|
||||
|
||||
|
@ -63,15 +63,25 @@ class BillingInfoAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(AccountDump)
|
||||
class AccountDumpAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "warning_mail_sent_at"
|
||||
list_display = (
|
||||
"customer",
|
||||
"warning_mail_sent_at",
|
||||
"warning_mail_error",
|
||||
"dump_operation",
|
||||
"amount",
|
||||
)
|
||||
autocomplete_fields = ("customer",)
|
||||
autocomplete_fields = ("customer", "dump_operation")
|
||||
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)
|
||||
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.core.mail import send_mail
|
||||
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.utils.timezone import localdate, now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import User
|
||||
from core.models import User, UserQuerySet
|
||||
from counter.models import AccountDump
|
||||
from subscription.models import Subscription
|
||||
|
||||
@ -72,7 +72,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Finished !")
|
||||
|
||||
@staticmethod
|
||||
def _get_users() -> QuerySet[User]:
|
||||
def _get_users() -> UserQuerySet:
|
||||
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
|
||||
customer__user=OuterRef("pk")
|
||||
)
|
||||
@ -97,7 +97,7 @@ class Command(BaseCommand):
|
||||
True if the mail was successfully sent, else False
|
||||
"""
|
||||
message = render_to_string(
|
||||
"counter/account_dump_warning_mail.jinja",
|
||||
"counter/mails/account_dump_warning.jinja",
|
||||
{
|
||||
"balance": user.customer.amount,
|
||||
"last_subscription_date": user.last_subscription_date,
|
||||
|
@ -20,14 +20,15 @@ import random
|
||||
import string
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import timezone as tz
|
||||
from decimal import Decimal
|
||||
from typing import Self, Tuple
|
||||
|
||||
from dict2xml import dict2xml
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value
|
||||
from django.db.models.functions import Concat, Length
|
||||
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Subquery, Sum, Value
|
||||
from django.db.models.functions import Coalesce, Concat, Length
|
||||
from django.forms import ValidationError
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@ -45,6 +46,39 @@ from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
||||
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):
|
||||
"""Customer data of a User.
|
||||
|
||||
@ -57,6 +91,8 @@ class Customer(models.Model):
|
||||
amount = CurrencyField(_("amount"), default=0)
|
||||
recorded_products = models.IntegerField(_("recorded product"), default=0)
|
||||
|
||||
objects = CustomerQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("customer")
|
||||
verbose_name_plural = _("customers")
|
||||
@ -141,18 +177,6 @@ class Customer(models.Model):
|
||||
account = cls.objects.create(user=user, account_id=account_id)
|
||||
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):
|
||||
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"
|
||||
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):
|
||||
"""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
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core import mail
|
||||
from django.core.management import call_command
|
||||
@ -9,25 +12,29 @@ from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from core.baker_recipes import subscriber_user, very_old_subscriber_user
|
||||
from counter.management.commands.dump_warning_mail import Command
|
||||
from counter.models import AccountDump, Customer, Refilling
|
||||
from counter.management.commands.dump_accounts import Command as DumpCommand
|
||||
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
|
||||
def setUpTestData(cls):
|
||||
# delete existing customers to avoid side effect
|
||||
Customer.objects.all().delete()
|
||||
refill_recipe = Recipe(Refilling, amount=10)
|
||||
def set_up_notified_users(cls):
|
||||
"""Create the users which should be considered as dumpable"""
|
||||
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
|
||||
inactive_date = (
|
||||
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
|
||||
)
|
||||
refill_recipe.make(
|
||||
baker.make(
|
||||
Refilling,
|
||||
amount=10,
|
||||
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),
|
||||
)
|
||||
|
||||
@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 = [
|
||||
subscriber_user.make(),
|
||||
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)
|
||||
)
|
||||
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(
|
||||
AccountDump,
|
||||
@ -46,10 +54,19 @@ class TestAccountDumpWarningMailCommand(TestCase):
|
||||
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):
|
||||
"""Test that the user to warn are well selected."""
|
||||
users = list(Command._get_users())
|
||||
assert len(users) == 3
|
||||
users = list(WarningCommand._get_users())
|
||||
assert len(users) == len(self.notified_users)
|
||||
assert set(users) == set(self.notified_users)
|
||||
|
||||
def test_command(self):
|
||||
@ -63,3 +80,89 @@ class TestAccountDumpWarningMailCommand(TestCase):
|
||||
for sent in sent_mails:
|
||||
assert len(sent.to) == 1
|
||||
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"
|
||||
#
|
||||
#
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
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.models import User
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
Customer,
|
||||
Permanency,
|
||||
@ -46,6 +43,7 @@ class TestCounter(TestCase):
|
||||
cls.skia = User.objects.filter(username="skia").first()
|
||||
cls.sli = User.objects.filter(username="sli").first()
|
||||
cls.krophil = User.objects.filter(username="krophil").first()
|
||||
cls.richard = User.objects.filter(username="rbatsbak").first()
|
||||
cls.mde = Counter.objects.filter(name="MDE").first()
|
||||
cls.foyer = Counter.objects.get(id=2)
|
||||
|
||||
@ -66,7 +64,7 @@ class TestCounter(TestCase):
|
||||
|
||||
response = self.client.post(
|
||||
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")
|
||||
response = self.client.get(response.get("location"))
|
||||
@ -137,7 +135,7 @@ class TestCounter(TestCase):
|
||||
|
||||
response = self.client.post(
|
||||
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")
|
||||
|
||||
@ -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):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -529,341 +384,6 @@ def test_barman_timeout():
|
||||
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):
|
||||
@classmethod
|
||||
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.schemas import ProductSchema, SimplifiedCounterSchema
|
||||
|
||||
_js = ["webpack/counter/components/ajax-select-index.ts"]
|
||||
_js = ["bundled/counter/components/ajax-select-index.ts"]
|
||||
|
||||
|
||||
class AutoCompleteSelectCounter(AutoCompleteSelect):
|
||||
|
@ -200,6 +200,19 @@ Grâce à son architecture, il est extrêmement
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
[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.
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
[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.
|
||||
Le logiciel se configure au moyen du fichier `vite.config.mts` à la racine du projet.
|
||||
|
@ -1,6 +1,6 @@
|
||||
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.
|
||||
|
||||
@ -11,7 +11,7 @@ D'abord, il faut ajouter dans node via `package.json`:
|
||||
// ...
|
||||
"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": {
|
||||
// ...
|
||||
"#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') }}">
|
||||
```
|
||||
|
||||
## 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
|
||||
les fichiers sont à mettre dans un dossier `static/webpack` de l'application à la place.
|
||||
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/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
|
||||
{# Example pour ajouter sith/core/webpack/alpine-index.js #}
|
||||
<script src="{{ static('webpack/alpine-index.js') }}" defer></script>
|
||||
<script src="{{ static('webpack/other-index.ts') }}" defer></script>
|
||||
{# Example pour ajouter sith/core/bundled/alpine-index.js #}
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/other-index.ts') }}"></script>
|
||||
```
|
||||
|
||||
!!!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
|
||||
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:
|
||||
|
||||
|
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.
|
||||
22. Fonctionnalités pour gérer le spam.
|
||||
23. Gestion des statics du site. Override le système de statics de Django.
|
||||
Ajoute l'intégration du scss et de webpack
|
||||
Ajoute l'intégration du scss et du bundler js
|
||||
de manière transparente pour l'utilisateur.
|
||||
24. Fichier de configuration de coverage.
|
||||
25. Fichier de configuration de direnv.
|
||||
@ -178,7 +178,7 @@ comme suit :
|
||||
├── templates/ (2)
|
||||
│ └── ...
|
||||
├── static/ (3)
|
||||
│ └── webpack/ (4)
|
||||
│ └── bundled/ (4)
|
||||
│ └── ...
|
||||
├── api.py (5)
|
||||
├── admin.py (6)
|
||||
@ -196,7 +196,7 @@ comme suit :
|
||||
cf. [Gestion des migrations](../howto/migrations.md)
|
||||
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.
|
||||
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
|
||||
6. Fichier de configuration de l'interface d'administration.
|
||||
Ce fichier permet de déclarer les modèles de l'application
|
||||
|
@ -1,21 +1,24 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
export {};
|
||||
|
||||
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
|
||||
* @param {string} 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
|
||||
* @param name Name of the cookie to get
|
||||
* @returns the value of the cookie or null if it does not exist, undefined if not found
|
||||
*/
|
||||
function getCookie(name) {
|
||||
// biome-ignore lint/style/useBlockStatements: <explanation>
|
||||
if (!document.cookie || document.cookie.length === 0) return null;
|
||||
function getCookie(name: string): string | null | undefined {
|
||||
if (!document.cookie || document.cookie.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const found = document.cookie
|
||||
.split(";")
|
||||
@ -27,9 +30,9 @@ function getCookie(name) {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!cookie) {
|
||||
return [];
|
||||
@ -46,31 +49,34 @@ function getStartingItems() {
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("basket", () => ({
|
||||
items: getStartingItems(),
|
||||
items: getStartingItems() as BasketItem[],
|
||||
|
||||
/**
|
||||
* Get the total price of the basket
|
||||
* @returns {number} The total price of the basket
|
||||
*/
|
||||
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
|
||||
* @param {BasketItem} item
|
||||
*/
|
||||
add(item) {
|
||||
add(item: BasketItem) {
|
||||
item.quantity++;
|
||||
this.setCookies();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const index = this.items.findIndex((e) => e.id === itemId);
|
||||
remove(itemId: number) {
|
||||
const index = this.items.findIndex((e: BasketItem) => e.id === itemId);
|
||||
|
||||
if (index < 0) {
|
||||
return;
|
||||
@ -78,7 +84,9 @@ document.addEventListener("alpine:init", () => {
|
||||
this.items[index].quantity -= 1;
|
||||
|
||||
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();
|
||||
},
|
||||
@ -105,19 +113,19 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
/**
|
||||
* Create an item in the basket if it was not already in
|
||||
* @param {number} id The id of the product to add
|
||||
* @param {string} name The name of the product
|
||||
* @param {number} price The unit price of the product
|
||||
* @returns {BasketItem} The created item
|
||||
* @param id The id of the product to add
|
||||
* @param name The name of the product
|
||||
* @param price The unit price of the product
|
||||
* @returns The created item
|
||||
*/
|
||||
createItem(id, name, price) {
|
||||
createItem(id: number, name: string, price: number): BasketItem {
|
||||
const newItem = {
|
||||
id,
|
||||
name,
|
||||
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,
|
||||
};
|
||||
} as BasketItem;
|
||||
|
||||
this.items.push(newItem);
|
||||
this.add(newItem);
|
||||
@ -128,12 +136,12 @@ document.addEventListener("alpine:init", () => {
|
||||
/**
|
||||
* Add an item to the basket.
|
||||
* This is called when the user click on a button in the catalog
|
||||
* @param {number} id The id of the product to add
|
||||
* @param {string} name The name of the product
|
||||
* @param {number} price The unit price of the product
|
||||
* @param id The id of the product to add
|
||||
* @param name The name of the product
|
||||
* @param price The unit price of the product
|
||||
*/
|
||||
addFromCatalog(id, name, price) {
|
||||
let item = this.items.find((e) => e.id === id);
|
||||
addFromCatalog(id: number, name: string, price: number) {
|
||||
let item = this.items.find((e: BasketItem) => e.id === id);
|
||||
|
||||
// if the item is not in the basket, we create it
|
||||
// else we add + 1 to it
|
@ -11,7 +11,7 @@
|
||||
{% block additional_js %}
|
||||
{# This script contains the code to perform requests to manipulate the
|
||||
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 %}
|
||||
|
||||
{% block additional_css %}
|
||||
|
@ -9,6 +9,10 @@
|
||||
<link rel="stylesheet" href="{{ static('election/css/election.scss') }}">
|
||||
{%- endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<script src="{{ static('bundled/vendored/jquery.shorten.min.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3 class="election__title">{{ election.title }}</h3>
|
||||
<p class="election__description">{{ election.description }}</p>
|
||||
@ -197,12 +201,12 @@
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script type="text/javascript">
|
||||
shorten('.role_description', {
|
||||
$('.role_description').shorten({
|
||||
moreText: "{% trans %}Show more{% endtrans %}",
|
||||
lessText: "{% trans %}Show less{% endtrans %}",
|
||||
showChars: 50
|
||||
});
|
||||
shorten('.candidate_program', {
|
||||
$('.candidate_program').shorten({
|
||||
moreText: "{% trans %}Show more{% endtrans %}",
|
||||
lessText: "{% trans %}Show less{% endtrans %}",
|
||||
showChars: 200
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-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"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -23,17 +23,14 @@ msgid "captured.%s"
|
||||
msgstr "capture.%s"
|
||||
|
||||
#: 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"
|
||||
msgstr "Retirer"
|
||||
|
||||
#: 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"
|
||||
msgstr "Vous devez taper %(number)s caractères de plus"
|
||||
|
||||
#: 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"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
@ -113,6 +110,10 @@ msgstr "Activer le plein écran"
|
||||
msgid "Markdown guide"
|
||||
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
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
@ -126,11 +127,9 @@ msgid "Incorrect value"
|
||||
msgstr "Valeur incorrecte"
|
||||
|
||||
#: sas/static/webpack/sas/viewer-index.ts:271
|
||||
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:234
|
||||
msgid "Couldn't moderate picture"
|
||||
msgstr "Il n'a pas été possible de modérer l'image"
|
||||
|
||||
#: sas/static/webpack/sas/viewer-index.ts:284
|
||||
#: staticfiles/generated/webpack/sas/static/webpack/sas/viewer-index.js:248
|
||||
msgid "Couldn't delete picture"
|
||||
msgstr "Il n'a pas été possible de supprimer l'image"
|
||||
|
@ -66,6 +66,7 @@ nav:
|
||||
- Structure du projet: tutorial/structure.md
|
||||
- Gestion des permissions: tutorial/perms.md
|
||||
- Gestion des groupes: tutorial/groups.md
|
||||
- Créer des fragments: tutorial/fragments.md
|
||||
- Etransactions: tutorial/etransaction.md
|
||||
- How-to:
|
||||
- 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",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"compile": "webpack --mode production",
|
||||
"compile-dev": "webpack --mode development",
|
||||
"serve": "webpack --mode development --watch",
|
||||
"analyse-dev": "webpack --config webpack.analyze.config.js --mode development",
|
||||
"analyse-prod": "webpack --config webpack.analyze.config.js --mode production",
|
||||
"compile": "vite build --mode production",
|
||||
"compile-dev": "vite build --mode development",
|
||||
"serve": "vite build --mode development --watch",
|
||||
"analyse-dev": "vite-bundle-visualizer --mode development",
|
||||
"analyse-prod": "vite-bundle-visualizer --mode production",
|
||||
"check": "biome check --write"
|
||||
},
|
||||
"keywords": [],
|
||||
@ -17,28 +17,20 @@
|
||||
"sideEffects": [".css"],
|
||||
"imports": {
|
||||
"#openapi": "./staticfiles/generated/openapi/index.ts",
|
||||
"#core:*": "./core/static/webpack/*",
|
||||
"#pedagogy:*": "./pedagogy/static/webpack/*"
|
||||
"#core:*": "./core/static/bundled/*",
|
||||
"#pedagogy:*": "./pedagogy/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@biomejs/biome": "1.9.3",
|
||||
"@hey-api/openapi-ts": "^0.53.8",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.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"
|
||||
"vite": "^5.4.11",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-static-copy": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
@ -54,6 +46,7 @@
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.18.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
"jquery-ui": "^1.14.0",
|
||||
"jquery.shorten": "^1.0.0",
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% block head %}
|
||||
|
@ -123,7 +123,7 @@ def merge_users(u1: User, u2: User) -> User:
|
||||
c_dest, created = Customer.get_or_create(u1)
|
||||
c_src.refillings.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:
|
||||
# swap the account numbers, so that the user keep
|
||||
# the id he is accustomed to
|
||||
|
@ -6,7 +6,7 @@
|
||||
{%- endblock -%}
|
||||
|
||||
{%- 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 -%}
|
||||
|
||||
{% block title %}
|
||||
|
@ -1,14 +1,14 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{%- 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('sas/css/picture.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script defer src="{{ static('webpack/core/components/ajax-select-index.ts') }}"></script>
|
||||
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
|
||||
<script type="module" src="{{ static('bundled/core/components/ajax-select-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static("bundled/sas/viewer-index.ts") }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
|
@ -1,13 +1,10 @@
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from core.views.widgets.select import (
|
||||
AutoCompleteSelect,
|
||||
AutoCompleteSelectMultiple,
|
||||
)
|
||||
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||
from sas.models import Album
|
||||
from sas.schemas import AlbumSchema
|
||||
|
||||
_js = ["webpack/sas/components/ajax-select-index.ts"]
|
||||
_js = ["bundled/sas/components/ajax-select-index.ts"]
|
||||
|
||||
|
||||
class AutoCompleteSelectAlbum(AutoCompleteSelect):
|
||||
|
@ -95,7 +95,6 @@ INSTALLED_APPS = (
|
||||
"com",
|
||||
"election",
|
||||
"forum",
|
||||
"stock",
|
||||
"trombi",
|
||||
"matmat",
|
||||
"pedagogy",
|
||||
@ -370,6 +369,8 @@ SITH_CLUB_REFOUND_ID = 89
|
||||
SITH_COUNTER_REFOUND_ID = 38
|
||||
SITH_PRODUCT_REFOUND_ID = 5
|
||||
|
||||
SITH_COUNTER_ACCOUNT_DUMP_ID = 39
|
||||
|
||||
# Pages
|
||||
SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"
|
||||
|
||||
|
@ -3,13 +3,15 @@ from pathlib import Path
|
||||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||
|
||||
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_TYPESCRIPT = ["*.ts"]
|
||||
IGNORE_PATTERNS = [
|
||||
*StaticFilesConfig.ignore_patterns,
|
||||
*IGNORE_PATTERNS_TYPESCRIPT,
|
||||
*IGNORE_PATTERNS_WEBPACK,
|
||||
*IGNORE_PATTERNS_BUNDLED,
|
||||
*IGNORE_PATTERNS_SCSS,
|
||||
]
|
||||
|
||||
@ -25,7 +27,7 @@ class StaticFilesConfig(StaticFilesConfig):
|
||||
"""
|
||||
Application in charge of processing statics files.
|
||||
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
|
||||
when using the development server.
|
||||
"""
|
||||
|
@ -4,7 +4,7 @@ from django.contrib.staticfiles import utils
|
||||
from django.contrib.staticfiles.finders import FileSystemFinder
|
||||
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):
|
||||
@ -27,9 +27,9 @@ class GeneratedFilesFinder(FileSystemFinder):
|
||||
continue
|
||||
|
||||
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:
|
||||
ignored = list(set(ignored) - set(IGNORE_PATTERNS_WEBPACK))
|
||||
ignored = list(set(ignored) - set(IGNORE_PATTERNS_BUNDLED))
|
||||
|
||||
storage = self.storages[root]
|
||||
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.processors import OpenApi, Scss, Webpack
|
||||
from staticfiles.processors import JSBundler, OpenApi, Scss
|
||||
|
||||
|
||||
class Command(CollectStatic):
|
||||
"""Integrate webpack and css compilation to collectstatic"""
|
||||
"""Integrate js bundling and css compilation to collectstatic"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
super().add_arguments(parser)
|
||||
@ -50,8 +50,8 @@ class Command(CollectStatic):
|
||||
return Path(location)
|
||||
|
||||
Scss.compile(self.collect_scss())
|
||||
OpenApi.compile() # This needs to be prior to webpack
|
||||
Webpack.compile()
|
||||
OpenApi.compile() # This needs to be prior to javascript bundling
|
||||
JSBundler.compile()
|
||||
|
||||
collected = super().collect()
|
||||
|
||||
|
@ -6,19 +6,19 @@ from django.contrib.staticfiles.management.commands.runserver import (
|
||||
)
|
||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||
|
||||
from staticfiles.processors import OpenApi, Webpack
|
||||
from staticfiles.processors import JSBundler, OpenApi
|
||||
|
||||
|
||||
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):
|
||||
# OpenApi generation needs to be before webpack
|
||||
# OpenApi generation needs to be before the bundler
|
||||
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
|
||||
if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and settings.DEBUG:
|
||||
with Webpack.runserver():
|
||||
with JSBundler.runserver():
|
||||
super().run(**options)
|
||||
return
|
||||
super().run(**options)
|
||||
|
@ -1,33 +1,120 @@
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha1
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from typing import Iterable, Self
|
||||
|
||||
import rjsmin
|
||||
import sass
|
||||
from django.conf import settings
|
||||
|
||||
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
|
||||
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.wait()
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"Webpack failed with returncode {process.returncode}")
|
||||
raise RuntimeError(f"Bundler failed with returncode {process.returncode}")
|
||||
|
||||
@staticmethod
|
||||
def runserver() -> subprocess.Popen:
|
||||
"""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"])
|
||||
|
||||
@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:
|
||||
@dataclass
|
||||
@ -69,7 +156,7 @@ class JS:
|
||||
p
|
||||
for p in settings.STATIC_ROOT.rglob("*.js")
|
||||
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:
|
||||
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