Merge pull request #939 from ae-utbm/taiste

`dump_account`, HTMX, Subscriptions and more
This commit is contained in:
thomas girod 2024-12-04 00:10:19 +01:00 committed by GitHub
commit 35c5f96672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
124 changed files with 5479 additions and 5942 deletions

View File

@ -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

View File

@ -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):

View File

@ -1,15 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
}
}
]
]
}

View File

@ -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):

View File

@ -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>

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because it is too large Load Diff

View File

@ -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(

View File

@ -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

View 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);
}
}

View File

@ -0,0 +1 @@
import "@fortawesome/fontawesome-free/css/all.css";

View File

@ -0,0 +1,3 @@
import htmx from "htmx.org";
Object.assign(window, { htmx });

View 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
View 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;
}

View 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);
}
}

View 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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -1 +0,0 @@
require("@fortawesome/fontawesome-free/css/all.css");

View File

@ -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;

View File

@ -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 %}

View File

@ -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') }}')">
&nbsp;
</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 %}
&nbsp;
{% 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>

View 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') }}')">
&nbsp;
</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 %}
&nbsp;
{% 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>

View 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>

View 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 %}

View 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 %}

View File

@ -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 %}

View 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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",
]

View File

@ -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
View 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))
)

View 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)

View File

@ -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,

View File

@ -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.

View 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

View File

@ -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

View File

@ -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):

View 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

View File

@ -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):

View File

@ -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, linteractivité 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.

View File

@ -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/*"]
}
}
}

View File

@ -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:

View 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 %}
```

View File

@ -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

View File

@ -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

View File

@ -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 %}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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 %}

View File

@ -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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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):

View File

@ -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"

View File

@ -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.
"""

View File

@ -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):

View File

@ -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()

View File

@ -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)

View File

@ -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