mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 06:03:20 +00:00
Merge pull request #875 from ae-utbm/taiste
Send mail to inactive users, fix user accounts and webpack sas
This commit is contained in:
commit
19e21c80df
@ -1,7 +1,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
@ -11,13 +12,13 @@ from subscription.models import Subscription
|
|||||||
|
|
||||||
active_subscription = Recipe(
|
active_subscription = Recipe(
|
||||||
Subscription,
|
Subscription,
|
||||||
subscription_start=now() - timedelta(days=30),
|
subscription_start=localdate() - timedelta(days=30),
|
||||||
subscription_end=now() + timedelta(days=30),
|
subscription_end=localdate() + timedelta(days=30),
|
||||||
)
|
)
|
||||||
ended_subscription = Recipe(
|
ended_subscription = Recipe(
|
||||||
Subscription,
|
Subscription,
|
||||||
subscription_start=now() - timedelta(days=60),
|
subscription_start=localdate() - timedelta(days=60),
|
||||||
subscription_end=now() - timedelta(days=30),
|
subscription_end=localdate() - timedelta(days=30),
|
||||||
)
|
)
|
||||||
|
|
||||||
subscriber_user = Recipe(
|
subscriber_user = Recipe(
|
||||||
@ -36,6 +37,17 @@ old_subscriber_user = Recipe(
|
|||||||
)
|
)
|
||||||
"""A user with an ended subscription."""
|
"""A user with an ended subscription."""
|
||||||
|
|
||||||
|
__inactivity = localdate() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
|
||||||
|
very_old_subscriber_user = old_subscriber_user.extend(
|
||||||
|
subscriptions=related(
|
||||||
|
ended_subscription.extend(
|
||||||
|
subscription_start=__inactivity - relativedelta(months=6, days=1),
|
||||||
|
subscription_end=__inactivity - relativedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""A user which subscription ended enough time ago to be considered as inactive."""
|
||||||
|
|
||||||
ae_board_membership = Recipe(
|
ae_board_membership = Recipe(
|
||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
|
15
core/migrations/0039_alter_user_managers.py
Normal file
15
core/migrations/0039_alter_user_managers.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-06 14:52
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0038_alter_preferences_receive_weekmail")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name="user", managers=[("objects", core.models.CustomUserManager())]
|
||||||
|
)
|
||||||
|
]
|
105
core/models.py
105
core/models.py
@ -27,15 +27,12 @@ import importlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import date, timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Any, Optional, Self
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import AbstractBaseUser, UserManager
|
||||||
AbstractBaseUser,
|
|
||||||
UserManager,
|
|
||||||
)
|
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
AnonymousUser as AuthAnonymousUser,
|
AnonymousUser as AuthAnonymousUser,
|
||||||
)
|
)
|
||||||
@ -51,15 +48,18 @@ from django.core.cache import cache
|
|||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
|
from django.utils.timezone import localdate, now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
from pydantic.v1 import NonNegativeInt
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
|
||||||
|
|
||||||
@ -91,15 +91,15 @@ class Group(AuthGroup):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("core:group_list")
|
return reverse("core:group_list")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs) -> None:
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
cache.set(f"sith_group_{self.id}", self)
|
cache.set(f"sith_group_{self.id}", self)
|
||||||
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
|
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs) -> None:
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
cache.delete(f"sith_group_{self.id}")
|
cache.delete(f"sith_group_{self.id}")
|
||||||
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
|
||||||
@ -164,9 +164,9 @@ class RealGroup(Group):
|
|||||||
proxy = True
|
proxy = True
|
||||||
|
|
||||||
|
|
||||||
def validate_promo(value):
|
def validate_promo(value: int) -> None:
|
||||||
start_year = settings.SITH_SCHOOL_START_YEAR
|
start_year = settings.SITH_SCHOOL_START_YEAR
|
||||||
delta = (date.today() + timedelta(days=180)).year - start_year
|
delta = (localdate() + timedelta(days=180)).year - start_year
|
||||||
if value < 0 or delta < value:
|
if value < 0 or delta < value:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
|
_("%(value)s is not a valid promo (between 0 and %(end)s)"),
|
||||||
@ -174,7 +174,7 @@ def validate_promo(value):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
|
def get_group(*, pk: int = None, name: str = None) -> Group | None:
|
||||||
"""Search for a group by its primary key or its name.
|
"""Search for a group by its primary key or its name.
|
||||||
Either one of the two must be set.
|
Either one of the two must be set.
|
||||||
|
|
||||||
@ -216,6 +216,31 @@ def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
class UserQuerySet(models.QuerySet):
|
||||||
|
def filter_inactive(self) -> Self:
|
||||||
|
from counter.models import Refilling, Selling
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
threshold = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
|
||||||
|
subscriptions = Subscription.objects.filter(
|
||||||
|
member_id=OuterRef("pk"), subscription_end__gt=localdate(threshold)
|
||||||
|
)
|
||||||
|
refills = Refilling.objects.filter(
|
||||||
|
customer__user_id=OuterRef("pk"), date__gt=threshold
|
||||||
|
)
|
||||||
|
purchases = Selling.objects.filter(
|
||||||
|
customer__user_id=OuterRef("pk"), date__gt=threshold
|
||||||
|
)
|
||||||
|
return self.exclude(
|
||||||
|
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
|
||||||
|
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser):
|
class User(AbstractBaseUser):
|
||||||
"""Defines the base user class, useable in every app.
|
"""Defines the base user class, useable in every app.
|
||||||
|
|
||||||
@ -373,36 +398,41 @@ class User(AbstractBaseUser):
|
|||||||
)
|
)
|
||||||
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
||||||
|
|
||||||
objects = UserManager()
|
objects = CustomUserManager()
|
||||||
|
|
||||||
USERNAME_FIELD = "username"
|
USERNAME_FIELD = "username"
|
||||||
|
|
||||||
def promo_has_logo(self):
|
|
||||||
return Path(
|
|
||||||
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
def has_module_perms(self, package_name):
|
|
||||||
return self.is_active
|
|
||||||
|
|
||||||
def has_perm(self, perm, obj=None):
|
|
||||||
return self.is_active and self.is_superuser
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse("core:user_profile", kwargs={"user_id": self.pk})
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.get_display_name()
|
return self.get_display_name()
|
||||||
|
|
||||||
def to_dict(self):
|
def save(self, *args, **kwargs):
|
||||||
return self.__dict__
|
with transaction.atomic():
|
||||||
|
if self.id:
|
||||||
|
old = User.objects.filter(id=self.id).first()
|
||||||
|
if old and old.username != self.username:
|
||||||
|
self._change_username(self.username)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("core:user_profile", kwargs={"user_id": self.pk})
|
||||||
|
|
||||||
|
def promo_has_logo(self) -> bool:
|
||||||
|
return Path(
|
||||||
|
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def has_module_perms(self, package_name: str) -> bool:
|
||||||
|
return self.is_active
|
||||||
|
|
||||||
|
def has_perm(self, perm: str, obj: Any = None) -> bool:
|
||||||
|
return self.is_active and self.is_superuser
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def was_subscribed(self):
|
def was_subscribed(self) -> bool:
|
||||||
return self.subscriptions.exists()
|
return self.subscriptions.exists()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_subscribed(self):
|
def is_subscribed(self) -> bool:
|
||||||
s = self.subscriptions.filter(
|
s = self.subscriptions.filter(
|
||||||
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
|
||||||
)
|
)
|
||||||
@ -542,17 +572,6 @@ class User(AbstractBaseUser):
|
|||||||
)
|
)
|
||||||
return age
|
return age
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
create = False
|
|
||||||
with transaction.atomic():
|
|
||||||
if self.id:
|
|
||||||
old = User.objects.filter(id=self.id).first()
|
|
||||||
if old and old.username != self.username:
|
|
||||||
self._change_username(self.username)
|
|
||||||
else:
|
|
||||||
create = True
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def make_home(self):
|
def make_home(self):
|
||||||
if self.home is None:
|
if self.home is None:
|
||||||
home_root = SithFile.objects.filter(parent=None, name="users").first()
|
home_root = SithFile.objects.filter(parent=None, name="users").first()
|
||||||
|
@ -74,52 +74,3 @@ function displayNotif() {
|
|||||||
function getCSRFToken() {
|
function getCSRFToken() {
|
||||||
return $("[name=csrfmiddlewaretoken]").val();
|
return $("[name=csrfmiddlewaretoken]").val();
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
|
|
||||||
const initialUrlParams = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @readonly
|
|
||||||
* @enum {number}
|
|
||||||
*/
|
|
||||||
const History = {
|
|
||||||
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
|
|
||||||
NONE: 0,
|
|
||||||
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
|
|
||||||
PUSH: 1,
|
|
||||||
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
|
|
||||||
REPLACE: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} key
|
|
||||||
* @param {string | string[] | null} value
|
|
||||||
* @param {History} action
|
|
||||||
* @param {URL | null} url
|
|
||||||
*/
|
|
||||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
|
|
||||||
function updateQueryString(key, value, action = History.REPLACE, url = null) {
|
|
||||||
let ret = url;
|
|
||||||
if (!ret) {
|
|
||||||
ret = new URL(window.location.href);
|
|
||||||
}
|
|
||||||
if (value === undefined || value === null || value === "") {
|
|
||||||
// If the value is null, undefined or empty => delete it
|
|
||||||
ret.searchParams.delete(key);
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
ret.searchParams.delete(key);
|
|
||||||
for (const v of value) {
|
|
||||||
ret.searchParams.append(key, v);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ret.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === History.PUSH) {
|
|
||||||
window.history.pushState(null, "", ret.toString());
|
|
||||||
} else if (action === History.REPLACE) {
|
|
||||||
window.history.replaceState(null, "", ret.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||||
import cytoscape from "cytoscape";
|
import cytoscape from "cytoscape";
|
||||||
import cxtmenu from "cytoscape-cxtmenu";
|
import cxtmenu from "cytoscape-cxtmenu";
|
||||||
import klay from "cytoscape-klay";
|
import klay from "cytoscape-klay";
|
||||||
@ -184,7 +185,6 @@ window.loadFamilyGraph = (config) => {
|
|||||||
const defaultDepth = 2;
|
const defaultDepth = 2;
|
||||||
|
|
||||||
function getInitialDepth(prop) {
|
function getInitialDepth(prop) {
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
|
|
||||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||||
return defaultDepth;
|
return defaultDepth;
|
||||||
@ -196,7 +196,6 @@ window.loadFamilyGraph = (config) => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
godfathersDepth: getInitialDepth("godfathersDepth"),
|
godfathersDepth: getInitialDepth("godfathersDepth"),
|
||||||
godchildrenDepth: getInitialDepth("godchildrenDepth"),
|
godchildrenDepth: getInitialDepth("godchildrenDepth"),
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
|
|
||||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||||
graph: undefined,
|
graph: undefined,
|
||||||
graphData: {},
|
graphData: {},
|
||||||
@ -210,14 +209,12 @@ window.loadFamilyGraph = (config) => {
|
|||||||
if (value < config.depthMin || value > config.depthMax) {
|
if (value < config.depthMin || value > config.depthMax) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
|
updateQueryString(param, value, History.Replace);
|
||||||
updateQueryString(param, value, History.REPLACE);
|
|
||||||
await delayedFetch();
|
await delayedFetch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.$watch("reverse", async (value) => {
|
this.$watch("reverse", async (value) => {
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
|
updateQueryString("reverse", value, History.Replace);
|
||||||
updateQueryString("reverse", value, History.REPLACE);
|
|
||||||
await this.reverseGraph();
|
await this.reverseGraph();
|
||||||
});
|
});
|
||||||
this.$watch("graphData", async () => {
|
this.$watch("graphData", async () => {
|
||||||
|
@ -27,10 +27,12 @@ export const paginated = async <T>(
|
|||||||
options?: PaginatedRequest,
|
options?: PaginatedRequest,
|
||||||
) => {
|
) => {
|
||||||
const maxPerPage = 199;
|
const maxPerPage = 199;
|
||||||
options.query.page_size = maxPerPage;
|
const queryParams = options ?? {};
|
||||||
options.query.page = 1;
|
queryParams.query = queryParams.query ?? {};
|
||||||
|
queryParams.query.page_size = maxPerPage;
|
||||||
|
queryParams.query.page = 1;
|
||||||
|
|
||||||
const firstPage = (await endpoint(options)).data;
|
const firstPage = (await endpoint(queryParams)).data;
|
||||||
const results = firstPage.results;
|
const results = firstPage.results;
|
||||||
|
|
||||||
const nbElements = firstPage.count;
|
const nbElements = firstPage.count;
|
||||||
@ -39,7 +41,7 @@ export const paginated = async <T>(
|
|||||||
if (nbPages > 1) {
|
if (nbPages > 1) {
|
||||||
const promises: Promise<T[]>[] = [];
|
const promises: Promise<T[]>[] = [];
|
||||||
for (let i = 2; i <= nbPages; i++) {
|
for (let i = 2; i <= nbPages; i++) {
|
||||||
const nextPage = structuredClone(options);
|
const nextPage = structuredClone(queryParams);
|
||||||
nextPage.query.page = i;
|
nextPage.query.page = i;
|
||||||
promises.push(endpoint(nextPage).then((res) => res.data.results));
|
promises.push(endpoint(nextPage).then((res) => res.data.results));
|
||||||
}
|
}
|
||||||
|
40
core/static/webpack/utils/history.ts
Normal file
40
core/static/webpack/utils/history.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
export enum History {
|
||||||
|
None = 0,
|
||||||
|
Push = 1,
|
||||||
|
Replace = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialUrlParams = new URLSearchParams(window.location.search);
|
||||||
|
export const getCurrentUrlParams = () => {
|
||||||
|
return new URLSearchParams(window.location.search);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function updateQueryString(
|
||||||
|
key: string,
|
||||||
|
value?: string | string[],
|
||||||
|
action?: History,
|
||||||
|
url?: string,
|
||||||
|
) {
|
||||||
|
const historyAction = action ?? History.Replace;
|
||||||
|
const ret = new URL(url ?? window.location.href);
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
// If the value is null, undefined or empty => delete it
|
||||||
|
ret.searchParams.delete(key);
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
ret.searchParams.delete(key);
|
||||||
|
for (const v of value) {
|
||||||
|
ret.searchParams.append(key, v);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ret.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyAction === History.Push) {
|
||||||
|
window.history.pushState(null, "", ret.toString());
|
||||||
|
} else if (historyAction === History.Replace) {
|
||||||
|
window.history.replaceState(null, "", ret.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
@ -1,15 +1,22 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker, seq
|
from model_bakery import baker, seq
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe, foreign_key
|
||||||
|
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import (
|
||||||
|
old_subscriber_user,
|
||||||
|
subscriber_user,
|
||||||
|
very_old_subscriber_user,
|
||||||
|
)
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
from counter.models import Counter, Refilling, Selling
|
||||||
|
from eboutic.models import Invoice, InvoiceItem
|
||||||
|
|
||||||
|
|
||||||
class TestSearchUsers(TestCase):
|
class TestSearchUsers(TestCase):
|
||||||
@ -111,3 +118,50 @@ def test_user_account_not_found(client: Client):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterInactive(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
time_active = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + timedelta(days=1)
|
||||||
|
time_inactive = time_active - timedelta(days=3)
|
||||||
|
counter, seller = baker.make(Counter), baker.make(User)
|
||||||
|
sale_recipe = Recipe(
|
||||||
|
Selling,
|
||||||
|
counter=counter,
|
||||||
|
club=counter.club,
|
||||||
|
seller=seller,
|
||||||
|
is_validated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.users = [
|
||||||
|
baker.make(User),
|
||||||
|
subscriber_user.make(),
|
||||||
|
old_subscriber_user.make(),
|
||||||
|
*very_old_subscriber_user.make(_quantity=3),
|
||||||
|
]
|
||||||
|
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
|
||||||
|
baker.make(
|
||||||
|
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
|
||||||
|
)
|
||||||
|
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
|
||||||
|
|
||||||
|
def test_filter_inactive(self):
|
||||||
|
res = User.objects.filter(id__in=[u.id for u in self.users]).filter_inactive()
|
||||||
|
assert list(res) == [self.users[0], self.users[5]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_user_invoice_with_multiple_items():
|
||||||
|
"""Test that annotate_total() works when invoices contain multiple items."""
|
||||||
|
user: User = subscriber_user.make()
|
||||||
|
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
|
||||||
|
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
|
||||||
|
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
|
||||||
|
res = list(
|
||||||
|
Invoice.objects.filter(user=user)
|
||||||
|
.annotate_total()
|
||||||
|
.order_by("-total")
|
||||||
|
.values_list("total", flat=True)
|
||||||
|
)
|
||||||
|
assert res == [15, 5]
|
||||||
|
@ -49,6 +49,18 @@ class BillingInfoAdmin(admin.ModelAdmin):
|
|||||||
autocomplete_fields = ("customer",)
|
autocomplete_fields = ("customer",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AccountDump)
|
||||||
|
class AccountDumpAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"customer",
|
||||||
|
"warning_mail_sent_at",
|
||||||
|
"warning_mail_error",
|
||||||
|
"dump_operation",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("customer",)
|
||||||
|
list_filter = ("warning_mail_error",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Counter)
|
@admin.register(Counter)
|
||||||
class CounterAdmin(admin.ModelAdmin):
|
class CounterAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "club", "type")
|
list_display = ("name", "club", "type")
|
||||||
|
0
counter/management/__init__.py
Normal file
0
counter/management/__init__.py
Normal file
0
counter/management/commands/__init__.py
Normal file
0
counter/management/commands/__init__.py
Normal file
91
counter/management/commands/dump_warning_mail.py
Normal file
91
counter/management/commands/dump_warning_mail.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import logging
|
||||||
|
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.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 counter.models import AccountDump
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Send mail to inactive users, warning them that their account is about to be dumped.
|
||||||
|
|
||||||
|
This command should be automated with a cron task.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.logger = logging.getLogger("account_dump_mail")
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
users = list(self._get_users())
|
||||||
|
self.stdout.write(f"{len(users)} users will be warned of their account dump")
|
||||||
|
dumps = []
|
||||||
|
for user in users:
|
||||||
|
is_success = self._send_mail(user)
|
||||||
|
dumps.append(
|
||||||
|
AccountDump(
|
||||||
|
customer_id=user.id,
|
||||||
|
warning_mail_sent_at=now(),
|
||||||
|
warning_mail_error=not is_success,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountDump.objects.bulk_create(dumps)
|
||||||
|
self.stdout.write("Finished !")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_users() -> QuerySet[User]:
|
||||||
|
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
|
||||||
|
customer__user=OuterRef("pk")
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
User.objects.filter_inactive()
|
||||||
|
.filter(customer__amount__gt=0)
|
||||||
|
.exclude(Exists(ongoing_dump_operation))
|
||||||
|
.annotate(
|
||||||
|
last_subscription_date=Subquery(
|
||||||
|
Subscription.objects.filter(member=OuterRef("pk"))
|
||||||
|
.order_by("-subscription_end")
|
||||||
|
.values("subscription_end")[:1]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.select_related("customer")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_mail(self, user: User) -> bool:
|
||||||
|
"""Send the warning email to the given user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the mail was successfully sent, else False
|
||||||
|
"""
|
||||||
|
message = render_to_string(
|
||||||
|
"counter/account_dump_warning_mail.jinja",
|
||||||
|
{
|
||||||
|
"balance": user.customer.amount,
|
||||||
|
"last_subscription_date": user.last_subscription_date,
|
||||||
|
"dump_date": localdate() + settings.SITH_ACCOUNT_DUMP_DELTA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# sending mails one by one is long and ineffective,
|
||||||
|
# but it makes easier to know which emails failed (and how).
|
||||||
|
# Also, there won't be that much mails sent (except on the first run)
|
||||||
|
send_mail(
|
||||||
|
_("Clearing of your AE account"),
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[user.email],
|
||||||
|
)
|
||||||
|
self.logger.info(f"Mail successfully sent to {user.email}")
|
||||||
|
return True
|
||||||
|
except SMTPException as e:
|
||||||
|
self.logger.error(f"failed mail to {user.email} :\n{e}")
|
||||||
|
return False
|
@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 4.2.16 on 2024-10-06 14:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0023_billinginfo_phone_number")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AccountDump",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"warning_mail_sent_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
help_text="When the mail warning that the account was about to be dumped was sent."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"warning_mail_error",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Set this to True if the warning mail received an error",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="dumps",
|
||||||
|
to="counter.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dump_operation",
|
||||||
|
models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The operation that emptied the account.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="counter.selling",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="accountdump",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("dump_operation", None)),
|
||||||
|
fields=("customer",),
|
||||||
|
name="unique_ongoing_dump",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -26,7 +26,7 @@ from dict2xml import dict2xml
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, F, OuterRef, QuerySet, Sum, Value
|
from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value
|
||||||
from django.db.models.functions import Concat, Length
|
from django.db.models.functions import Concat, Length
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -211,6 +211,51 @@ class BillingInfo(models.Model):
|
|||||||
return '<?xml version="1.0" encoding="UTF-8" ?>' + xml
|
return '<?xml version="1.0" encoding="UTF-8" ?>' + xml
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDumpQuerySet(models.QuerySet):
|
||||||
|
def ongoing(self) -> Self:
|
||||||
|
"""Filter dump operations that are not completed yet."""
|
||||||
|
return self.filter(dump_operation=None)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountDump(models.Model):
|
||||||
|
"""The process of dumping an account."""
|
||||||
|
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
Customer, related_name="dumps", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
warning_mail_sent_at = models.DateTimeField(
|
||||||
|
help_text=_(
|
||||||
|
"When the mail warning that the account was about to be dumped was sent."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
warning_mail_error = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_("Set this to True if the warning mail received an error"),
|
||||||
|
)
|
||||||
|
dump_operation = models.OneToOneField(
|
||||||
|
"Selling",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
help_text=_("The operation that emptied the account."),
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = AccountDumpQuerySet.as_manager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["customer"],
|
||||||
|
condition=Q(dump_operation=None),
|
||||||
|
name="unique_ongoing_dump",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = "ongoing" if self.dump_operation is None else "finished"
|
||||||
|
return f"{self.customer} - {status}"
|
||||||
|
|
||||||
|
|
||||||
class ProductType(models.Model):
|
class ProductType(models.Model):
|
||||||
"""A product type.
|
"""A product type.
|
||||||
|
|
||||||
|
43
counter/templates/counter/account_dump_warning_mail.jinja
Normal file
43
counter/templates/counter/account_dump_warning_mail.jinja
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<p>
|
||||||
|
Bonjour,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{%- trans date=last_subscription_date|date(DATETIME_FORMAT) -%}
|
||||||
|
You received this email because your last subscription to the
|
||||||
|
Students' association ended on {{ date }}.
|
||||||
|
{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{%- trans date=dump_date|date(DATETIME_FORMAT), amount=balance -%}
|
||||||
|
In accordance with the Internal Regulations, the balance of any
|
||||||
|
inactive AE account for more than 2 years automatically goes back
|
||||||
|
to the AE.
|
||||||
|
The money present on your account will therefore be recovered in full
|
||||||
|
on {{ date }}, for a total of {{ amount }} €.
|
||||||
|
{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{%- trans -%}However, if your subscription is renewed by this date,
|
||||||
|
your right to keep the money in your AE account will be renewed.{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if balance >= 10 %}
|
||||||
|
<p>
|
||||||
|
{%- trans -%}You can also request a refund by sending an email to
|
||||||
|
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a>
|
||||||
|
before the aforementioned date.{%- endtrans -%}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% trans %}Sincerely{% endtrans %},
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
L'association des étudiants de l'UTBM <br>
|
||||||
|
6, Boulevard Anatole France <br>
|
||||||
|
90000 Belfort
|
||||||
|
</p>
|
65
counter/tests/test_account_dump.py
Normal file
65
counter/tests/test_account_dump.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import mail
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils.timezone import now
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestAccountDumpWarningMailCommand(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
# delete existing customers to avoid side effect
|
||||||
|
Customer.objects.all().delete()
|
||||||
|
refill_recipe = Recipe(Refilling, amount=10)
|
||||||
|
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
|
||||||
|
inactive_date = (
|
||||||
|
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
|
||||||
|
)
|
||||||
|
refill_recipe.make(
|
||||||
|
customer=(u.customer for u in cls.notified_users),
|
||||||
|
date=inactive_date,
|
||||||
|
_quantity=len(cls.notified_users),
|
||||||
|
)
|
||||||
|
cls.not_notified_users = [
|
||||||
|
subscriber_user.make(),
|
||||||
|
very_old_subscriber_user.make(), # inactive, but account already empty
|
||||||
|
very_old_subscriber_user.make(), # inactive, but with a recent transaction
|
||||||
|
very_old_subscriber_user.make(), # inactive, but already warned
|
||||||
|
]
|
||||||
|
refill_recipe.make(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
baker.make(
|
||||||
|
AccountDump,
|
||||||
|
customer=cls.not_notified_users[3].customer,
|
||||||
|
dump_operation=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_selection(self):
|
||||||
|
"""Test that the user to warn are well selected."""
|
||||||
|
users = list(Command._get_users())
|
||||||
|
assert len(users) == 3
|
||||||
|
assert set(users) == set(self.notified_users)
|
||||||
|
|
||||||
|
def test_command(self):
|
||||||
|
"""The actual command test."""
|
||||||
|
call_command("dump_warning_mail")
|
||||||
|
# 1 already existing + 3 new account dump objects
|
||||||
|
assert AccountDump.objects.count() == 4
|
||||||
|
sent_mails = list(mail.outbox)
|
||||||
|
assert len(sent_mails) == 3
|
||||||
|
target_emails = {u.email for u in self.notified_users}
|
||||||
|
for sent in sent_mails:
|
||||||
|
assert len(sent.to) == 1
|
||||||
|
assert sent.to[0] in target_emails
|
@ -16,6 +16,7 @@ import re
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from datetime import timezone as tz
|
from datetime import timezone as tz
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@ -49,7 +50,6 @@ from django.views.generic.edit import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from accounting.models import CurrencyField
|
from accounting.models import CurrencyField
|
||||||
from core.models import User
|
|
||||||
from core.utils import get_semester_code, get_start_of_semester
|
from core.utils import get_semester_code, get_start_of_semester
|
||||||
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
|
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
|
||||||
from core.views.forms import LoginForm
|
from core.views.forms import LoginForm
|
||||||
@ -78,6 +78,9 @@ from counter.models import (
|
|||||||
)
|
)
|
||||||
from counter.utils import is_logged_in_counter
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class CounterAdminMixin(View):
|
class CounterAdminMixin(View):
|
||||||
"""Protect counter admin section."""
|
"""Protect counter admin section."""
|
||||||
|
@ -187,8 +187,8 @@ que sont VsCode et Sublime Text.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"editor.defaultFormatter": "<other formatter>",
|
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -167,10 +167,15 @@ class InvoiceQueryset(models.QuerySet):
|
|||||||
The total amount is the sum of (product_unit_price * quantity)
|
The total amount is the sum of (product_unit_price * quantity)
|
||||||
for all items related to the invoice.
|
for all items related to the invoice.
|
||||||
"""
|
"""
|
||||||
|
# aggregates within subqueries require a little bit of black magic,
|
||||||
|
# but hopefully, django gives a comprehensive documentation for that :
|
||||||
|
# https://docs.djangoproject.com/en/stable/ref/models/expressions/#using-aggregates-within-a-subquery-expression
|
||||||
return self.annotate(
|
return self.annotate(
|
||||||
total=Subquery(
|
total=Subquery(
|
||||||
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
|
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
|
||||||
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
|
.annotate(item_amount=F("product_unit_price") * F("quantity"))
|
||||||
|
.values("item_amount")
|
||||||
|
.annotate(total=Sum("item_amount"))
|
||||||
.values("total")
|
.values("total")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
|||||||
|
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
|
||||||
import { uvFetchUvList } from "#openapi";
|
import { uvFetchUvList } from "#openapi";
|
||||||
|
|
||||||
const pageDefault = 1;
|
const pageDefault = 1;
|
||||||
@ -22,13 +23,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
semester: [],
|
semester: [],
|
||||||
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
// biome-ignore lint/style/useNamingConvention: api is in snake_case
|
||||||
to_change: [],
|
to_change: [],
|
||||||
pushstate: History.PUSH,
|
pushstate: History.Push,
|
||||||
|
|
||||||
update: undefined,
|
update: undefined,
|
||||||
|
|
||||||
initializeArgs() {
|
initializeArgs() {
|
||||||
const url = new URLSearchParams(window.location.search);
|
const url = getCurrentUrlParams();
|
||||||
this.pushstate = History.REPLACE;
|
this.pushstate = History.Replace;
|
||||||
|
|
||||||
this.page = Number.parseInt(url.get("page")) || pageDefault;
|
this.page = Number.parseInt(url.get("page")) || pageDefault;
|
||||||
this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault;
|
this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault;
|
||||||
@ -47,17 +48,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.update = Alpine.debounce(async () => {
|
this.update = Alpine.debounce(async () => {
|
||||||
/* Create the whole url before changing everything all at once */
|
/* Create the whole url before changing everything all at once */
|
||||||
const first = this.to_change.shift();
|
const first = this.to_change.shift();
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
|
let url = updateQueryString(first.param, first.value, History.None);
|
||||||
let url = updateQueryString(first.param, first.value, History.NONE);
|
|
||||||
for (const value of this.to_change) {
|
for (const value of this.to_change) {
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
|
url = updateQueryString(value.param, value.value, History.None, url);
|
||||||
url = updateQueryString(value.param, value.value, History.NONE, url);
|
|
||||||
}
|
}
|
||||||
// biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js
|
|
||||||
updateQueryString(first.param, first.value, this.pushstate, url);
|
updateQueryString(first.param, first.value, this.pushstate, url);
|
||||||
await this.fetchData(); /* reload data on form change */
|
await this.fetchData(); /* reload data on form change */
|
||||||
this.to_change = [];
|
this.to_change = [];
|
||||||
this.pushstate = History.PUSH;
|
this.pushstate = History.Push;
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
const searchParams = ["search", "department", "credit_type", "semester"];
|
const searchParams = ["search", "department", "credit_type", "semester"];
|
||||||
@ -65,7 +63,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
for (const param of searchParams) {
|
for (const param of searchParams) {
|
||||||
this.$watch(param, () => {
|
this.$watch(param, () => {
|
||||||
if (this.pushstate !== History.PUSH) {
|
if (this.pushstate !== History.Push) {
|
||||||
/* This means that we are doing a mass param edit */
|
/* This means that we are doing a mass param edit */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
59
sas/static/webpack/sas/album-index.js
Normal file
59
sas/static/webpack/sas/album-index.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||||
|
import { picturesFetchPictures } from "#openapi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef AlbumConfig
|
||||||
|
* @property {number} albumId id of the album to visualize
|
||||||
|
* @property {number} maxPageSize maximum number of elements to show on a page
|
||||||
|
**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a family graph of an user
|
||||||
|
* @param {AlbumConfig} config
|
||||||
|
**/
|
||||||
|
window.loadAlbum = (config) => {
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("pictures", () => ({
|
||||||
|
pictures: {},
|
||||||
|
page: Number.parseInt(initialUrlParams.get("page")) || 1,
|
||||||
|
pushstate: History.Push /* Used to avoid pushing a state on a back action */,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.fetchPictures();
|
||||||
|
this.$watch("page", () => {
|
||||||
|
updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate);
|
||||||
|
this.pushstate = History.Push;
|
||||||
|
this.fetchPictures();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
this.pushstate = History.Replace;
|
||||||
|
this.page =
|
||||||
|
Number.parseInt(new URLSearchParams(window.location.search).get("page")) ||
|
||||||
|
1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchPictures() {
|
||||||
|
this.loading = true;
|
||||||
|
this.pictures = (
|
||||||
|
await picturesFetchPictures({
|
||||||
|
query: {
|
||||||
|
// biome-ignore lint/style/useNamingConvention: API is in snake_case
|
||||||
|
album_id: config.albumId,
|
||||||
|
page: this.page,
|
||||||
|
// biome-ignore lint/style/useNamingConvention: API is in snake_case
|
||||||
|
page_size: config.maxPageSize,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
nbPages() {
|
||||||
|
return Math.ceil(this.pictures.count / config.maxPageSize);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
@ -5,6 +5,10 @@
|
|||||||
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
|
<link rel="stylesheet" href="{{ static('sas/css/album.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
|
{%- block additional_js -%}
|
||||||
|
<script src="{{ static('webpack/sas/album-index.js') }}" defer></script>
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}SAS{% endtrans %}
|
{% trans %}SAS{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -108,48 +112,14 @@
|
|||||||
{% block script %}
|
{% block script %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("alpine:init", () => {
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
Alpine.data("pictures", () => ({
|
loadAlbum({
|
||||||
pictures: {},
|
albumId: {{ album.id }},
|
||||||
page: parseInt(initialUrlParams.get("page")) || 1,
|
maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }},
|
||||||
pushstate: History.PUSH, /* Used to avoid pushing a state on a back action */
|
});
|
||||||
loading: false,
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
await this.fetchPictures();
|
|
||||||
this.$watch("page", () => {
|
|
||||||
updateQueryString("page",
|
|
||||||
this.page === 1 ? null : this.page,
|
|
||||||
this.pushstate
|
|
||||||
);
|
|
||||||
this.pushstate = History.PUSH;
|
|
||||||
this.fetchPictures();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("popstate", () => {
|
// Todo: migrate to alpine.js if we have some time
|
||||||
this.pushstate = History.REPLACE;
|
|
||||||
this.page = parseInt(
|
|
||||||
new URLSearchParams(window.location.search).get("page")
|
|
||||||
) || 1;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async fetchPictures() {
|
|
||||||
this.loading=true;
|
|
||||||
const url = "{{ url("api:pictures") }}"
|
|
||||||
+"?album_id={{ album.id }}"
|
|
||||||
+`&page=${this.page}`
|
|
||||||
+"&page_size={{ settings.SITH_SAS_IMAGES_PER_PAGE }}";
|
|
||||||
this.pictures = await (await fetch(url)).json();
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
nbPages() {
|
|
||||||
return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
$("form#upload_form").submit(function (event) {
|
$("form#upload_form").submit(function (event) {
|
||||||
let formData = new FormData($(this)[0]);
|
let formData = new FormData($(this)[0]);
|
||||||
|
|
||||||
@ -255,4 +225,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -37,9 +37,11 @@ import binascii
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
@ -228,13 +230,20 @@ LOGGING = {
|
|||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "simple",
|
"formatter": "simple",
|
||||||
},
|
},
|
||||||
|
"dump_mail_file": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": "account_dump_mail.log",
|
||||||
|
"formatter": "simple",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"main": {
|
"main": {
|
||||||
"handlers": ["log_to_stdout"],
|
"handlers": ["log_to_stdout"],
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": True,
|
"propagate": True,
|
||||||
}
|
},
|
||||||
|
"account_dump_mail": {"handlers": ["dump_mail_file", "log_to_stdout"]},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -495,6 +504,11 @@ SITH_ECOCUP_LIMIT = 3
|
|||||||
# Defines pagination for cash summary
|
# Defines pagination for cash summary
|
||||||
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
|
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
|
||||||
|
|
||||||
|
SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
|
||||||
|
"""Time before which a user account is considered inactive"""
|
||||||
|
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
|
||||||
|
"""timedelta between the warning mail and the actual account dump"""
|
||||||
|
|
||||||
# Defines which product type is the refilling type, and thus increases the account amount
|
# Defines which product type is the refilling type, and thus increases the account amount
|
||||||
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
|
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user