mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-13 05:19:26 +00:00
Compare commits
70 Commits
counter-ac
...
trombi
Author | SHA1 | Date | |
---|---|---|---|
2a9cbfdf77 | |||
0a460b46b9 | |||
f5d5cc18a8 | |||
4c65939bbe | |||
830c752971 | |||
6bdc1b73ae | |||
0f003870bb | |||
0631c77a1c | |||
2cc4308a58 | |||
4975475e85 | |||
466fe58763 | |||
3b7e338808 | |||
53b13e7aef | |||
fa60ecb25a | |||
a975824481 | |||
c51e5eb6cb | |||
f0bc502ec9 | |||
902cafc5e4 | |||
b2f54aa23e | |||
29a5425259 | |||
e2a34c75ea | |||
de7aa6f6a6 | |||
9acb421b2e | |||
66d2dc74e7 | |||
2f613607af | |||
d4b9c3afb1 | |||
b81cf49d0a | |||
1da45fdffc | |||
10dde3f002 | |||
c2d6af12ab | |||
6e48f88c06 | |||
7a91a71565 | |||
c4764110d8 | |||
ff68e65250 | |||
c9d83e5916 | |||
5dc99dbfcb | |||
8dbec85c8e | |||
84d7e40e66 | |||
0b509f2200 | |||
9591162cc9 | |||
007e17fd8b | |||
95f8e7517c | |||
9667c79162 | |||
1c79c25262 | |||
04b4b34bfe | |||
fc0e689d4e | |||
83bb4b3b12 | |||
8dcfc604a0 | |||
d2d639e5f6 | |||
b3eb7693e3 | |||
10f42b1522 | |||
76e9f3b1dc | |||
d0ff9bc16c | |||
5e4ebd16f9 | |||
d2b19424ff | |||
08286254cd | |||
4805c39b45 | |||
f845bbf20a | |||
71c7158124 | |||
c4643ee52c | |||
b46b0882f3 | |||
1c4efc9431 | |||
4133e0ccdd | |||
de415e7e75 | |||
9d17524f45 | |||
68ad9650af | |||
8d4d8a3abc | |||
9617e29ed5 | |||
75406f7b58 | |||
70f5ae4f9c |
18
.github/workflows/deploy.yml
vendored
18
.github/workflows/deploy.yml
vendored
@ -45,3 +45,21 @@ jobs:
|
|||||||
poetry run ./manage.py compilemessages
|
poetry run ./manage.py compilemessages
|
||||||
|
|
||||||
sudo systemctl restart uwsgi
|
sudo systemctl restart uwsgi
|
||||||
|
|
||||||
|
sentry:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
timeout-minutes: 30
|
||||||
|
needs: deployment
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sentry Release
|
||||||
|
uses: getsentry/action-release@v1.7.0
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
|
with:
|
||||||
|
environment: production
|
@ -1,7 +1,7 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
# Ruff version.
|
# Ruff version.
|
||||||
rev: v0.6.9
|
rev: v0.8.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff # just check the code, and print the errors
|
- id: ruff # just check the code, and print the errors
|
||||||
- id: ruff # actually fix the fixable errors, but print nothing
|
- id: ruff # actually fix the fixable errors, but print nothing
|
||||||
@ -14,7 +14,7 @@ repos:
|
|||||||
- id: biome-check
|
- id: biome-check
|
||||||
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
additional_dependencies: ["@biomejs/biome@1.9.3"]
|
||||||
- repo: https://github.com/rtts/djhtml
|
- repo: https://github.com/rtts/djhtml
|
||||||
rev: 3.0.6
|
rev: 3.0.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: djhtml
|
- id: djhtml
|
||||||
name: format templates
|
name: format templates
|
||||||
|
@ -69,7 +69,7 @@ class Command(BaseCommand):
|
|||||||
# sqlite doesn't support this operation
|
# sqlite doesn't support this operation
|
||||||
return
|
return
|
||||||
sqlcmd = StringIO()
|
sqlcmd = StringIO()
|
||||||
call_command("sqlsequencereset", *args, stdout=sqlcmd)
|
call_command("sqlsequencereset", "--no-color", *args, stdout=sqlcmd)
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
cursor.execute(sqlcmd.getvalue())
|
cursor.execute(sqlcmd.getvalue())
|
||||||
|
|
||||||
@ -137,11 +137,10 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.reset_index("club")
|
self.reset_index("club")
|
||||||
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
|
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||||
|
self.reset_index("counter")
|
||||||
counters = [
|
counters = [
|
||||||
*[
|
|
||||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR")
|
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS
|
|
||||||
],
|
|
||||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||||
|
@ -529,13 +529,15 @@ class User(AbstractBaseUser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def can_create_subscription(self):
|
def can_create_subscription(self) -> bool:
|
||||||
from club.models import Club
|
from club.models import Membership
|
||||||
|
|
||||||
for club in Club.objects.filter(id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS):
|
return (
|
||||||
if club in self.clubs_with_rights:
|
Membership.objects.board()
|
||||||
return True
|
.ongoing()
|
||||||
return False
|
.filter(club_id__in=settings.SITH_CAN_CREATE_SUBSCRIPTIONS)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_launderette_manager(self):
|
def is_launderette_manager(self):
|
||||||
|
@ -4,6 +4,7 @@ from typing import Annotated
|
|||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
from django.contrib.staticfiles.storage import staticfiles_storage
|
from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from haystack.query import SearchQuerySet
|
from haystack.query import SearchQuerySet
|
||||||
from ninja import FilterSchema, ModelSchema, Schema
|
from ninja import FilterSchema, ModelSchema, Schema
|
||||||
@ -37,13 +38,13 @@ class UserProfileSchema(ModelSchema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_profile_url(obj: User) -> str:
|
def resolve_profile_url(obj: User) -> str:
|
||||||
return obj.get_absolute_url()
|
return reverse("core:user_profile", kwargs={"user_id": obj.pk})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_profile_pict(obj: User) -> str:
|
def resolve_profile_pict(obj: User) -> str:
|
||||||
if obj.profile_pict_id is None:
|
if obj.profile_pict_id is None:
|
||||||
return staticfiles_storage.url("core/img/unknown.jpg")
|
return staticfiles_storage.url("core/img/unknown.jpg")
|
||||||
return obj.profile_pict.get_download_url()
|
return reverse("core:download", kwargs={"file_id": obj.profile_pict_id})
|
||||||
|
|
||||||
|
|
||||||
class SithFileSchema(ModelSchema):
|
class SithFileSchema(ModelSchema):
|
||||||
|
@ -103,6 +103,12 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
|
|||||||
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
||||||
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
protected filter?: (items: TomOption[]) => TomOption[] = null;
|
||||||
protected minCharNumberForSearch = 2;
|
protected minCharNumberForSearch = 2;
|
||||||
|
/**
|
||||||
|
* A cache of researches that have been made using this input.
|
||||||
|
* For each record, the key is the user's query and the value
|
||||||
|
* is the list of results sent back by the server.
|
||||||
|
*/
|
||||||
|
protected cache = {} as Record<string, TomOption[]>;
|
||||||
|
|
||||||
protected abstract valueField: string;
|
protected abstract valueField: string;
|
||||||
protected abstract labelField: string;
|
protected abstract labelField: string;
|
||||||
@ -135,7 +141,13 @@ export abstract class AjaxSelect extends AutoCompleteSelectBase {
|
|||||||
this.widget.clearOptions();
|
this.widget.clearOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await this.search(query);
|
// Check in the cache if this query has already been typed
|
||||||
|
// and do an actual HTTP request only if the result isn't cached
|
||||||
|
let resp = this.cache[query];
|
||||||
|
if (!resp) {
|
||||||
|
resp = await this.search(query);
|
||||||
|
this.cache[query] = resp;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.filter) {
|
if (this.filter) {
|
||||||
callback(this.filter(resp), []);
|
callback(this.filter(resp), []);
|
||||||
|
@ -1,3 +1,11 @@
|
|||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||||
|
event.target.ariaBusy = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener("htmx:afterRequest", (event) => {
|
||||||
|
event.originalTarget.ariaBusy = null;
|
||||||
|
});
|
||||||
|
|
||||||
Object.assign(window, { htmx });
|
Object.assign(window, { htmx });
|
||||||
|
@ -6,7 +6,16 @@
|
|||||||
**/
|
**/
|
||||||
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
|
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
|
||||||
return (component: CustomElementConstructor) => {
|
return (component: CustomElementConstructor) => {
|
||||||
window.customElements.define(name, component, options);
|
try {
|
||||||
|
window.customElements.define(name, component, options);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException) {
|
||||||
|
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
|
||||||
|
console.warn(e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,3 +30,8 @@ $shadow-color: rgb(223, 223, 223);
|
|||||||
$background-button-color: hsl(0, 0%, 95%);
|
$background-button-color: hsl(0, 0%, 95%);
|
||||||
|
|
||||||
$deepblue: #354a5f;
|
$deepblue: #354a5f;
|
||||||
|
|
||||||
|
@mixin shadow {
|
||||||
|
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
|
||||||
|
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
|
||||||
|
}
|
89
core/static/core/forms.scss
Normal file
89
core/static/core/forms.scss
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
@import "colors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style related to forms
|
||||||
|
*/
|
||||||
|
|
||||||
|
a.button,
|
||||||
|
button,
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="file"] {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0.1em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: black;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: hsl(0, 0%, 83%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button,
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
input[type="file"] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.button:not(:disabled),
|
||||||
|
button:not(:disabled),
|
||||||
|
input[type="button"]:not(:disabled),
|
||||||
|
input[type="submit"]:not(:disabled),
|
||||||
|
input[type="reset"]:not(:disabled),
|
||||||
|
input[type="checkbox"]:not(:disabled),
|
||||||
|
input[type="file"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea[type="text"],
|
||||||
|
[type="number"] {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 0.4em;
|
||||||
|
margin: 0.1em;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 7px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.2em;
|
||||||
|
background-color: $background-button-color;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(.button) {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $primary-dark-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-light-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
@import "colors";
|
@import "colors";
|
||||||
|
@import "forms";
|
||||||
|
|
||||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||||
$small-devices: 576px;
|
$small-devices: 576px;
|
||||||
@ -13,91 +14,6 @@ body {
|
|||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
a.button,
|
|
||||||
button,
|
|
||||||
input[type="button"],
|
|
||||||
input[type="submit"],
|
|
||||||
input[type="reset"],
|
|
||||||
input[type="file"] {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0.1em;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: black;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: hsl(0, 0%, 83%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.button,
|
|
||||||
input[type="button"],
|
|
||||||
input[type="submit"],
|
|
||||||
input[type="reset"],
|
|
||||||
input[type="file"] {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.button:not(:disabled),
|
|
||||||
button:not(:disabled),
|
|
||||||
input[type="button"]:not(:disabled),
|
|
||||||
input[type="submit"]:not(:disabled),
|
|
||||||
input[type="reset"]:not(:disabled),
|
|
||||||
input[type="checkbox"]:not(:disabled),
|
|
||||||
input[type="file"]:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea[type="text"],
|
|
||||||
[type="number"] {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 0.4em;
|
|
||||||
margin: 0.1em;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 5px;
|
|
||||||
max-width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 7px;
|
|
||||||
font-size: 1.2em;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-family: sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1.2em;
|
|
||||||
background-color: $background-button-color;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not(.button) {
|
|
||||||
text-decoration: none;
|
|
||||||
color: $primary-dark-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $primary-light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[aria-busy] {
|
[aria-busy] {
|
||||||
--loading-size: 50px;
|
--loading-size: 50px;
|
||||||
--loading-stroke: 5px;
|
--loading-stroke: 5px;
|
||||||
@ -126,6 +42,32 @@ a:not(.button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[tooltip] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tooltip]::before {
|
||||||
|
@include shadow;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 1;
|
||||||
|
content: attr(tooltip);
|
||||||
|
background: hsl(219.6, 20.8%, 96%);
|
||||||
|
color: $black-color;
|
||||||
|
border: 0.5px solid hsl(0, 0%, 50%);
|
||||||
|
;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
top: 1em;
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 500ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tooltip]:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ib {
|
.ib {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@ -163,8 +105,7 @@ a:not(.button) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shadow {
|
.shadow {
|
||||||
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
|
@include shadow;
|
||||||
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.w_big {
|
.w_big {
|
||||||
@ -262,8 +203,10 @@ a:not(.button) {
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
|
margin: 3px;
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
&.btn-blue {
|
&.btn-blue {
|
||||||
@ -367,6 +310,53 @@ a:not(.button) {
|
|||||||
.alert-aside {
|
.alert-aside {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
.tab-headers {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
background-color: $primary-neutral-light-color;
|
||||||
|
padding: 3px 12px 12px;
|
||||||
|
column-gap: 20px;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
|
||||||
|
.tab-header {
|
||||||
|
border: none;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
font-size: 120%;
|
||||||
|
background-color: unset;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:after {
|
||||||
|
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active:after {
|
||||||
|
border-bottom-color: $primary-dark-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1246,26 +1236,26 @@ u,
|
|||||||
/*-----------------------------USER PROFILE----------------------------*/
|
/*-----------------------------USER PROFILE----------------------------*/
|
||||||
|
|
||||||
.user_mini_profile {
|
.user_mini_profile {
|
||||||
height: 100%;
|
--gap-size: 1em;
|
||||||
width: 100%;
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap-size);
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user_mini_profile_infos {
|
.user_mini_profile_infos {
|
||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
height: 20%;
|
max-height: 20%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|
||||||
div {
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user_mini_profile_infos_text {
|
.user_mini_profile_infos_text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
@ -1276,10 +1266,10 @@ u,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user_mini_profile_picture {
|
.user_mini_profile_picture {
|
||||||
height: 80%;
|
max-height: calc(80% - var(--gap-size));
|
||||||
display: flex;
|
max-width: 100%;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
margin: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,7 +66,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if user.promo and user.promo_has_logo() %}
|
{% if user.promo and user.promo_has_logo() %}
|
||||||
<div class="user_mini_profile_promo">
|
<div class="user_mini_profile_promo">
|
||||||
<img src="{{ static('core/img/promo_%02d.png' % user.promo) }}" title="Promo {{ user.promo }}" alt="Promo {{ user.promo }}" class="promo_pict" />
|
<img
|
||||||
|
src="{{ static('core/img/promo_%02d.png' % user.promo) }}"
|
||||||
|
title="Promo {{ user.promo }}"
|
||||||
|
alt="Promo {{ user.promo }}"
|
||||||
|
class="promo_pict"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -74,8 +79,11 @@
|
|||||||
{% if user.profile_pict %}
|
{% if user.profile_pict %}
|
||||||
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
<img src="{{ user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}" />
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}"
|
<img
|
||||||
title="{% trans %}Profile{% endtrans %}" />
|
src="{{ static('core/img/unknown.jpg') }}"
|
||||||
|
alt="{% trans %}Profile{% endtrans %}"
|
||||||
|
title="{% trans %}Profile{% endtrans %}"
|
||||||
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -170,12 +178,12 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro paginate_htmx(current_page, paginator) %}
|
{% macro paginate_htmx(current_page, paginator) %}
|
||||||
{# Add pagination buttons for pages without Alpine but supporting framgents.
|
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||||
|
|
||||||
This must be coupled with a view that handles pagination
|
This must be coupled with a view that handles pagination
|
||||||
with the Django Paginator object and supports framgents.
|
with the Django Paginator object and supports fragments.
|
||||||
|
|
||||||
The relpaced fragment will be #content so make sure you are calling this macro inside your content block.
|
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
current_page (django.core.paginator.Page): the current page object
|
current_page (django.core.paginator.Page): the current page object
|
||||||
@ -247,9 +255,9 @@
|
|||||||
{% macro select_all_checkbox(form_id) %}
|
{% macro select_all_checkbox(form_id) %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function checkbox_{{form_id}}(value) {
|
function checkbox_{{form_id}}(value) {
|
||||||
list = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
const inputs = document.getElementById("{{ form_id }}").getElementsByTagName("input");
|
||||||
for (let element of list){
|
for (let element of inputs){
|
||||||
if (element.type == "checkbox"){
|
if (element.type === "checkbox"){
|
||||||
element.checked = value;
|
element.checked = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -258,3 +266,65 @@
|
|||||||
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
||||||
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro tabs(tab_list, attrs = "") %}
|
||||||
|
{# Tab component
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
tab_list: list[tuple[str, str]] The list of tabs to display.
|
||||||
|
Each element of the list is a tuple which first element
|
||||||
|
is the title of the tab and the second element its content
|
||||||
|
attrs: str Additional attributes to put on the enclosing div
|
||||||
|
|
||||||
|
Example:
|
||||||
|
A basic usage would be as follow :
|
||||||
|
|
||||||
|
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
|
||||||
|
|
||||||
|
If you want to display more complex logic, you can define macros
|
||||||
|
and use those macros in parameters :
|
||||||
|
|
||||||
|
{{ tabs([("title", my_macro())]) }}
|
||||||
|
|
||||||
|
It's also possible to get and set the currently selected tab using Alpine.
|
||||||
|
Here, the title of the currently selected tab will be displayed.
|
||||||
|
Moreover, on page load, the tab will be opened on "tab 2".
|
||||||
|
|
||||||
|
<div x-data="{current_tab: 'tab 2'}">
|
||||||
|
<p x-text="current_tab"></p>
|
||||||
|
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
If you want to have translated tab titles, you can enclose the macro call
|
||||||
|
in a with block :
|
||||||
|
|
||||||
|
{% with title=_("title"), content=_("Content") %}
|
||||||
|
{{ tabs([(tab1, content)]) }}
|
||||||
|
{% endwith %}
|
||||||
|
#}
|
||||||
|
<div
|
||||||
|
class="tabs shadow"
|
||||||
|
x-data="{selected: '{{ tab_list[0][0] }}'}"
|
||||||
|
x-modelable="selected"
|
||||||
|
{{ attrs }}
|
||||||
|
>
|
||||||
|
<div class="tab-headers">
|
||||||
|
{% for title, _ in tab_list %}
|
||||||
|
<button
|
||||||
|
class="tab-header clickable"
|
||||||
|
:class="{active: selected === '{{ title }}'}"
|
||||||
|
@click="selected = '{{ title }}'"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-content">
|
||||||
|
{% for title, content in tab_list %}
|
||||||
|
<section x-show="selected === '{{ title }}'">
|
||||||
|
{{ content }}
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
@ -28,42 +28,20 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{% trans trombi=user.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||||
<br />
|
<br />
|
||||||
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
{% if profile.customer %}
|
{% if student_card_fragment %}
|
||||||
<h3>{% trans %}Student cards{% endtrans %}</h3>
|
<h3>{% trans %}Student card{% endtrans %}</h3>
|
||||||
|
{{ student_card_fragment }}
|
||||||
{% if profile.customer.student_cards.exists() %}
|
<p class="justify">
|
||||||
<ul class="student-cards">
|
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
|
||||||
{% for card in profile.customer.student_cards.all() %}
|
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
|
||||||
<li>
|
</p>
|
||||||
{{ card.uid }}
|
|
||||||
-
|
|
||||||
<a href="{{ url('counter:delete_student_card', customer_id=profile.customer.pk, card_id=card.id) }}">
|
|
||||||
{% trans %}Delete{% endtrans %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
|
|
||||||
<p class="justify">
|
|
||||||
{% trans %}You can add a card by asking at a counter or add it yourself here. If you want to manually
|
|
||||||
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form class="form form-cards" action="{{ url('counter:add_student_card', customer_id=profile.customer.pk) }}"
|
|
||||||
method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ student_card_form.as_p() }}
|
|
||||||
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -2,6 +2,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.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
|
||||||
@ -9,7 +10,9 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker, seq
|
from model_bakery import baker, seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key
|
from model_bakery.recipe import Recipe, foreign_key
|
||||||
|
|
||||||
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import (
|
from core.baker_recipes import (
|
||||||
|
board_user,
|
||||||
old_subscriber_user,
|
old_subscriber_user,
|
||||||
subscriber_user,
|
subscriber_user,
|
||||||
very_old_subscriber_user,
|
very_old_subscriber_user,
|
||||||
@ -17,6 +20,7 @@ from core.baker_recipes import (
|
|||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.models import Counter, Refilling, Selling
|
from counter.models import Counter, Refilling, Selling
|
||||||
from eboutic.models import Invoice, InvoiceItem
|
from eboutic.models import Invoice, InvoiceItem
|
||||||
|
from trombi.models import Trombi, TrombiUser
|
||||||
|
|
||||||
|
|
||||||
class TestSearchUsers(TestCase):
|
class TestSearchUsers(TestCase):
|
||||||
@ -187,3 +191,103 @@ def test_generate_username(first_name: str, last_name: str, expected: str):
|
|||||||
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
|
new_user = User(first_name=first_name, last_name=last_name, email="a@example.com")
|
||||||
new_user.generate_username()
|
new_user.generate_username()
|
||||||
assert new_user.username == expected
|
assert new_user.username == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestUserPreferences:
|
||||||
|
@pytest.fixture
|
||||||
|
def subscriber(self) -> User:
|
||||||
|
return subscriber_user.make()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_subscriber(self) -> User:
|
||||||
|
return subscriber_user.make()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trombi_user(self) -> User:
|
||||||
|
user = subscriber_user.make()
|
||||||
|
club = baker.make(Club)
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
club=club,
|
||||||
|
start_date=now() - timedelta(days=30),
|
||||||
|
role=settings.SITH_CLUB_ROLES_ID["Curious"],
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
trombi = baker.make(Trombi, club=club)
|
||||||
|
baker.make(TrombiUser, user=user, trombi=trombi)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def non_subscriber(self) -> User:
|
||||||
|
return baker.make(User)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def club_admin(self) -> User:
|
||||||
|
user = baker.make(User)
|
||||||
|
baker.make(
|
||||||
|
Membership,
|
||||||
|
start_date=now() - timedelta(days=30),
|
||||||
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def board_member(self) -> User:
|
||||||
|
return board_user.make()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin(self) -> User:
|
||||||
|
return baker.make(User, is_superuser=True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("tested_user", "accessing_user", "expected_code"),
|
||||||
|
[
|
||||||
|
("subscriber", None, 403), # Anonymous user
|
||||||
|
("subscriber", "non_subscriber", 403),
|
||||||
|
("subscriber", "club_admin", 403),
|
||||||
|
("subscriber", "other_subscriber", 403),
|
||||||
|
("subscriber", "trombi_user", 403),
|
||||||
|
("subscriber", "subscriber", 200),
|
||||||
|
("subscriber", "board_member", 200),
|
||||||
|
("subscriber", "admin", 200),
|
||||||
|
("non_subscriber", None, 403), # Anonymous user
|
||||||
|
("non_subscriber", "club_admin", 403),
|
||||||
|
("non_subscriber", "subscriber", 403),
|
||||||
|
("non_subscriber", "other_subscriber", 403),
|
||||||
|
("non_subscriber", "trombi_user", 403),
|
||||||
|
("non_subscriber", "non_subscriber", 200),
|
||||||
|
("non_subscriber", "board_member", 200),
|
||||||
|
("non_subscriber", "admin", 200),
|
||||||
|
("trombi_user", None, 403), # Anonymous user
|
||||||
|
("trombi_user", "club_admin", 403),
|
||||||
|
("trombi_user", "subscriber", 403),
|
||||||
|
("trombi_user", "other_subscriber", 403),
|
||||||
|
("trombi_user", "non_subscriber", 403),
|
||||||
|
("trombi_user", "trombi_user", 200),
|
||||||
|
("trombi_user", "board_member", 200),
|
||||||
|
("trombi_user", "admin", 200),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_user_preferences_access(
|
||||||
|
self,
|
||||||
|
client: Client,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
tested_user: str,
|
||||||
|
accessing_user: str | None,
|
||||||
|
expected_code: int,
|
||||||
|
):
|
||||||
|
cache.clear()
|
||||||
|
if accessing_user is not None:
|
||||||
|
client.force_login(request.getfixturevalue(accessing_user))
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
reverse(
|
||||||
|
"core:user_prefs",
|
||||||
|
kwargs={"user_id": request.getfixturevalue(tested_user).pk},
|
||||||
|
)
|
||||||
|
).status_code
|
||||||
|
== expected_code
|
||||||
|
)
|
||||||
|
@ -13,22 +13,41 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
# Image utils
|
# Image utils
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Optional
|
from typing import Any
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.forms import BaseForm
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import SafeString
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL import ExifTags
|
from PIL import ExifTags
|
||||||
from PIL.Image import Image, Resampling
|
from PIL.Image import Image, Resampling
|
||||||
|
|
||||||
|
|
||||||
def get_start_of_semester(today: Optional[date] = None) -> date:
|
@dataclass
|
||||||
|
class FormFragmentTemplateData[T: BaseForm]:
|
||||||
|
"""Dataclass used to pre-render form fragments"""
|
||||||
|
|
||||||
|
form: T
|
||||||
|
template: str
|
||||||
|
context: dict[str, Any]
|
||||||
|
|
||||||
|
def render(self, request: HttpRequest) -> SafeString:
|
||||||
|
# Request is needed for csrf_tokens
|
||||||
|
return render_to_string(
|
||||||
|
self.template, context={"form": self.form, **self.context}, request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_of_semester(today: date | None = None) -> date:
|
||||||
"""Return the date of the start of the semester of the given date.
|
"""Return the date of the start of the semester of the given date.
|
||||||
If no date is given, return the start date of the current semester.
|
If no date is given, return the start date of the current semester.
|
||||||
|
|
||||||
@ -58,7 +77,7 @@ def get_start_of_semester(today: Optional[date] = None) -> date:
|
|||||||
return autumn.replace(year=autumn.year - 1)
|
return autumn.replace(year=autumn.year - 1)
|
||||||
|
|
||||||
|
|
||||||
def get_semester_code(d: Optional[date] = None) -> str:
|
def get_semester_code(d: date | None = None) -> str:
|
||||||
"""Return the semester code of the given date.
|
"""Return the semester code of the given date.
|
||||||
If no date is given, return the semester code of the current semester.
|
If no date is given, return the semester code of the current semester.
|
||||||
|
|
||||||
|
@ -70,8 +70,8 @@ from core.views.forms import (
|
|||||||
UserGodfathersForm,
|
UserGodfathersForm,
|
||||||
UserProfileForm,
|
UserProfileForm,
|
||||||
)
|
)
|
||||||
from counter.forms import StudentCardForm
|
|
||||||
from counter.models import Refilling, Selling
|
from counter.models import Refilling, Selling
|
||||||
|
from counter.views.student_card import StudentCardFormView
|
||||||
from eboutic.models import Invoice
|
from eboutic.models import Invoice
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
from trombi.views import UserTrombiForm
|
from trombi.views import UserTrombiForm
|
||||||
@ -559,10 +559,6 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
|||||||
context_object_name = "profile"
|
context_object_name = "profile"
|
||||||
current_tab = "prefs"
|
current_tab = "prefs"
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
|
||||||
user = get_object_or_404(User, pk=self.kwargs["user_id"])
|
|
||||||
return user
|
|
||||||
|
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
pref = self.object.preferences
|
pref = self.object.preferences
|
||||||
@ -572,13 +568,12 @@ class UserPreferencesView(UserTabsMixin, CanEditMixin, UpdateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
if not (
|
if not hasattr(self.object, "trombi_user"):
|
||||||
hasattr(self.object, "trombi_user") and self.request.user.trombi_user.trombi
|
|
||||||
):
|
|
||||||
kwargs["trombi_form"] = UserTrombiForm()
|
kwargs["trombi_form"] = UserTrombiForm()
|
||||||
|
|
||||||
if hasattr(self.object, "customer"):
|
if hasattr(self.object, "customer"):
|
||||||
kwargs["student_card_form"] = StudentCardForm()
|
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||||
|
self.object.customer
|
||||||
|
).render(self.request)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,16 +19,13 @@ from django.db.models import Q
|
|||||||
from ninja import Query
|
from ninja import Query
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.permissions import IsAuthenticated
|
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
from core.api_permissions import CanAccessLookup, CanView, IsRoot
|
||||||
from counter.models import Counter, Permanency, Product
|
from counter.models import Counter, Product
|
||||||
from counter.schemas import (
|
from counter.schemas import (
|
||||||
CounterFilterSchema,
|
CounterFilterSchema,
|
||||||
CounterSchema,
|
CounterSchema,
|
||||||
PermanencyFilterSchema,
|
|
||||||
PermanencySchema,
|
|
||||||
ProductSchema,
|
ProductSchema,
|
||||||
SimplifiedCounterSchema,
|
SimplifiedCounterSchema,
|
||||||
)
|
)
|
||||||
@ -38,15 +35,17 @@ from counter.schemas import (
|
|||||||
class CounterController(ControllerBase):
|
class CounterController(ControllerBase):
|
||||||
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
|
@route.get("", response=list[CounterSchema], permissions=[IsRoot])
|
||||||
def fetch_all(self):
|
def fetch_all(self):
|
||||||
return Counter.objects.all()
|
return Counter.objects.annotate_is_open()
|
||||||
|
|
||||||
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
|
@route.get("{counter_id}/", response=CounterSchema, permissions=[CanView])
|
||||||
def fetch_one(self, counter_id: int):
|
def fetch_one(self, counter_id: int):
|
||||||
return self.get_object_or_exception(Counter.objects.all(), pk=counter_id)
|
return self.get_object_or_exception(
|
||||||
|
Counter.objects.annotate_is_open(), pk=counter_id
|
||||||
|
)
|
||||||
|
|
||||||
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
|
@route.get("bar/", response=list[CounterSchema], permissions=[CanView])
|
||||||
def fetch_bars(self):
|
def fetch_bars(self):
|
||||||
counters = list(Counter.objects.all().filter(type="BAR"))
|
counters = list(Counter.objects.annotate_is_open().filter(type="BAR"))
|
||||||
for c in counters:
|
for c in counters:
|
||||||
self.check_object_permissions(c)
|
self.check_object_permissions(c)
|
||||||
return counters
|
return counters
|
||||||
@ -77,21 +76,3 @@ class ProductController(ControllerBase):
|
|||||||
.filter(archived=False)
|
.filter(archived=False)
|
||||||
.values()
|
.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/permanency")
|
|
||||||
class PermanencyController(ControllerBase):
|
|
||||||
@route.get(
|
|
||||||
"",
|
|
||||||
response=PaginatedResponseSchema[PermanencySchema],
|
|
||||||
permissions=[IsAuthenticated],
|
|
||||||
exclude_none=True,
|
|
||||||
)
|
|
||||||
@paginate(PageNumberPaginationExtra, page_size=100)
|
|
||||||
def fetch_permanencies(self, filters: Query[PermanencyFilterSchema]):
|
|
||||||
return (
|
|
||||||
filters.filter(Permanency.objects.all())
|
|
||||||
.distinct()
|
|
||||||
.order_by("-start")
|
|
||||||
.select_related("counter")
|
|
||||||
)
|
|
||||||
|
@ -45,16 +45,12 @@ class BillingInfoForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class StudentCardForm(forms.ModelForm):
|
class StudentCardForm(forms.ModelForm):
|
||||||
"""Form for adding student cards
|
"""Form for adding student cards"""
|
||||||
Only used for user profile since CounterClick is to complicated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = StudentCard
|
model = StudentCard
|
||||||
fields = ["uid"]
|
fields = ["uid"]
|
||||||
widgets = {
|
widgets = {"uid": NFCTextInput}
|
||||||
"uid": NFCTextInput,
|
|
||||||
}
|
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
@ -114,14 +110,6 @@ class GetUserForm(forms.Form):
|
|||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
class NFCCardForm(forms.Form):
|
|
||||||
student_card_uid = forms.CharField(
|
|
||||||
max_length=StudentCard.UID_SIZE,
|
|
||||||
required=False,
|
|
||||||
widget=NFCTextInput,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RefillForm(forms.ModelForm):
|
class RefillForm(forms.ModelForm):
|
||||||
error_css_class = "error"
|
error_css_class = "error"
|
||||||
required_css_class = "required"
|
required_css_class = "required"
|
||||||
@ -153,7 +141,6 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"description",
|
"description",
|
||||||
"product_type",
|
"product_type",
|
||||||
"code",
|
"code",
|
||||||
"parent_product",
|
|
||||||
"buying_groups",
|
"buying_groups",
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
"selling_price",
|
"selling_price",
|
||||||
@ -165,7 +152,6 @@ class ProductEditForm(forms.ModelForm):
|
|||||||
"archived",
|
"archived",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"parent_product": AutoCompleteSelectMultipleProduct,
|
|
||||||
"product_type": AutoCompleteSelect,
|
"product_type": AutoCompleteSelect,
|
||||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||||
"club": AutoCompleteSelectClub,
|
"club": AutoCompleteSelectClub,
|
||||||
|
@ -55,7 +55,9 @@ class Command(BaseCommand):
|
|||||||
customer__user__in=reactivated_users
|
customer__user__in=reactivated_users
|
||||||
).delete()
|
).delete()
|
||||||
self._dump_accounts({u.customer for u in users_to_dump})
|
self._dump_accounts({u.customer for u in users_to_dump})
|
||||||
self._send_mails(users_to_dump)
|
self.stdout.write("Accounts dumped")
|
||||||
|
nb_successful_mails = self._send_mails(users_to_dump)
|
||||||
|
self.stdout.write(f"{nb_successful_mails} were successfuly sent.")
|
||||||
self.stdout.write("Finished !")
|
self.stdout.write("Finished !")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -103,13 +105,14 @@ class Command(BaseCommand):
|
|||||||
if len(pending_dumps) != len(customer_ids):
|
if len(pending_dumps) != len(customer_ids):
|
||||||
raise ValueError("One or more accounts were not engaged in a dump process")
|
raise ValueError("One or more accounts were not engaged in a dump process")
|
||||||
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
|
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
|
||||||
|
seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
|
||||||
sales = Selling.objects.bulk_create(
|
sales = Selling.objects.bulk_create(
|
||||||
[
|
[
|
||||||
Selling(
|
Selling(
|
||||||
label="Vidange compte inactif",
|
label="Vidange compte inactif",
|
||||||
club=counter.club,
|
club=counter.club,
|
||||||
counter=counter,
|
counter=counter,
|
||||||
seller=None,
|
seller=seller,
|
||||||
product=None,
|
product=None,
|
||||||
customer=account,
|
customer=account,
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@ -134,8 +137,12 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_mails(users: Iterable[User]):
|
def _send_mails(users: Iterable[User]) -> int:
|
||||||
"""Send the mails informing users that their account has been dumped."""
|
"""Send the mails informing users that their account has been dumped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of emails successfully sent.
|
||||||
|
"""
|
||||||
mails = [
|
mails = [
|
||||||
(
|
(
|
||||||
_("Your AE account has been emptied"),
|
_("Your AE account has been emptied"),
|
||||||
@ -145,4 +152,4 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
for user in users
|
for user in users
|
||||||
]
|
]
|
||||||
send_mass_mail(mails)
|
return send_mass_mail(mails, fail_silently=True)
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-09 11:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import accounting.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0024_accountdump_accountdump_unique_ongoing_dump")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="product", name="parent_product"),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(default="", verbose_name="description"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="purchase_price",
|
||||||
|
field=accounting.models.CurrencyField(
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Initial cost of purchasing the product",
|
||||||
|
max_digits=12,
|
||||||
|
verbose_name="purchase price",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="special_selling_price",
|
||||||
|
field=accounting.models.CurrencyField(
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Price for barmen during their permanence",
|
||||||
|
max_digits=12,
|
||||||
|
verbose_name="special selling price",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
53
counter/migrations/0026_alter_studentcard_customer.py
Normal file
53
counter/migrations/0026_alter_studentcard_customer.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-08 13:30
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.migrations.state import StateApps
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
|
||||||
|
def delete_duplicates(apps: StateApps, schema_editor):
|
||||||
|
"""Delete cards of users with more than one student cards.
|
||||||
|
|
||||||
|
For all users who have more than one registered student card, all
|
||||||
|
the cards except the last one are deleted.
|
||||||
|
"""
|
||||||
|
Customer = apps.get_model("counter", "Customer")
|
||||||
|
StudentCard = apps.get_model("counter", "StudentCard")
|
||||||
|
customers = (
|
||||||
|
Customer.objects.annotate(nb_cards=Count("student_cards"))
|
||||||
|
.filter(nb_cards__gt=1)
|
||||||
|
.prefetch_related("student_cards")
|
||||||
|
)
|
||||||
|
to_delete = [
|
||||||
|
card.id
|
||||||
|
for customer in customers
|
||||||
|
for card in sorted(customer.student_cards.all(), key=attrgetter("id"))[:-1]
|
||||||
|
]
|
||||||
|
StudentCard.objects.filter(id__in=to_delete).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("counter", "0025_remove_product_parent_product_and_more")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(delete_duplicates, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="studentcard",
|
||||||
|
name="customer",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="student_card",
|
||||||
|
to="counter.customer",
|
||||||
|
verbose_name="student card",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="studentcard",
|
||||||
|
options={
|
||||||
|
"verbose_name": "student card",
|
||||||
|
"verbose_name_plural": "student cards",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -326,7 +326,7 @@ class Product(models.Model):
|
|||||||
"""A product, with all its related information."""
|
"""A product, with all its related information."""
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=64)
|
name = models.CharField(_("name"), max_length=64)
|
||||||
description = models.TextField(_("description"), blank=True)
|
description = models.TextField(_("description"), default="")
|
||||||
product_type = models.ForeignKey(
|
product_type = models.ForeignKey(
|
||||||
ProductType,
|
ProductType,
|
||||||
related_name="products",
|
related_name="products",
|
||||||
@ -336,9 +336,15 @@ class Product(models.Model):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
code = models.CharField(_("code"), max_length=16, blank=True)
|
code = models.CharField(_("code"), max_length=16, blank=True)
|
||||||
purchase_price = CurrencyField(_("purchase price"))
|
purchase_price = CurrencyField(
|
||||||
|
_("purchase price"),
|
||||||
|
help_text=_("Initial cost of purchasing the product"),
|
||||||
|
)
|
||||||
selling_price = CurrencyField(_("selling price"))
|
selling_price = CurrencyField(_("selling price"))
|
||||||
special_selling_price = CurrencyField(_("special selling price"))
|
special_selling_price = CurrencyField(
|
||||||
|
_("special selling price"),
|
||||||
|
help_text=_("Price for barmen during their permanence"),
|
||||||
|
)
|
||||||
icon = ResizedImageField(
|
icon = ResizedImageField(
|
||||||
height=70,
|
height=70,
|
||||||
force_format="WEBP",
|
force_format="WEBP",
|
||||||
@ -352,14 +358,6 @@ class Product(models.Model):
|
|||||||
)
|
)
|
||||||
limit_age = models.IntegerField(_("limit age"), default=0)
|
limit_age = models.IntegerField(_("limit age"), default=0)
|
||||||
tray = models.BooleanField(_("tray price"), default=False)
|
tray = models.BooleanField(_("tray price"), default=False)
|
||||||
parent_product = models.ForeignKey(
|
|
||||||
"self",
|
|
||||||
related_name="children_products",
|
|
||||||
verbose_name=_("parent product"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
)
|
|
||||||
buying_groups = models.ManyToManyField(
|
buying_groups = models.ManyToManyField(
|
||||||
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
Group, related_name="products", verbose_name=_("buying groups"), blank=True
|
||||||
)
|
)
|
||||||
@ -369,7 +367,7 @@ class Product(models.Model):
|
|||||||
verbose_name = _("product")
|
verbose_name = _("product")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "%s (%s)" % (self.name, self.code)
|
return f"{self.name} ({self.code})"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("counter:product_list")
|
return reverse("counter:product_list")
|
||||||
@ -1140,20 +1138,22 @@ class StudentCard(models.Model):
|
|||||||
uid = models.CharField(
|
uid = models.CharField(
|
||||||
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
|
_("uid"), max_length=UID_SIZE, unique=True, validators=[MinLengthValidator(4)]
|
||||||
)
|
)
|
||||||
customer = models.ForeignKey(
|
customer = models.OneToOneField(
|
||||||
Customer,
|
Customer,
|
||||||
related_name="student_cards",
|
related_name="student_card",
|
||||||
verbose_name=_("student cards"),
|
verbose_name=_("student card"),
|
||||||
null=False,
|
|
||||||
blank=False,
|
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("student card")
|
||||||
|
verbose_name_plural = _("student cards")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.uid
|
return self.uid
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_valid(uid):
|
def is_valid(uid: str) -> bool:
|
||||||
return (
|
return (
|
||||||
(uid.isupper() or uid.isnumeric())
|
(uid.isupper() or uid.isnumeric())
|
||||||
and len(uid) == StudentCard.UID_SIZE
|
and len(uid) == StudentCard.UID_SIZE
|
||||||
|
@ -1,33 +1,19 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
from ninja import Field, FilterSchema, ModelSchema
|
from ninja import Field, FilterSchema, ModelSchema
|
||||||
|
|
||||||
from counter.models import Counter, Permanency, Product
|
from core.schemas import SimpleUserSchema
|
||||||
|
from counter.models import Counter, Product
|
||||||
|
|
||||||
|
|
||||||
class CounterSchema(ModelSchema):
|
class CounterSchema(ModelSchema):
|
||||||
|
barmen_list: list[SimpleUserSchema]
|
||||||
|
is_open: bool
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Counter
|
model = Counter
|
||||||
fields = ["id", "name", "type"]
|
fields = ["id", "name", "type", "club", "products"]
|
||||||
|
|
||||||
|
|
||||||
class PermanencySchema(ModelSchema):
|
|
||||||
counter: CounterSchema
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Permanency
|
|
||||||
fields = ["start", "end"]
|
|
||||||
|
|
||||||
|
|
||||||
class PermanencyFilterSchema(FilterSchema):
|
|
||||||
start_after: datetime | None = Field(None, q="start__gte")
|
|
||||||
start_before: datetime | None = Field(None, q="start__lte")
|
|
||||||
end_after: datetime | None = Field(None, q="end__gte")
|
|
||||||
end_before: datetime | None = Field(None, q="end__lte")
|
|
||||||
took_place_after: datetime | None = Field(None, q=["start__gte", "end__gte"])
|
|
||||||
counter: set[int] | None = Field(None, q="counter_id__in")
|
|
||||||
|
|
||||||
|
|
||||||
class CounterFilterSchema(FilterSchema):
|
class CounterFilterSchema(FilterSchema):
|
||||||
|
@ -1,152 +0,0 @@
|
|||||||
import { paginated } from "#core:utils/api";
|
|
||||||
import { exportToHtml } from "#core:utils/globals";
|
|
||||||
import { Calendar } from "@fullcalendar/core";
|
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
||||||
import {
|
|
||||||
type PermanencyFetchPermanenciesData,
|
|
||||||
type PermanencySchema,
|
|
||||||
permanencyFetchPermanencies,
|
|
||||||
} from "#openapi";
|
|
||||||
|
|
||||||
interface ActivityTimeGridConfig {
|
|
||||||
canvas: HTMLCanvasElement;
|
|
||||||
startDate: Date;
|
|
||||||
counterId: number;
|
|
||||||
locale: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpeningTime {
|
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventInput {
|
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
backgroundColor: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportToHtml("loadActivityTimeGrid", loadActivityTimeGrid);
|
|
||||||
|
|
||||||
async function loadActivityTimeGrid(options: ActivityTimeGridConfig) {
|
|
||||||
const permanencies = await paginated(permanencyFetchPermanencies, {
|
|
||||||
query: {
|
|
||||||
counter: [options.counterId],
|
|
||||||
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
|
|
||||||
took_place_after: options.startDate.toISOString(),
|
|
||||||
},
|
|
||||||
} as PermanencyFetchPermanenciesData);
|
|
||||||
|
|
||||||
const events = getEvents(permanencies);
|
|
||||||
|
|
||||||
const calendar = new Calendar(options.canvas, {
|
|
||||||
plugins: [timeGridPlugin],
|
|
||||||
initialView: "timeGridWeek",
|
|
||||||
locale: options.locale,
|
|
||||||
dayHeaderFormat: { weekday: "long" },
|
|
||||||
firstDay: 1,
|
|
||||||
views: { timeGrid: { allDaySlot: false } },
|
|
||||||
scrollTime: "09:00:00",
|
|
||||||
headerToolbar: { left: "prev today", center: "title", right: "" },
|
|
||||||
events: events,
|
|
||||||
nowIndicator: true,
|
|
||||||
height: 600,
|
|
||||||
});
|
|
||||||
calendar.render();
|
|
||||||
|
|
||||||
calendar.on("datesSet", async (info) => {
|
|
||||||
if (options.startDate <= info.start) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newPerms = await paginated(permanencyFetchPermanencies, {
|
|
||||||
query: {
|
|
||||||
counter: [options.counterId],
|
|
||||||
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
|
|
||||||
end_after: info.startStr,
|
|
||||||
// biome-ignore lint/style/useNamingConvention: backend API uses snake_case
|
|
||||||
start_before: info.endStr,
|
|
||||||
},
|
|
||||||
} as PermanencyFetchPermanenciesData);
|
|
||||||
options.startDate = info.start;
|
|
||||||
calendar.addEventSource(getEvents(newPerms, false));
|
|
||||||
permanencies.push(...newPerms);
|
|
||||||
calendar.render();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundToQuarter(date: Date, ceil: boolean) {
|
|
||||||
const result = date;
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
// removes minutes exceeding the lower quarter and adds 15 minutes if rounded to ceiling
|
|
||||||
result.setMinutes(minutes - (minutes % 15) + +ceil * 15, 0, 0);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertPermanencyToOpeningTime(permanency: PermanencySchema): OpeningTime {
|
|
||||||
return {
|
|
||||||
start: roundToQuarter(new Date(permanency.start), false),
|
|
||||||
end: roundToQuarter(new Date(permanency.end ?? Date.now()), true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpeningTimes(rawPermanencies: PermanencySchema[]) {
|
|
||||||
const permanencies = rawPermanencies
|
|
||||||
.map(convertPermanencyToOpeningTime)
|
|
||||||
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
||||||
|
|
||||||
const openingTimes: OpeningTime[] = [];
|
|
||||||
|
|
||||||
for (const permanency of permanencies) {
|
|
||||||
// if there are no opening times, add the first one
|
|
||||||
if (openingTimes.length === 0) {
|
|
||||||
openingTimes.push(permanency);
|
|
||||||
} else {
|
|
||||||
const lastPermanency = openingTimes[openingTimes.length - 1];
|
|
||||||
// if the new permanency starts before the 15 minutes following the end of the last one, merge them
|
|
||||||
if (permanency.start <= lastPermanency.end) {
|
|
||||||
lastPermanency.end = new Date(
|
|
||||||
Math.max(lastPermanency.end.getTime(), permanency.end.getTime()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
openingTimes.push(permanency);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return openingTimes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEvents(permanencies: PermanencySchema[], currentWeek = true): EventInput[] {
|
|
||||||
const openingTimes = getOpeningTimes(permanencies);
|
|
||||||
const events: EventInput[] = [];
|
|
||||||
for (const openingTime of openingTimes) {
|
|
||||||
let shift = false;
|
|
||||||
if (currentWeek) {
|
|
||||||
const lastMonday = getLastMonday();
|
|
||||||
shift = openingTime.end < lastMonday;
|
|
||||||
}
|
|
||||||
// if permanencies took place last week (=before monday),
|
|
||||||
// -> display them in lightblue as part of the current week
|
|
||||||
events.push({
|
|
||||||
start: shift ? shiftDateByDays(openingTime.start, 7) : openingTime.start,
|
|
||||||
end: shift ? shiftDateByDays(openingTime.end, 7) : openingTime.end,
|
|
||||||
backgroundColor: shift ? "lightblue" : "green",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get last Monday at 00:00
|
|
||||||
function getLastMonday(now = new Date()): Date {
|
|
||||||
const dayOfWeek = now.getDay();
|
|
||||||
const lastMonday = new Date(now);
|
|
||||||
lastMonday.setDate(now.getDate() - ((dayOfWeek + 6) % 7)); // Adjust for Monday as day 1
|
|
||||||
lastMonday.setHours(0, 0, 0, 0);
|
|
||||||
return lastMonday;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shiftDateByDays(date: Date, days: number): Date {
|
|
||||||
const newDate = new Date(date);
|
|
||||||
newDate.setDate(date.getDate() + days);
|
|
||||||
return newDate;
|
|
||||||
}
|
|
@ -22,20 +22,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#activityTimeGrid {
|
|
||||||
th {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fc-col-header-cell-cushion {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
table table {
|
|
||||||
// the JS library puts tables inside the table
|
|
||||||
// Those additional tables must be hidden
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,10 +5,6 @@
|
|||||||
{% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %}
|
{% trans counter_name=counter %}{{ counter_name }} activity{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block additional_js %}
|
|
||||||
<script defer src="{{ static('bundled/counter/permanencies/time-grid-index.ts') }}" type="module"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}">
|
<link rel="stylesheet" href="{{ static('counter/css/activity.scss') }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
@ -26,10 +22,6 @@
|
|||||||
{% trans %}There is currently no barman connected.{% endtrans %}
|
{% trans %}There is currently no barman connected.{% endtrans %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<h4>{% trans %}Last weeks opening times{% endtrans %}</h4>
|
|
||||||
<div id="activityTimeGrid"></div>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h5>{% trans %}Legend{% endtrans %}</h5>
|
<h5>{% trans %}Legend{% endtrans %}</h5>
|
||||||
@ -45,17 +37,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{super()}}
|
|
||||||
<script>
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
loadActivityTimeGrid({
|
|
||||||
canvas: document.getElementById("activityTimeGrid"),
|
|
||||||
// sets the start day to 7 days ago
|
|
||||||
startDate: new Date(new Date().setDate(new Date().getDate() - 7)),
|
|
||||||
counterId: {{ counter.id }},
|
|
||||||
locale: {{ get_current_language()|tojson }}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
@ -29,26 +29,10 @@
|
|||||||
{{ user_mini_profile(customer.user) }}
|
{{ user_mini_profile(customer.user) }}
|
||||||
{{ user_subscription(customer.user) }}
|
{{ user_subscription(customer.user) }}
|
||||||
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
|
<p>{% trans %}Amount: {% endtrans %}{{ customer.amount }} €</p>
|
||||||
<form method="post" action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="action" value="add_student_card">
|
|
||||||
{% trans %}Add a student card{% endtrans %}
|
|
||||||
{{ student_card_input.student_card_uid }}
|
|
||||||
{% if request.session['not_valid_student_card_uid'] %}
|
|
||||||
<p><strong>{% trans %}This is not a valid student card UID{% endtrans %}</strong></p>
|
|
||||||
{% endif %}
|
|
||||||
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
|
||||||
</form>
|
|
||||||
<h6>{% trans %}Registered cards{% endtrans %}</h6>
|
|
||||||
{% if student_cards %}
|
|
||||||
|
|
||||||
<ul>
|
{% if counter.type == 'BAR' %}
|
||||||
{% for card in student_cards %}
|
<h5>{% trans %}Student card{% endtrans %}</h3>
|
||||||
<li>{{ card.uid }}</li>
|
{{ student_card_fragment }}
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
{% trans %}No card registered{% endtrans %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
<div id="student_card_form">
|
||||||
|
{% if not customer.student_card %}
|
||||||
|
<form
|
||||||
|
hx-post="{{ action }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#student_card_form"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p() }}
|
||||||
|
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
|
||||||
|
</form>
|
||||||
|
<em class="no-cards">{% trans %}No student card registered.{% endtrans %}</em>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
<span tooltip="{% trans uid=customer.student_card.uid %}uid: {{ uid }} {% endtrans %}">
|
||||||
|
{% trans %}Card registered{% endtrans %}
|
||||||
|
<i class="fa fa-check" style="color: green"></i>
|
||||||
|
</span>
|
||||||
|
-
|
||||||
|
<button
|
||||||
|
hx-get="{{ url('counter:delete_student_card', customer_id=customer.pk) }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#student_card_form"
|
||||||
|
>
|
||||||
|
{% trans %}Delete{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
@ -0,0 +1,15 @@
|
|||||||
|
<div id="student_card_form">
|
||||||
|
<form hx-post="{{ action }}" hx-swap="outerHTML" hx-target="#student_card_form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}</p>
|
||||||
|
<input type="submit" value="{% trans %}Confirm{% endtrans %}" />
|
||||||
|
<input
|
||||||
|
hx-get="{{ action_cancel }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#student_card_form"
|
||||||
|
type="submit"
|
||||||
|
name="cancel"
|
||||||
|
value="{% trans %}Cancel{% endtrans %}"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
@ -1,15 +1,28 @@
|
|||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.base_user import make_password
|
||||||
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 model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from core.baker_recipes import subscriber_user
|
from club.models import Membership
|
||||||
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.baker_recipes import refill_recipe, sale_recipe
|
from counter.baker_recipes import refill_recipe, sale_recipe
|
||||||
from counter.models import BillingInfo, Counter, Customer, Refilling, Selling
|
from counter.models import (
|
||||||
|
BillingInfo,
|
||||||
|
Counter,
|
||||||
|
Customer,
|
||||||
|
Refilling,
|
||||||
|
Selling,
|
||||||
|
StudentCard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@ -162,302 +175,221 @@ class TestStudentCard(TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.krophil = User.objects.get(username="krophil")
|
cls.customer = subscriber_user.make()
|
||||||
cls.sli = User.objects.get(username="sli")
|
cls.barmen = subscriber_user.make(password=make_password("plop"))
|
||||||
cls.skia = User.objects.get(username="skia")
|
cls.board_admin = board_user.make()
|
||||||
cls.root = User.objects.get(username="root")
|
cls.club_admin = baker.make(User)
|
||||||
|
cls.root = baker.make(User, is_superuser=True)
|
||||||
|
cls.subscriber = subscriber_user.make()
|
||||||
|
|
||||||
cls.counter = Counter.objects.get(id=2)
|
cls.counter = baker.make(Counter, type="BAR")
|
||||||
|
cls.counter.sellers.add(cls.barmen)
|
||||||
|
|
||||||
def setUp(self):
|
cls.club_counter = baker.make(Counter)
|
||||||
# Auto login on counter
|
baker.make(
|
||||||
self.client.post(
|
Membership,
|
||||||
reverse("counter:login", args=[self.counter.id]),
|
start_date=now() - timedelta(days=30),
|
||||||
{"username": "krophil", "password": "plop"},
|
club=cls.club_counter.club,
|
||||||
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cls.valid_card = baker.make(
|
||||||
|
StudentCard, customer=cls.customer.customer, uid="8A89B82018B0A0"
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_in_counter(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("counter:login", args=[self.counter.id]),
|
||||||
|
{"username": self.barmen.username, "password": "plop"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def invalid_uids(self) -> list[tuple[str, str]]:
|
||||||
|
"""Return a list of invalid uids, with the associated error message"""
|
||||||
|
return [
|
||||||
|
("8B90734A802A8", ""), # too short
|
||||||
|
(
|
||||||
|
"8B90734A802A8FA",
|
||||||
|
"Assurez-vous que cette valeur comporte au plus 14 caractères (actuellement 15).",
|
||||||
|
), # too long
|
||||||
|
("8b90734a802a9f", ""), # has lowercases
|
||||||
|
(" " * 14, "Ce champ est obligatoire."), # empty
|
||||||
|
(
|
||||||
|
self.customer.customer.student_card.uid,
|
||||||
|
"Un objet Carte étudiante avec ce champ Uid existe déjà.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def test_search_user_with_student_card(self):
|
def test_search_user_with_student_card(self):
|
||||||
|
self.login_in_counter()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("counter:details", args=[self.counter.id]),
|
reverse("counter:details", args=[self.counter.id]),
|
||||||
{"code": "9A89B82018B0A0"},
|
{"code": self.valid_card.uid},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.url == reverse(
|
assert response.url == reverse(
|
||||||
"counter:click",
|
"counter:click",
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
kwargs={"counter_id": self.counter.id, "user_id": self.customer.pk},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_add_student_card_from_counter(self):
|
def test_add_student_card_from_counter(self):
|
||||||
# Test card with mixed letters and numbers
|
self.login_in_counter()
|
||||||
response = self.client.post(
|
for uid in ["8B90734A802A8F", "ABCAAAFAAFAAAB", "15248196326518"]:
|
||||||
reverse(
|
customer = subscriber_user.make().customer
|
||||||
"counter:click",
|
response = self.client.post(
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
reverse(
|
||||||
),
|
"counter:add_student_card", kwargs={"customer_id": customer.pk}
|
||||||
{"student_card_uid": "8B90734A802A8F", "action": "add_student_card"},
|
),
|
||||||
)
|
{"uid": uid},
|
||||||
self.assertContains(response, text="8B90734A802A8F")
|
HTTP_REFERER=reverse(
|
||||||
|
"counter:click",
|
||||||
# Test card with only numbers
|
kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
|
||||||
response = self.client.post(
|
),
|
||||||
reverse(
|
)
|
||||||
"counter:click",
|
assert response.status_code == 302
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
customer.refresh_from_db()
|
||||||
),
|
assert hasattr(customer, "student_card")
|
||||||
{"student_card_uid": "04786547890123", "action": "add_student_card"},
|
assert customer.student_card.uid == uid
|
||||||
)
|
|
||||||
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):
|
def test_add_student_card_from_counter_fail(self):
|
||||||
# UID too short
|
self.login_in_counter()
|
||||||
response = self.client.post(
|
customer = subscriber_user.make().customer
|
||||||
reverse(
|
for uid, error_msg in self.invalid_uids():
|
||||||
"counter:click",
|
response = self.client.post(
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
reverse(
|
||||||
),
|
"counter:add_student_card", kwargs={"customer_id": customer.pk}
|
||||||
{"student_card_uid": "8B90734A802A8", "action": "add_student_card"},
|
),
|
||||||
)
|
{"uid": uid},
|
||||||
self.assertContains(
|
HTTP_REFERER=reverse(
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
"counter:click",
|
||||||
)
|
kwargs={"counter_id": self.counter.id, "user_id": customer.pk},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
self.assertContains(response, text=error_msg)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
assert not hasattr(customer, "student_card")
|
||||||
|
|
||||||
# UID too long
|
def test_add_student_card_from_counter_unauthorized(self):
|
||||||
response = self.client.post(
|
def send_valid_request(client, counter_id):
|
||||||
reverse(
|
return client.post(
|
||||||
"counter:click",
|
reverse(
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
"counter:add_student_card", kwargs={"customer_id": self.customer.pk}
|
||||||
),
|
),
|
||||||
{"student_card_uid": "8B90734A802A8FA", "action": "add_student_card"},
|
{"uid": "8B90734A802A8F"},
|
||||||
)
|
HTTP_REFERER=reverse(
|
||||||
self.assertContains(
|
"counter:click",
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
kwargs={"counter_id": counter_id, "user_id": self.customer.pk},
|
||||||
)
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Test with already existing card
|
# Send to a counter where you aren't logged in
|
||||||
response = self.client.post(
|
assert send_valid_request(self.client, self.counter.id).status_code == 403
|
||||||
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
|
self.login_in_counter()
|
||||||
response = self.client.post(
|
barman = subscriber_user.make()
|
||||||
reverse(
|
self.counter.sellers.add(barman)
|
||||||
"counter:click",
|
# We want to test sending requests from another counter while
|
||||||
kwargs={"counter_id": self.counter.id, "user_id": self.sli.id},
|
# we are currently registered to another counter
|
||||||
),
|
# so we connect to a counter and
|
||||||
{"student_card_uid": "8b90734a802a9f", "action": "add_student_card"},
|
# we create a new client, in order to check
|
||||||
)
|
# that using a client not logged to a counter
|
||||||
self.assertContains(
|
# where another client is logged still isn't authorized.
|
||||||
response, text="Ce n'est pas un UID de carte étudiante valide"
|
client = Client()
|
||||||
)
|
# Send to a counter where you aren't logged in
|
||||||
|
assert send_valid_request(client, self.counter.id).status_code == 403
|
||||||
|
|
||||||
# Test with white spaces
|
# Send to a non bar counter
|
||||||
response = self.client.post(
|
client.force_login(self.club_admin)
|
||||||
reverse(
|
assert send_valid_request(client, self.club_counter.id).status_code == 403
|
||||||
"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):
|
def test_delete_student_card_with_owner(self):
|
||||||
self.client.force_login(self.sli)
|
self.client.force_login(self.customer)
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"counter:delete_student_card",
|
"counter:delete_student_card",
|
||||||
kwargs={
|
kwargs={"customer_id": self.customer.customer.pk},
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert not self.sli.customer.student_cards.exists()
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert not hasattr(self.customer.customer, "student_card")
|
||||||
|
|
||||||
def test_delete_student_card_with_board_member(self):
|
def test_delete_student_card_with_admin_user(self):
|
||||||
self.client.force_login(self.skia)
|
"""Test that AE board members and root users can delete student cards"""
|
||||||
self.client.post(
|
for user in self.board_admin, self.root:
|
||||||
reverse(
|
self.client.force_login(user)
|
||||||
"counter:delete_student_card",
|
self.client.post(
|
||||||
kwargs={
|
reverse(
|
||||||
"customer_id": self.sli.customer.pk,
|
"counter:delete_student_card",
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
kwargs={"customer_id": self.customer.customer.pk},
|
||||||
},
|
)
|
||||||
)
|
)
|
||||||
)
|
self.customer.customer.refresh_from_db()
|
||||||
assert not self.sli.customer.student_cards.exists()
|
assert not hasattr(self.customer.customer, "student_card")
|
||||||
|
|
||||||
def test_delete_student_card_with_root(self):
|
def test_delete_student_card_from_counter(self):
|
||||||
self.client.force_login(self.root)
|
self.login_in_counter()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"counter:delete_student_card",
|
"counter:delete_student_card",
|
||||||
|
kwargs={"customer_id": self.customer.customer.pk},
|
||||||
|
),
|
||||||
|
http_referer=reverse(
|
||||||
|
"counter:click",
|
||||||
kwargs={
|
kwargs={
|
||||||
"customer_id": self.sli.customer.pk,
|
"counter_id": self.counter.id,
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
"user_id": self.customer.customer.pk,
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
assert not self.sli.customer.student_cards.exists()
|
self.customer.customer.refresh_from_db()
|
||||||
|
assert not hasattr(self.customer.customer, "student_card")
|
||||||
|
|
||||||
def test_delete_student_card_fail(self):
|
def test_delete_student_card_fail(self):
|
||||||
self.client.force_login(self.krophil)
|
"""Test that non-admin users cannot delete student cards"""
|
||||||
|
self.client.force_login(self.subscriber)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"counter:delete_student_card",
|
"counter:delete_student_card",
|
||||||
kwargs={
|
kwargs={"customer_id": self.customer.customer.pk},
|
||||||
"customer_id": self.sli.customer.pk,
|
|
||||||
"card_id": self.sli.customer.student_cards.first().id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert self.sli.customer.student_cards.exists()
|
self.subscriber.customer.refresh_from_db()
|
||||||
|
assert not hasattr(self.subscriber.customer, "student_card")
|
||||||
|
|
||||||
def test_add_student_card_from_user_preferences(self):
|
def test_add_student_card_from_user_preferences(self):
|
||||||
# Test with owner of the card
|
users = [self.customer, self.board_admin, self.root]
|
||||||
self.client.force_login(self.sli)
|
uids = ["8B90734A802A8F", "ABCAAAFAAFAAAB", "15248196326518"]
|
||||||
self.client.post(
|
for user, uid in itertools.product(users, uids):
|
||||||
reverse(
|
self.customer.customer.student_card.delete()
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
self.client.force_login(user)
|
||||||
),
|
response = self.client.post(
|
||||||
{"uid": "8B90734A802A8F"},
|
reverse(
|
||||||
)
|
"counter:add_student_card",
|
||||||
|
kwargs={"customer_id": self.customer.customer.pk},
|
||||||
|
),
|
||||||
|
{"uid": uid},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
response = self.client.get(response.url)
|
||||||
|
|
||||||
response = self.client.get(
|
self.customer.customer.refresh_from_db()
|
||||||
reverse("core:user_prefs", kwargs={"user_id": self.sli.id})
|
assert self.customer.customer.student_card.uid == uid
|
||||||
)
|
self.assertContains(response, text="Carte enregistrée")
|
||||||
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):
|
def test_add_student_card_from_user_preferences_fail(self):
|
||||||
self.client.force_login(self.sli)
|
customer = subscriber_user.make()
|
||||||
# UID too short
|
self.client.force_login(customer)
|
||||||
response = self.client.post(
|
for uid, error_msg in self.invalid_uids():
|
||||||
reverse(
|
url = reverse(
|
||||||
"counter:add_student_card", kwargs={"customer_id": self.sli.customer.pk}
|
"counter:add_student_card", kwargs={"customer_id": customer.customer.pk}
|
||||||
),
|
)
|
||||||
{"uid": "8B90734A802A8"},
|
response = self.client.post(url, {"uid": uid})
|
||||||
)
|
self.assertContains(response, text="Cet UID est invalide")
|
||||||
|
self.assertContains(response, text=error_msg)
|
||||||
self.assertContains(response, text="Cet UID est invalide")
|
customer.refresh_from_db()
|
||||||
|
assert not hasattr(customer.customer, "student_card")
|
||||||
# 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):
|
class TestCustomerAccountId(TestCase):
|
||||||
|
@ -15,28 +15,16 @@
|
|||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from counter.views import (
|
from counter.views.admin import (
|
||||||
ActiveProductListView,
|
ActiveProductListView,
|
||||||
ArchivedProductListView,
|
ArchivedProductListView,
|
||||||
CashSummaryEditView,
|
|
||||||
CashSummaryListView,
|
|
||||||
CounterActivityView,
|
|
||||||
CounterCashSummaryView,
|
|
||||||
CounterClick,
|
|
||||||
CounterCreateView,
|
CounterCreateView,
|
||||||
CounterDeleteView,
|
CounterDeleteView,
|
||||||
CounterEditPropView,
|
CounterEditPropView,
|
||||||
CounterEditView,
|
CounterEditView,
|
||||||
CounterLastOperationsView,
|
|
||||||
CounterListView,
|
CounterListView,
|
||||||
CounterMain,
|
|
||||||
CounterRefillingListView,
|
CounterRefillingListView,
|
||||||
CounterStatView,
|
CounterStatView,
|
||||||
EticketCreateView,
|
|
||||||
EticketEditView,
|
|
||||||
EticketListView,
|
|
||||||
EticketPDFView,
|
|
||||||
InvoiceCallView,
|
|
||||||
ProductCreateView,
|
ProductCreateView,
|
||||||
ProductEditView,
|
ProductEditView,
|
||||||
ProductTypeCreateView,
|
ProductTypeCreateView,
|
||||||
@ -44,10 +32,29 @@ from counter.views import (
|
|||||||
ProductTypeListView,
|
ProductTypeListView,
|
||||||
RefillingDeleteView,
|
RefillingDeleteView,
|
||||||
SellingDeleteView,
|
SellingDeleteView,
|
||||||
|
)
|
||||||
|
from counter.views.auth import counter_login, counter_logout
|
||||||
|
from counter.views.cash import (
|
||||||
|
CashSummaryEditView,
|
||||||
|
CashSummaryListView,
|
||||||
|
CounterCashSummaryView,
|
||||||
|
)
|
||||||
|
from counter.views.click import CounterClick
|
||||||
|
from counter.views.eticket import (
|
||||||
|
EticketCreateView,
|
||||||
|
EticketEditView,
|
||||||
|
EticketListView,
|
||||||
|
EticketPDFView,
|
||||||
|
)
|
||||||
|
from counter.views.home import (
|
||||||
|
CounterActivityView,
|
||||||
|
CounterLastOperationsView,
|
||||||
|
CounterMain,
|
||||||
|
)
|
||||||
|
from counter.views.invoice import InvoiceCallView
|
||||||
|
from counter.views.student_card import (
|
||||||
StudentCardDeleteView,
|
StudentCardDeleteView,
|
||||||
StudentCardFormView,
|
StudentCardFormView,
|
||||||
counter_login,
|
|
||||||
counter_logout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -74,7 +81,7 @@ urlpatterns = [
|
|||||||
name="add_student_card",
|
name="add_student_card",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"customer/<int:customer_id>/card/delete/<int:card_id>/",
|
"customer/<int:customer_id>/card/delete/",
|
||||||
StudentCardDeleteView.as_view(),
|
StudentCardDeleteView.as_view(),
|
||||||
name="delete_student_card",
|
name="delete_student_card",
|
||||||
),
|
),
|
||||||
|
@ -22,14 +22,22 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
|
|||||||
to the counter)
|
to the counter)
|
||||||
- The current session has a counter token associated with it.
|
- The current session has a counter token associated with it.
|
||||||
- A counter with this token exists.
|
- A counter with this token exists.
|
||||||
|
- The counter is open
|
||||||
"""
|
"""
|
||||||
referer_ok = (
|
referer_ok = (
|
||||||
"HTTP_REFERER" in request.META
|
"HTTP_REFERER" in request.META
|
||||||
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
|
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
|
||||||
)
|
)
|
||||||
return (
|
has_token = (
|
||||||
(referer_ok or request.resolver_match.app_name == "counter")
|
(referer_ok or request.resolver_match.app_name == "counter")
|
||||||
and "counter_token" in request.session
|
and "counter_token" in request.session
|
||||||
and request.session["counter_token"]
|
and request.session["counter_token"]
|
||||||
and Counter.objects.filter(token=request.session["counter_token"]).exists()
|
)
|
||||||
|
if not has_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
Counter.objects.annotate_is_open()
|
||||||
|
.filter(token=request.session["counter_token"], is_open=True)
|
||||||
|
.exists()
|
||||||
)
|
)
|
||||||
|
1537
counter/views.py
1537
counter/views.py
File diff suppressed because it is too large
Load Diff
288
counter/views/admin.py
Normal file
288
counter/views/admin.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
import itertools
|
||||||
|
from datetime import timedelta
|
||||||
|
from operator import itemgetter
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db.models import F
|
||||||
|
from django.forms import CheckboxSelectMultiple
|
||||||
|
from django.forms.models import modelform_factory
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import DetailView, ListView
|
||||||
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
|
|
||||||
|
from core.utils import get_semester_code, get_start_of_semester
|
||||||
|
from core.views import CanEditMixin, CanViewMixin
|
||||||
|
from counter.forms import CounterEditForm, ProductEditForm
|
||||||
|
from counter.models import Counter, Product, ProductType, Refilling, Selling
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||||
|
"""A list view for the admins."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
template_name = "counter/counter_list.jinja"
|
||||||
|
current_tab = "counters"
|
||||||
|
|
||||||
|
|
||||||
|
class CounterEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""Edit a counter's main informations (for the counter's manager)."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
form_class = CounterEditForm
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "core/edit.jinja"
|
||||||
|
current_tab = "counters"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
self.edit_club.append(obj.club)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
|
||||||
|
|
||||||
|
|
||||||
|
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""Edit a counter's main informations (for the counter's admin)."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "core/edit.jinja"
|
||||||
|
current_tab = "counters"
|
||||||
|
|
||||||
|
|
||||||
|
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
|
"""Create a counter (for the admins)."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
form_class = modelform_factory(
|
||||||
|
Counter,
|
||||||
|
fields=["name", "club", "type", "products"],
|
||||||
|
widgets={"products": CheckboxSelectMultiple},
|
||||||
|
)
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
current_tab = "counters"
|
||||||
|
|
||||||
|
|
||||||
|
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
|
||||||
|
"""Delete a counter (for the admins)."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "core/delete_confirm.jinja"
|
||||||
|
success_url = reverse_lazy("counter:admin_list")
|
||||||
|
current_tab = "counters"
|
||||||
|
|
||||||
|
|
||||||
|
# Product management
|
||||||
|
|
||||||
|
|
||||||
|
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
|
"""A list view for the admins."""
|
||||||
|
|
||||||
|
model = ProductType
|
||||||
|
template_name = "counter/producttype_list.jinja"
|
||||||
|
current_tab = "product_types"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
|
"""A create view for the admins."""
|
||||||
|
|
||||||
|
model = ProductType
|
||||||
|
fields = ["name", "description", "comment", "icon", "priority"]
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
current_tab = "products"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""An edit view for the admins."""
|
||||||
|
|
||||||
|
model = ProductType
|
||||||
|
template_name = "core/edit.jinja"
|
||||||
|
fields = ["name", "description", "comment", "icon", "priority"]
|
||||||
|
pk_url_kwarg = "type_id"
|
||||||
|
current_tab = "products"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
|
model = Product
|
||||||
|
queryset = Product.objects.values("id", "name", "code", "product_type__name")
|
||||||
|
template_name = "counter/product_list.jinja"
|
||||||
|
ordering = [
|
||||||
|
F("product_type__priority").desc(nulls_last=True),
|
||||||
|
"product_type",
|
||||||
|
"name",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
res = super().get_context_data(**kwargs)
|
||||||
|
res["object_list"] = itertools.groupby(
|
||||||
|
res["object_list"], key=itemgetter("product_type__name")
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class ArchivedProductListView(ProductListView):
|
||||||
|
"""A list view for the admins."""
|
||||||
|
|
||||||
|
current_tab = "archive"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(archived=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveProductListView(ProductListView):
|
||||||
|
"""A list view for the admins."""
|
||||||
|
|
||||||
|
current_tab = "products"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(archived=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
|
"""A create view for the admins."""
|
||||||
|
|
||||||
|
model = Product
|
||||||
|
form_class = ProductEditForm
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
current_tab = "products"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""An edit view for the admins."""
|
||||||
|
|
||||||
|
model = Product
|
||||||
|
form_class = ProductEditForm
|
||||||
|
pk_url_kwarg = "product_id"
|
||||||
|
template_name = "core/edit.jinja"
|
||||||
|
current_tab = "products"
|
||||||
|
|
||||||
|
|
||||||
|
class RefillingDeleteView(DeleteView):
|
||||||
|
"""Delete a refilling (for the admins)."""
|
||||||
|
|
||||||
|
model = Refilling
|
||||||
|
pk_url_kwarg = "refilling_id"
|
||||||
|
template_name = "core/delete_confirm.jinja"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
if timezone.now() - self.object.date <= timedelta(
|
||||||
|
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||||
|
) and is_logged_in_counter(request):
|
||||||
|
self.success_url = reverse(
|
||||||
|
"counter:details", kwargs={"counter_id": self.object.counter.id}
|
||||||
|
)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
elif self.object.is_owned_by(request.user):
|
||||||
|
self.success_url = reverse(
|
||||||
|
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
|
||||||
|
)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
class SellingDeleteView(DeleteView):
|
||||||
|
"""Delete a selling (for the admins)."""
|
||||||
|
|
||||||
|
model = Selling
|
||||||
|
pk_url_kwarg = "selling_id"
|
||||||
|
template_name = "core/delete_confirm.jinja"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
if timezone.now() - self.object.date <= timedelta(
|
||||||
|
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||||
|
) and is_logged_in_counter(request):
|
||||||
|
self.success_url = reverse(
|
||||||
|
"counter:details", kwargs={"counter_id": self.object.counter.id}
|
||||||
|
)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
elif self.object.is_owned_by(request.user):
|
||||||
|
self.success_url = reverse(
|
||||||
|
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
|
||||||
|
)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
class CounterStatView(DetailView, CounterAdminMixin):
|
||||||
|
"""Show the bar stats."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "counter/stats.jinja"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add stats to the context."""
|
||||||
|
counter: Counter = self.object
|
||||||
|
semester_start = get_start_of_semester()
|
||||||
|
office_hours = counter.get_top_barmen()
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs.update(
|
||||||
|
{
|
||||||
|
"counter": counter,
|
||||||
|
"current_semester": get_semester_code(),
|
||||||
|
"total_sellings": counter.get_total_sales(since=semester_start),
|
||||||
|
"top_customers": counter.get_top_customers(since=semester_start)[:100],
|
||||||
|
"top_barman": office_hours[:100],
|
||||||
|
"top_barman_semester": (
|
||||||
|
office_hours.filter(start__gt=semester_start)[:100]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
except PermissionDenied:
|
||||||
|
if (
|
||||||
|
request.user.is_root
|
||||||
|
or request.user.is_board_member
|
||||||
|
or self.get_object().is_owned_by(request.user)
|
||||||
|
):
|
||||||
|
return super(CanEditMixin, self).dispatch(request, *args, **kwargs)
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
|
"""List of refillings on a counter."""
|
||||||
|
|
||||||
|
model = Refilling
|
||||||
|
template_name = "counter/refilling_list.jinja"
|
||||||
|
current_tab = "counters"
|
||||||
|
paginate_by = 30
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
|
||||||
|
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs["counter"] = self.counter
|
||||||
|
return kwargs
|
53
counter/views/auth.py
Normal file
53
counter/views/auth.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
from django.db.models import F
|
||||||
|
from django.http import HttpRequest, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from core.views.forms import LoginForm
|
||||||
|
from counter.models import Counter, Permanency
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||||
|
"""Log a user in a counter.
|
||||||
|
|
||||||
|
A successful login will result in the beginning of a counter duty
|
||||||
|
for the user.
|
||||||
|
"""
|
||||||
|
counter = get_object_or_404(Counter, pk=counter_id)
|
||||||
|
form = LoginForm(request, data=request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
return redirect(counter.get_absolute_url() + "?credentials")
|
||||||
|
user = form.get_user()
|
||||||
|
if not counter.sellers.contains(user) or user in counter.barmen_list:
|
||||||
|
return redirect(counter.get_absolute_url() + "?sellers")
|
||||||
|
if len(counter.barmen_list) == 0:
|
||||||
|
counter.gen_token()
|
||||||
|
request.session["counter_token"] = counter.token
|
||||||
|
counter.permanencies.create(user=user, start=timezone.now())
|
||||||
|
return redirect(counter)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||||
|
"""End the permanency of a user in this counter."""
|
||||||
|
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
|
||||||
|
end=F("activity")
|
||||||
|
)
|
||||||
|
return redirect("counter:details", counter_id=counter_id)
|
359
counter/views/cash.py
Normal file
359
counter/views/cash.py
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone as tz
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import DetailView, ListView
|
||||||
|
from django.views.generic.edit import UpdateView
|
||||||
|
|
||||||
|
from core.views import CanViewMixin
|
||||||
|
from counter.forms import CashSummaryFormBase
|
||||||
|
from counter.models import (
|
||||||
|
CashRegisterSummary,
|
||||||
|
CashRegisterSummaryItem,
|
||||||
|
Counter,
|
||||||
|
Refilling,
|
||||||
|
)
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
from counter.views.mixins import (
|
||||||
|
CounterAdminMixin,
|
||||||
|
CounterAdminTabsMixin,
|
||||||
|
CounterTabsMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CashRegisterSummaryForm(forms.Form):
|
||||||
|
"""Provide the cash summary form."""
|
||||||
|
|
||||||
|
ten_cents = forms.IntegerField(label=_("10 cents"), required=False, min_value=0)
|
||||||
|
twenty_cents = forms.IntegerField(label=_("20 cents"), required=False, min_value=0)
|
||||||
|
fifty_cents = forms.IntegerField(label=_("50 cents"), required=False, min_value=0)
|
||||||
|
one_euro = forms.IntegerField(label=_("1 euro"), required=False, min_value=0)
|
||||||
|
two_euros = forms.IntegerField(label=_("2 euros"), required=False, min_value=0)
|
||||||
|
five_euros = forms.IntegerField(label=_("5 euros"), required=False, min_value=0)
|
||||||
|
ten_euros = forms.IntegerField(label=_("10 euros"), required=False, min_value=0)
|
||||||
|
twenty_euros = forms.IntegerField(label=_("20 euros"), required=False, min_value=0)
|
||||||
|
fifty_euros = forms.IntegerField(label=_("50 euros"), required=False, min_value=0)
|
||||||
|
hundred_euros = forms.IntegerField(
|
||||||
|
label=_("100 euros"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_1_value = forms.DecimalField(
|
||||||
|
label=_("Check amount"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_1_quantity = forms.IntegerField(
|
||||||
|
label=_("Check quantity"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_2_value = forms.DecimalField(
|
||||||
|
label=_("Check amount"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_2_quantity = forms.IntegerField(
|
||||||
|
label=_("Check quantity"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_3_value = forms.DecimalField(
|
||||||
|
label=_("Check amount"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_3_quantity = forms.IntegerField(
|
||||||
|
label=_("Check quantity"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_4_value = forms.DecimalField(
|
||||||
|
label=_("Check amount"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_4_quantity = forms.IntegerField(
|
||||||
|
label=_("Check quantity"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_5_value = forms.DecimalField(
|
||||||
|
label=_("Check amount"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
check_5_quantity = forms.IntegerField(
|
||||||
|
label=_("Check quantity"), required=False, min_value=0
|
||||||
|
)
|
||||||
|
comment = forms.CharField(label=_("Comment"), required=False)
|
||||||
|
emptied = forms.BooleanField(label=_("Emptied"), required=False)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
instance = kwargs.pop("instance", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if instance:
|
||||||
|
self.fields["ten_cents"].initial = (
|
||||||
|
instance.ten_cents.quantity if instance.ten_cents else 0
|
||||||
|
)
|
||||||
|
self.fields["twenty_cents"].initial = (
|
||||||
|
instance.twenty_cents.quantity if instance.twenty_cents else 0
|
||||||
|
)
|
||||||
|
self.fields["fifty_cents"].initial = (
|
||||||
|
instance.fifty_cents.quantity if instance.fifty_cents else 0
|
||||||
|
)
|
||||||
|
self.fields["one_euro"].initial = (
|
||||||
|
instance.one_euro.quantity if instance.one_euro else 0
|
||||||
|
)
|
||||||
|
self.fields["two_euros"].initial = (
|
||||||
|
instance.two_euros.quantity if instance.two_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["five_euros"].initial = (
|
||||||
|
instance.five_euros.quantity if instance.five_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["ten_euros"].initial = (
|
||||||
|
instance.ten_euros.quantity if instance.ten_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["twenty_euros"].initial = (
|
||||||
|
instance.twenty_euros.quantity if instance.twenty_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["fifty_euros"].initial = (
|
||||||
|
instance.fifty_euros.quantity if instance.fifty_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["hundred_euros"].initial = (
|
||||||
|
instance.hundred_euros.quantity if instance.hundred_euros else 0
|
||||||
|
)
|
||||||
|
self.fields["check_1_quantity"].initial = (
|
||||||
|
instance.check_1.quantity if instance.check_1 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_2_quantity"].initial = (
|
||||||
|
instance.check_2.quantity if instance.check_2 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_3_quantity"].initial = (
|
||||||
|
instance.check_3.quantity if instance.check_3 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_4_quantity"].initial = (
|
||||||
|
instance.check_4.quantity if instance.check_4 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_5_quantity"].initial = (
|
||||||
|
instance.check_5.quantity if instance.check_5 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_1_value"].initial = (
|
||||||
|
instance.check_1.value if instance.check_1 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_2_value"].initial = (
|
||||||
|
instance.check_2.value if instance.check_2 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_3_value"].initial = (
|
||||||
|
instance.check_3.value if instance.check_3 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_4_value"].initial = (
|
||||||
|
instance.check_4.value if instance.check_4 else 0
|
||||||
|
)
|
||||||
|
self.fields["check_5_value"].initial = (
|
||||||
|
instance.check_5.value if instance.check_5 else 0
|
||||||
|
)
|
||||||
|
self.fields["comment"].initial = instance.comment
|
||||||
|
self.fields["emptied"].initial = instance.emptied
|
||||||
|
self.instance = instance
|
||||||
|
else:
|
||||||
|
self.instance = None
|
||||||
|
|
||||||
|
def save(self, counter=None):
|
||||||
|
cd = self.cleaned_data
|
||||||
|
summary = self.instance or CashRegisterSummary(
|
||||||
|
counter=counter, user=counter.get_random_barman()
|
||||||
|
)
|
||||||
|
summary.comment = cd["comment"]
|
||||||
|
summary.emptied = cd["emptied"]
|
||||||
|
summary.save()
|
||||||
|
summary.items.all().delete()
|
||||||
|
# Cash
|
||||||
|
if cd["ten_cents"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=0.1, quantity=cd["ten_cents"]
|
||||||
|
).save()
|
||||||
|
if cd["twenty_cents"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=0.2, quantity=cd["twenty_cents"]
|
||||||
|
).save()
|
||||||
|
if cd["fifty_cents"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=0.5, quantity=cd["fifty_cents"]
|
||||||
|
).save()
|
||||||
|
if cd["one_euro"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=1, quantity=cd["one_euro"]
|
||||||
|
).save()
|
||||||
|
if cd["two_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=2, quantity=cd["two_euros"]
|
||||||
|
).save()
|
||||||
|
if cd["five_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=5, quantity=cd["five_euros"]
|
||||||
|
).save()
|
||||||
|
if cd["ten_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=10, quantity=cd["ten_euros"]
|
||||||
|
).save()
|
||||||
|
if cd["twenty_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=20, quantity=cd["twenty_euros"]
|
||||||
|
).save()
|
||||||
|
if cd["fifty_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=50, quantity=cd["fifty_euros"]
|
||||||
|
).save()
|
||||||
|
if cd["hundred_euros"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary, value=100, quantity=cd["hundred_euros"]
|
||||||
|
).save()
|
||||||
|
# Checks
|
||||||
|
if cd["check_1_quantity"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary,
|
||||||
|
value=cd["check_1_value"],
|
||||||
|
quantity=cd["check_1_quantity"],
|
||||||
|
is_check=True,
|
||||||
|
).save()
|
||||||
|
if cd["check_2_quantity"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary,
|
||||||
|
value=cd["check_2_value"],
|
||||||
|
quantity=cd["check_2_quantity"],
|
||||||
|
is_check=True,
|
||||||
|
).save()
|
||||||
|
if cd["check_3_quantity"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary,
|
||||||
|
value=cd["check_3_value"],
|
||||||
|
quantity=cd["check_3_quantity"],
|
||||||
|
is_check=True,
|
||||||
|
).save()
|
||||||
|
if cd["check_4_quantity"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary,
|
||||||
|
value=cd["check_4_value"],
|
||||||
|
quantity=cd["check_4_quantity"],
|
||||||
|
is_check=True,
|
||||||
|
).save()
|
||||||
|
if cd["check_5_quantity"]:
|
||||||
|
CashRegisterSummaryItem(
|
||||||
|
cash_summary=summary,
|
||||||
|
value=cd["check_5_value"],
|
||||||
|
quantity=cd["check_5_quantity"],
|
||||||
|
is_check=True,
|
||||||
|
).save()
|
||||||
|
if summary.items.count() < 1:
|
||||||
|
summary.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class CounterCashSummaryView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||||
|
"""Provide the cash summary form."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "counter/cash_register_summary.jinja"
|
||||||
|
current_tab = "cash_summary"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""We have here again a very particular right handling."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
if is_logged_in_counter(request) and self.object.barmen_list:
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||||
|
+ "?bad_location"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.form = CashRegisterSummaryForm()
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.form = CashRegisterSummaryForm(request.POST)
|
||||||
|
if self.form.is_valid():
|
||||||
|
self.form.save(self.object)
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("counter:details", kwargs={"counter_id": self.object.id})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add form to the context."""
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs["form"] = self.form
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class CashSummaryEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""Edit cash summaries."""
|
||||||
|
|
||||||
|
model = CashRegisterSummary
|
||||||
|
template_name = "counter/cash_register_summary.jinja"
|
||||||
|
context_object_name = "cashsummary"
|
||||||
|
pk_url_kwarg = "cashsummary_id"
|
||||||
|
form_class = CashRegisterSummaryForm
|
||||||
|
current_tab = "cash_summary"
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("counter:cash_summary_list")
|
||||||
|
|
||||||
|
|
||||||
|
class CashSummaryListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
|
"""Display a list of cash summaries."""
|
||||||
|
|
||||||
|
model = CashRegisterSummary
|
||||||
|
template_name = "counter/cash_summary_list.jinja"
|
||||||
|
context_object_name = "cashsummary_list"
|
||||||
|
current_tab = "cash_summary"
|
||||||
|
queryset = CashRegisterSummary.objects.all().order_by("-date")
|
||||||
|
paginate_by = settings.SITH_COUNTER_CASH_SUMMARY_LENGTH
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add sums to the context."""
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
form = CashSummaryFormBase(self.request.GET)
|
||||||
|
kwargs["form"] = form
|
||||||
|
kwargs["summaries_sums"] = {}
|
||||||
|
kwargs["refilling_sums"] = {}
|
||||||
|
for c in Counter.objects.filter(type="BAR").all():
|
||||||
|
refillings = Refilling.objects.filter(counter=c)
|
||||||
|
cashredistersummaries = CashRegisterSummary.objects.filter(counter=c)
|
||||||
|
if form.is_valid() and form.cleaned_data["begin_date"]:
|
||||||
|
refillings = refillings.filter(
|
||||||
|
date__gte=form.cleaned_data["begin_date"]
|
||||||
|
)
|
||||||
|
cashredistersummaries = cashredistersummaries.filter(
|
||||||
|
date__gte=form.cleaned_data["begin_date"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
last_summary = (
|
||||||
|
CashRegisterSummary.objects.filter(counter=c, emptied=True)
|
||||||
|
.order_by("-date")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_summary:
|
||||||
|
refillings = refillings.filter(date__gt=last_summary.date)
|
||||||
|
cashredistersummaries = cashredistersummaries.filter(
|
||||||
|
date__gt=last_summary.date
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
refillings = refillings.filter(
|
||||||
|
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||||
|
) # My birth date should be old enough
|
||||||
|
cashredistersummaries = cashredistersummaries.filter(
|
||||||
|
date__gte=datetime(year=1994, month=5, day=17, tzinfo=tz.utc)
|
||||||
|
)
|
||||||
|
if form.is_valid() and form.cleaned_data["end_date"]:
|
||||||
|
refillings = refillings.filter(date__lte=form.cleaned_data["end_date"])
|
||||||
|
cashredistersummaries = cashredistersummaries.filter(
|
||||||
|
date__lte=form.cleaned_data["end_date"]
|
||||||
|
)
|
||||||
|
kwargs["summaries_sums"][c.name] = sum(
|
||||||
|
[s.get_total() for s in cashredistersummaries.all()]
|
||||||
|
)
|
||||||
|
kwargs["refilling_sums"][c.name] = sum([s.amount for s in refillings.all()])
|
||||||
|
return kwargs
|
421
counter/views/click.py
Normal file
421
counter/views/click.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
import re
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db import DataError, transaction
|
||||||
|
from django.db.models import F
|
||||||
|
from django.http import Http404, HttpResponseRedirect, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import DetailView
|
||||||
|
|
||||||
|
from core.views import CanViewMixin
|
||||||
|
from counter.forms import RefillForm
|
||||||
|
from counter.models import Counter, Customer, Product, Selling
|
||||||
|
from counter.views.mixins import CounterTabsMixin
|
||||||
|
from counter.views.student_card import StudentCardFormView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class CounterClick(CounterTabsMixin, CanViewMixin, DetailView):
|
||||||
|
"""The click view
|
||||||
|
This is a detail view not to have to worry about loading the counter
|
||||||
|
Everything is made by hand in the post method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
queryset = Counter.objects.annotate_is_open()
|
||||||
|
template_name = "counter/counter_click.jinja"
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
current_tab = "counter"
|
||||||
|
|
||||||
|
def render_to_response(self, *args, **kwargs):
|
||||||
|
if self.is_ajax(self.request):
|
||||||
|
response = {"errors": []}
|
||||||
|
status = HTTPStatus.OK
|
||||||
|
|
||||||
|
if self.request.session["too_young"]:
|
||||||
|
response["errors"].append(_("Too young for that product"))
|
||||||
|
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
||||||
|
if self.request.session["not_allowed"]:
|
||||||
|
response["errors"].append(_("Not allowed for that product"))
|
||||||
|
status = HTTPStatus.FORBIDDEN
|
||||||
|
if self.request.session["no_age"]:
|
||||||
|
response["errors"].append(_("No date of birth provided"))
|
||||||
|
status = HTTPStatus.UNAVAILABLE_FOR_LEGAL_REASONS
|
||||||
|
if self.request.session["not_enough"]:
|
||||||
|
response["errors"].append(_("Not enough money"))
|
||||||
|
status = HTTPStatus.PAYMENT_REQUIRED
|
||||||
|
|
||||||
|
if len(response["errors"]) > 1:
|
||||||
|
status = HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
response["basket"] = self.request.session["basket"]
|
||||||
|
|
||||||
|
return JsonResponse(response, status=status)
|
||||||
|
|
||||||
|
else: # Standard HTML page
|
||||||
|
return super().render_to_response(*args, **kwargs)
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
|
||||||
|
obj: Counter = self.get_object()
|
||||||
|
if not self.customer.can_buy:
|
||||||
|
raise Http404
|
||||||
|
if obj.type != "BAR" and not request.user.is_authenticated:
|
||||||
|
raise PermissionDenied
|
||||||
|
if obj.type == "BAR" and (
|
||||||
|
"counter_token" not in request.session
|
||||||
|
or request.session["counter_token"] != obj.token
|
||||||
|
or len(obj.barmen_list) == 0
|
||||||
|
):
|
||||||
|
return redirect(obj)
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Simple get view."""
|
||||||
|
if "basket" not in request.session: # Init the basket session entry
|
||||||
|
request.session["basket"] = {}
|
||||||
|
request.session["basket_total"] = 0
|
||||||
|
request.session["not_enough"] = False # Reset every variable
|
||||||
|
request.session["too_young"] = False
|
||||||
|
request.session["not_allowed"] = False
|
||||||
|
request.session["no_age"] = False
|
||||||
|
self.refill_form = None
|
||||||
|
ret = super().get(request, *args, **kwargs)
|
||||||
|
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
|
||||||
|
self.object.type == "BAR" and len(self.object.barmen_list) == 0
|
||||||
|
): # Check that at least one barman is logged in
|
||||||
|
ret = self.cancel(request) # Otherwise, go to main view
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Handle the many possibilities of the post request."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.refill_form = None
|
||||||
|
if (self.object.type != "BAR" and not request.user.is_authenticated) or (
|
||||||
|
self.object.type == "BAR" and len(self.object.barmen_list) < 1
|
||||||
|
): # Check that at least one barman is logged in
|
||||||
|
return self.cancel(request)
|
||||||
|
if self.object.type == "BAR" and not (
|
||||||
|
"counter_token" in self.request.session
|
||||||
|
and self.request.session["counter_token"] == self.object.token
|
||||||
|
): # Also check the token to avoid the bar to be stolen
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy(
|
||||||
|
"counter:details",
|
||||||
|
args=self.args,
|
||||||
|
kwargs={"counter_id": self.object.id},
|
||||||
|
)
|
||||||
|
+ "?bad_location"
|
||||||
|
)
|
||||||
|
if "basket" not in request.session:
|
||||||
|
request.session["basket"] = {}
|
||||||
|
request.session["basket_total"] = 0
|
||||||
|
request.session["not_enough"] = False # Reset every variable
|
||||||
|
request.session["too_young"] = False
|
||||||
|
request.session["not_allowed"] = False
|
||||||
|
request.session["no_age"] = False
|
||||||
|
if self.object.type != "BAR":
|
||||||
|
self.operator = request.user
|
||||||
|
elif self.customer_is_barman():
|
||||||
|
self.operator = self.customer.user
|
||||||
|
else:
|
||||||
|
self.operator = self.object.get_random_barman()
|
||||||
|
action = self.request.POST.get("action", None)
|
||||||
|
if action is None:
|
||||||
|
action = parse_qs(request.body.decode()).get("action", [""])[0]
|
||||||
|
if action == "add_product":
|
||||||
|
self.add_product(request)
|
||||||
|
elif action == "del_product":
|
||||||
|
self.del_product(request)
|
||||||
|
elif action == "refill":
|
||||||
|
self.refill(request)
|
||||||
|
elif action == "code":
|
||||||
|
return self.parse_code(request)
|
||||||
|
elif action == "cancel":
|
||||||
|
return self.cancel(request)
|
||||||
|
elif action == "finish":
|
||||||
|
return self.finish(request)
|
||||||
|
context = self.get_context_data(object=self.object)
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
def customer_is_barman(self) -> bool:
|
||||||
|
barmen = self.object.barmen_list
|
||||||
|
return self.object.type == "BAR" and self.customer.user in barmen
|
||||||
|
|
||||||
|
def get_product(self, pid):
|
||||||
|
return Product.objects.filter(pk=int(pid)).first()
|
||||||
|
|
||||||
|
def get_price(self, pid):
|
||||||
|
p = self.get_product(pid)
|
||||||
|
if self.customer_is_barman():
|
||||||
|
price = p.special_selling_price
|
||||||
|
else:
|
||||||
|
price = p.selling_price
|
||||||
|
return price
|
||||||
|
|
||||||
|
def sum_basket(self, request):
|
||||||
|
total = 0
|
||||||
|
for infos in request.session["basket"].values():
|
||||||
|
total += infos["price"] * infos["qty"]
|
||||||
|
return total / 100
|
||||||
|
|
||||||
|
def get_total_quantity_for_pid(self, request, pid):
|
||||||
|
pid = str(pid)
|
||||||
|
if pid not in request.session["basket"]:
|
||||||
|
return 0
|
||||||
|
return (
|
||||||
|
request.session["basket"][pid]["qty"]
|
||||||
|
+ request.session["basket"][pid]["bonus_qty"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def compute_record_product(self, request, product=None):
|
||||||
|
recorded = 0
|
||||||
|
basket = request.session["basket"]
|
||||||
|
|
||||||
|
if product:
|
||||||
|
if product.is_record_product:
|
||||||
|
recorded -= 1
|
||||||
|
elif product.is_unrecord_product:
|
||||||
|
recorded += 1
|
||||||
|
|
||||||
|
for p in basket:
|
||||||
|
bproduct = self.get_product(str(p))
|
||||||
|
if bproduct.is_record_product:
|
||||||
|
recorded -= basket[p]["qty"]
|
||||||
|
elif bproduct.is_unrecord_product:
|
||||||
|
recorded += basket[p]["qty"]
|
||||||
|
return recorded
|
||||||
|
|
||||||
|
def is_record_product_ok(self, request, product):
|
||||||
|
return self.customer.can_record_more(
|
||||||
|
self.compute_record_product(request, product)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_ajax(request):
|
||||||
|
# when using the fetch API, the django request.POST dict is empty
|
||||||
|
# this is but a wretched contrivance which strive to replace
|
||||||
|
# the deprecated django is_ajax() method
|
||||||
|
# and which must be replaced as soon as possible
|
||||||
|
# by a proper separation between the api endpoints of the counter
|
||||||
|
return len(request.POST) == 0 and len(request.body) != 0
|
||||||
|
|
||||||
|
def add_product(self, request, q=1, p=None):
|
||||||
|
"""Add a product to the basket
|
||||||
|
q is the quantity passed as integer
|
||||||
|
p is the product id, passed as an integer.
|
||||||
|
"""
|
||||||
|
pid = p or parse_qs(request.body.decode())["product_id"][0]
|
||||||
|
pid = str(pid)
|
||||||
|
price = self.get_price(pid)
|
||||||
|
total = self.sum_basket(request)
|
||||||
|
product: Product = self.get_product(pid)
|
||||||
|
user: User = self.customer.user
|
||||||
|
buying_groups = list(product.buying_groups.values_list("pk", flat=True))
|
||||||
|
can_buy = len(buying_groups) == 0 or any(
|
||||||
|
user.is_in_group(pk=group_id) for group_id in buying_groups
|
||||||
|
)
|
||||||
|
if not can_buy:
|
||||||
|
request.session["not_allowed"] = True
|
||||||
|
return False
|
||||||
|
bq = 0 # Bonus quantity, for trays
|
||||||
|
if (
|
||||||
|
product.tray
|
||||||
|
): # Handle the tray to adjust the quantity q to add and the bonus quantity bq
|
||||||
|
total_qty_mod_6 = self.get_total_quantity_for_pid(request, pid) % 6
|
||||||
|
bq = int((total_qty_mod_6 + q) / 6) # Integer division
|
||||||
|
q -= bq
|
||||||
|
if self.customer.amount < (
|
||||||
|
total + round(q * float(price), 2)
|
||||||
|
): # Check for enough money
|
||||||
|
request.session["not_enough"] = True
|
||||||
|
return False
|
||||||
|
if product.is_unrecord_product and not self.is_record_product_ok(
|
||||||
|
request, product
|
||||||
|
):
|
||||||
|
request.session["not_allowed"] = True
|
||||||
|
return False
|
||||||
|
if product.limit_age >= 18 and not user.date_of_birth:
|
||||||
|
request.session["no_age"] = True
|
||||||
|
return False
|
||||||
|
if product.limit_age >= 18 and user.is_banned_alcohol:
|
||||||
|
request.session["not_allowed"] = True
|
||||||
|
return False
|
||||||
|
if user.is_banned_counter:
|
||||||
|
request.session["not_allowed"] = True
|
||||||
|
return False
|
||||||
|
if (
|
||||||
|
user.date_of_birth and self.customer.user.get_age() < product.limit_age
|
||||||
|
): # Check if affordable
|
||||||
|
request.session["too_young"] = True
|
||||||
|
return False
|
||||||
|
if pid in request.session["basket"]: # Add if already in basket
|
||||||
|
request.session["basket"][pid]["qty"] += q
|
||||||
|
request.session["basket"][pid]["bonus_qty"] += bq
|
||||||
|
else: # or create if not
|
||||||
|
request.session["basket"][pid] = {
|
||||||
|
"qty": q,
|
||||||
|
"price": int(price * 100),
|
||||||
|
"bonus_qty": bq,
|
||||||
|
}
|
||||||
|
request.session.modified = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def del_product(self, request):
|
||||||
|
"""Delete a product from the basket."""
|
||||||
|
pid = parse_qs(request.body.decode())["product_id"][0]
|
||||||
|
product = self.get_product(pid)
|
||||||
|
if pid in request.session["basket"]:
|
||||||
|
if (
|
||||||
|
product.tray
|
||||||
|
and (self.get_total_quantity_for_pid(request, pid) % 6 == 0)
|
||||||
|
and request.session["basket"][pid]["bonus_qty"]
|
||||||
|
):
|
||||||
|
request.session["basket"][pid]["bonus_qty"] -= 1
|
||||||
|
else:
|
||||||
|
request.session["basket"][pid]["qty"] -= 1
|
||||||
|
if request.session["basket"][pid]["qty"] <= 0:
|
||||||
|
del request.session["basket"][pid]
|
||||||
|
request.session.modified = True
|
||||||
|
|
||||||
|
def parse_code(self, request):
|
||||||
|
"""Parse the string entered by the barman.
|
||||||
|
|
||||||
|
This can be of two forms :
|
||||||
|
- `<str>`, where the string is the code of the product
|
||||||
|
- `<int>X<str>`, where the integer is the quantity and str the code.
|
||||||
|
"""
|
||||||
|
string = parse_qs(request.body.decode()).get("code", [""])[0].upper()
|
||||||
|
if string == "FIN":
|
||||||
|
return self.finish(request)
|
||||||
|
elif string == "ANN":
|
||||||
|
return self.cancel(request)
|
||||||
|
regex = re.compile(r"^((?P<nb>[0-9]+)X)?(?P<code>[A-Z0-9]+)$")
|
||||||
|
m = regex.match(string)
|
||||||
|
if m is not None:
|
||||||
|
nb = m.group("nb")
|
||||||
|
code = m.group("code")
|
||||||
|
nb = int(nb) if nb is not None else 1
|
||||||
|
p = self.object.products.filter(code=code).first()
|
||||||
|
if p is not None:
|
||||||
|
self.add_product(request, nb, p.id)
|
||||||
|
context = self.get_context_data(object=self.object)
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
def finish(self, request):
|
||||||
|
"""Finish the click session, and validate the basket."""
|
||||||
|
with transaction.atomic():
|
||||||
|
request.session["last_basket"] = []
|
||||||
|
if self.sum_basket(request) > self.customer.amount:
|
||||||
|
raise DataError(_("You have not enough money to buy all the basket"))
|
||||||
|
|
||||||
|
for pid, infos in request.session["basket"].items():
|
||||||
|
# This duplicates code for DB optimization (prevent to load many times the same object)
|
||||||
|
p = Product.objects.filter(pk=pid).first()
|
||||||
|
if self.customer_is_barman():
|
||||||
|
uprice = p.special_selling_price
|
||||||
|
else:
|
||||||
|
uprice = p.selling_price
|
||||||
|
request.session["last_basket"].append(
|
||||||
|
"%d x %s" % (infos["qty"] + infos["bonus_qty"], p.name)
|
||||||
|
)
|
||||||
|
s = Selling(
|
||||||
|
label=p.name,
|
||||||
|
product=p,
|
||||||
|
club=p.club,
|
||||||
|
counter=self.object,
|
||||||
|
unit_price=uprice,
|
||||||
|
quantity=infos["qty"],
|
||||||
|
seller=self.operator,
|
||||||
|
customer=self.customer,
|
||||||
|
)
|
||||||
|
s.save()
|
||||||
|
if infos["bonus_qty"]:
|
||||||
|
s = Selling(
|
||||||
|
label=p.name + " (Plateau)",
|
||||||
|
product=p,
|
||||||
|
club=p.club,
|
||||||
|
counter=self.object,
|
||||||
|
unit_price=0,
|
||||||
|
quantity=infos["bonus_qty"],
|
||||||
|
seller=self.operator,
|
||||||
|
customer=self.customer,
|
||||||
|
)
|
||||||
|
s.save()
|
||||||
|
self.customer.recorded_products -= self.compute_record_product(request)
|
||||||
|
self.customer.save()
|
||||||
|
request.session["last_customer"] = self.customer.user.get_display_name()
|
||||||
|
request.session["last_total"] = "%0.2f" % self.sum_basket(request)
|
||||||
|
request.session["new_customer_amount"] = str(self.customer.amount)
|
||||||
|
del request.session["basket"]
|
||||||
|
request.session.modified = True
|
||||||
|
kwargs = {"counter_id": self.object.id}
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self, request):
|
||||||
|
"""Cancel the click session."""
|
||||||
|
kwargs = {"counter_id": self.object.id}
|
||||||
|
request.session.pop("basket", None)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy("counter:details", args=self.args, kwargs=kwargs)
|
||||||
|
)
|
||||||
|
|
||||||
|
def refill(self, request):
|
||||||
|
"""Refill the customer's account."""
|
||||||
|
if not self.object.can_refill():
|
||||||
|
raise PermissionDenied
|
||||||
|
form = RefillForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.instance.counter = self.object
|
||||||
|
form.instance.operator = self.operator
|
||||||
|
form.instance.customer = self.customer
|
||||||
|
form.instance.save()
|
||||||
|
else:
|
||||||
|
self.refill_form = form
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add customer to the context."""
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
products = self.object.products.select_related("product_type")
|
||||||
|
if self.customer_is_barman():
|
||||||
|
products = products.annotate(price=F("special_selling_price"))
|
||||||
|
else:
|
||||||
|
products = products.annotate(price=F("selling_price"))
|
||||||
|
kwargs["products"] = products
|
||||||
|
kwargs["categories"] = {}
|
||||||
|
for product in kwargs["products"]:
|
||||||
|
if product.product_type:
|
||||||
|
kwargs["categories"].setdefault(product.product_type, []).append(
|
||||||
|
product
|
||||||
|
)
|
||||||
|
kwargs["customer"] = self.customer
|
||||||
|
kwargs["basket_total"] = self.sum_basket(self.request)
|
||||||
|
kwargs["refill_form"] = self.refill_form or RefillForm()
|
||||||
|
kwargs["barmens_can_refill"] = self.object.can_refill()
|
||||||
|
kwargs["student_card_fragment"] = StudentCardFormView.get_template_data(
|
||||||
|
self.customer
|
||||||
|
).render(self.request)
|
||||||
|
return kwargs
|
141
counter/views/eticket.py
Normal file
141
counter/views/eticket.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
from django.http import Http404, HttpResponse
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import DetailView, ListView
|
||||||
|
from django.views.generic.edit import CreateView, UpdateView
|
||||||
|
|
||||||
|
from core.views import CanViewMixin
|
||||||
|
from counter.forms import EticketForm
|
||||||
|
from counter.models import Eticket, Selling
|
||||||
|
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||||
|
|
||||||
|
|
||||||
|
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
|
||||||
|
"""A list view for the admins."""
|
||||||
|
|
||||||
|
model = Eticket
|
||||||
|
template_name = "counter/eticket_list.jinja"
|
||||||
|
ordering = ["id"]
|
||||||
|
current_tab = "etickets"
|
||||||
|
|
||||||
|
|
||||||
|
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
|
||||||
|
"""Create an eticket."""
|
||||||
|
|
||||||
|
model = Eticket
|
||||||
|
template_name = "core/create.jinja"
|
||||||
|
form_class = EticketForm
|
||||||
|
current_tab = "etickets"
|
||||||
|
|
||||||
|
|
||||||
|
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
|
||||||
|
"""Edit an eticket."""
|
||||||
|
|
||||||
|
model = Eticket
|
||||||
|
template_name = "core/edit.jinja"
|
||||||
|
form_class = EticketForm
|
||||||
|
pk_url_kwarg = "eticket_id"
|
||||||
|
current_tab = "etickets"
|
||||||
|
|
||||||
|
|
||||||
|
class EticketPDFView(CanViewMixin, DetailView):
|
||||||
|
"""Display the PDF of an eticket."""
|
||||||
|
|
||||||
|
model = Selling
|
||||||
|
pk_url_kwarg = "selling_id"
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
from reportlab.graphics import renderPDF
|
||||||
|
from reportlab.graphics.barcode.qr import QrCodeWidget
|
||||||
|
from reportlab.graphics.shapes import Drawing
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
if not (
|
||||||
|
hasattr(self.object, "product") and hasattr(self.object.product, "eticket")
|
||||||
|
):
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
eticket = self.object.product.eticket
|
||||||
|
user = self.object.customer.user
|
||||||
|
code = "%s %s %s %s" % (
|
||||||
|
self.object.customer.user.id,
|
||||||
|
self.object.product.id,
|
||||||
|
self.object.id,
|
||||||
|
self.object.quantity,
|
||||||
|
)
|
||||||
|
code += " " + eticket.get_hash(code)[:8].upper()
|
||||||
|
response = HttpResponse(content_type="application/pdf")
|
||||||
|
response["Content-Disposition"] = 'filename="eticket.pdf"'
|
||||||
|
p = canvas.Canvas(response)
|
||||||
|
p.setTitle("Eticket")
|
||||||
|
im = ImageReader("core/static/core/img/eticket.jpg")
|
||||||
|
width, height = im.getSize()
|
||||||
|
size = max(width, height)
|
||||||
|
width = 8 * cm * width / size
|
||||||
|
height = 8 * cm * height / size
|
||||||
|
p.drawImage(im, 10 * cm, 25 * cm, width, height)
|
||||||
|
if eticket.banner:
|
||||||
|
im = ImageReader(eticket.banner)
|
||||||
|
width, height = im.getSize()
|
||||||
|
size = max(width, height)
|
||||||
|
width = 6 * cm * width / size
|
||||||
|
height = 6 * cm * height / size
|
||||||
|
p.drawImage(im, 1 * cm, 25 * cm, width, height)
|
||||||
|
if user.profile_pict:
|
||||||
|
im = ImageReader(user.profile_pict.file)
|
||||||
|
width, height = im.getSize()
|
||||||
|
size = max(width, height)
|
||||||
|
width = 150 * width / size
|
||||||
|
height = 150 * height / size
|
||||||
|
p.drawImage(im, 10.5 * cm - width / 2, 16 * cm, width, height)
|
||||||
|
if eticket.event_title:
|
||||||
|
p.setFont("Helvetica-Bold", 20)
|
||||||
|
p.drawCentredString(10.5 * cm, 23.6 * cm, eticket.event_title)
|
||||||
|
if eticket.event_date:
|
||||||
|
p.setFont("Helvetica-Bold", 16)
|
||||||
|
p.drawCentredString(
|
||||||
|
10.5 * cm, 22.6 * cm, eticket.event_date.strftime("%d %b %Y")
|
||||||
|
) # FIXME with a locale
|
||||||
|
p.setFont("Helvetica-Bold", 14)
|
||||||
|
p.drawCentredString(
|
||||||
|
10.5 * cm,
|
||||||
|
15 * cm,
|
||||||
|
"%s : %d %s"
|
||||||
|
% (user.get_display_name(), self.object.quantity, str(_("people(s)"))),
|
||||||
|
)
|
||||||
|
p.setFont("Courier-Bold", 14)
|
||||||
|
qrcode = QrCodeWidget(code)
|
||||||
|
bounds = qrcode.getBounds()
|
||||||
|
width = bounds[2] - bounds[0]
|
||||||
|
height = bounds[3] - bounds[1]
|
||||||
|
d = Drawing(260, 260, transform=[260.0 / width, 0, 0, 260.0 / height, 0, 0])
|
||||||
|
d.add(qrcode)
|
||||||
|
renderPDF.draw(d, p, 10.5 * cm - 130, 6.1 * cm)
|
||||||
|
p.drawCentredString(10.5 * cm, 6 * cm, code)
|
||||||
|
|
||||||
|
partners = ImageReader("core/static/core/img/partners.png")
|
||||||
|
width, height = partners.getSize()
|
||||||
|
size = max(width, height)
|
||||||
|
width = width * 2 / 3
|
||||||
|
height = height * 2 / 3
|
||||||
|
p.drawImage(partners, 0 * cm, 0 * cm, width, height)
|
||||||
|
|
||||||
|
p.showPage()
|
||||||
|
p.save()
|
||||||
|
return response
|
147
counter/views/home.py
Normal file
147
counter/views/home.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic import DetailView
|
||||||
|
from django.views.generic.edit import FormMixin, ProcessFormView
|
||||||
|
|
||||||
|
from core.views import CanViewMixin
|
||||||
|
from core.views.forms import LoginForm
|
||||||
|
from counter.forms import GetUserForm
|
||||||
|
from counter.models import Counter
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
from counter.views.mixins import CounterTabsMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CounterMain(
|
||||||
|
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
|
||||||
|
):
|
||||||
|
"""The public (barman) view."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
template_name = "counter/counter_main.jinja"
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
form_class = (
|
||||||
|
GetUserForm # Form to enter a client code and get the corresponding user id
|
||||||
|
)
|
||||||
|
current_tab = "counter"
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
if self.object.type == "BAR" and not (
|
||||||
|
"counter_token" in self.request.session
|
||||||
|
and self.request.session["counter_token"] == self.object.token
|
||||||
|
): # Check the token to avoid the bar to be stolen
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse_lazy(
|
||||||
|
"counter:details",
|
||||||
|
args=self.args,
|
||||||
|
kwargs={"counter_id": self.object.id},
|
||||||
|
)
|
||||||
|
+ "?bad_location"
|
||||||
|
)
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""We handle here the login form for the barman."""
|
||||||
|
if self.request.method == "POST":
|
||||||
|
self.object = self.get_object()
|
||||||
|
self.object.update_activity()
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs["login_form"] = LoginForm()
|
||||||
|
kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True
|
||||||
|
kwargs[
|
||||||
|
"login_form"
|
||||||
|
].cleaned_data = {} # add_error fails if there are no cleaned_data
|
||||||
|
if "credentials" in self.request.GET:
|
||||||
|
kwargs["login_form"].add_error(None, _("Bad credentials"))
|
||||||
|
if "sellers" in self.request.GET:
|
||||||
|
kwargs["login_form"].add_error(None, _("User is not barman"))
|
||||||
|
kwargs["form"] = self.get_form()
|
||||||
|
kwargs["form"].cleaned_data = {} # same as above
|
||||||
|
if "bad_location" in self.request.GET:
|
||||||
|
kwargs["form"].add_error(
|
||||||
|
None, _("Bad location, someone is already logged in somewhere else")
|
||||||
|
)
|
||||||
|
if self.object.type == "BAR":
|
||||||
|
kwargs["barmen"] = self.object.barmen_list
|
||||||
|
elif self.request.user.is_authenticated:
|
||||||
|
kwargs["barmen"] = [self.request.user]
|
||||||
|
if "last_basket" in self.request.session:
|
||||||
|
kwargs["last_basket"] = self.request.session.pop("last_basket")
|
||||||
|
kwargs["last_customer"] = self.request.session.pop("last_customer")
|
||||||
|
kwargs["last_total"] = self.request.session.pop("last_total")
|
||||||
|
kwargs["new_customer_amount"] = self.request.session.pop(
|
||||||
|
"new_customer_amount"
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""We handle here the redirection, passing the user id of the asked customer."""
|
||||||
|
self.kwargs["user_id"] = form.cleaned_data["user_id"]
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
|
||||||
|
"""Provide the last operations to allow barmen to delete them."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "counter/last_ops.jinja"
|
||||||
|
current_tab = "last_ops"
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""We have here again a very particular right handling."""
|
||||||
|
self.object = self.get_object()
|
||||||
|
if is_logged_in_counter(request) and self.object.barmen_list:
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("counter:details", kwargs={"counter_id": self.object.id})
|
||||||
|
+ "?bad_location"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add form to the context."""
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
threshold = timezone.now() - timedelta(
|
||||||
|
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
|
||||||
|
)
|
||||||
|
kwargs["last_refillings"] = (
|
||||||
|
self.object.refillings.filter(date__gte=threshold)
|
||||||
|
.select_related("operator", "customer__user")
|
||||||
|
.order_by("-id")[:20]
|
||||||
|
)
|
||||||
|
kwargs["last_sellings"] = (
|
||||||
|
self.object.sellings.filter(date__gte=threshold)
|
||||||
|
.select_related("seller", "customer__user")
|
||||||
|
.order_by("-id")[:20]
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class CounterActivityView(DetailView):
|
||||||
|
"""Show the bar activity."""
|
||||||
|
|
||||||
|
model = Counter
|
||||||
|
pk_url_kwarg = "counter_id"
|
||||||
|
template_name = "counter/activity.jinja"
|
89
counter/views/invoice.py
Normal file
89
counter/views/invoice.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import timezone as tz
|
||||||
|
|
||||||
|
from django.db.models import F
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from accounting.models import CurrencyField
|
||||||
|
from counter.models import Refilling, Selling
|
||||||
|
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
|
||||||
|
template_name = "counter/invoices_call.jinja"
|
||||||
|
current_tab = "invoices_call"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add sums to the context."""
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
|
||||||
|
if "month" in self.request.GET:
|
||||||
|
start_date = datetime.strptime(self.request.GET["month"], "%Y-%m")
|
||||||
|
else:
|
||||||
|
start_date = datetime(
|
||||||
|
year=timezone.now().year,
|
||||||
|
month=(timezone.now().month + 10) % 12 + 1,
|
||||||
|
day=1,
|
||||||
|
)
|
||||||
|
start_date = start_date.replace(tzinfo=tz.utc)
|
||||||
|
end_date = (start_date + timedelta(days=32)).replace(
|
||||||
|
day=1, hour=0, minute=0, microsecond=0
|
||||||
|
)
|
||||||
|
from django.db.models import Case, Sum, When
|
||||||
|
|
||||||
|
kwargs["sum_cb"] = sum(
|
||||||
|
[
|
||||||
|
r.amount
|
||||||
|
for r in Refilling.objects.filter(
|
||||||
|
payment_method="CARD",
|
||||||
|
is_validated=True,
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
kwargs["sum_cb"] += sum(
|
||||||
|
[
|
||||||
|
s.quantity * s.unit_price
|
||||||
|
for s in Selling.objects.filter(
|
||||||
|
payment_method="CARD",
|
||||||
|
is_validated=True,
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lte=end_date,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
kwargs["start_date"] = start_date
|
||||||
|
kwargs["sums"] = (
|
||||||
|
Selling.objects.values("club__name")
|
||||||
|
.annotate(
|
||||||
|
selling_sum=Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
date__gte=start_date,
|
||||||
|
date__lt=end_date,
|
||||||
|
then=F("unit_price") * F("quantity"),
|
||||||
|
),
|
||||||
|
output_field=CurrencyField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exclude(selling_sum=None)
|
||||||
|
.order_by("-selling_sum")
|
||||||
|
)
|
||||||
|
return kwargs
|
121
counter/views/mixins.py
Normal file
121
counter/views/mixins.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.generic.base import View
|
||||||
|
|
||||||
|
from core.views import TabedViewMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CounterAdminMixin(View):
|
||||||
|
"""Protect counter admin section."""
|
||||||
|
|
||||||
|
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
|
||||||
|
edit_club = []
|
||||||
|
|
||||||
|
def _test_group(self, user):
|
||||||
|
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
|
||||||
|
|
||||||
|
def _test_club(self, user):
|
||||||
|
return any(c.can_be_edited_by(user) for c in self.edit_club)
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
if not (
|
||||||
|
request.user.is_root
|
||||||
|
or self._test_group(request.user)
|
||||||
|
or self._test_club(request.user)
|
||||||
|
):
|
||||||
|
raise PermissionDenied
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterTabsMixin(TabedViewMixin):
|
||||||
|
def get_tabs_title(self):
|
||||||
|
return self.object
|
||||||
|
|
||||||
|
def get_list_of_tabs(self):
|
||||||
|
tab_list = [
|
||||||
|
{
|
||||||
|
"url": reverse_lazy(
|
||||||
|
"counter:details", kwargs={"counter_id": self.object.id}
|
||||||
|
),
|
||||||
|
"slug": "counter",
|
||||||
|
"name": _("Counter"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if self.object.type == "BAR":
|
||||||
|
tab_list.append(
|
||||||
|
{
|
||||||
|
"url": reverse_lazy(
|
||||||
|
"counter:cash_summary", kwargs={"counter_id": self.object.id}
|
||||||
|
),
|
||||||
|
"slug": "cash_summary",
|
||||||
|
"name": _("Cash summary"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tab_list.append(
|
||||||
|
{
|
||||||
|
"url": reverse_lazy(
|
||||||
|
"counter:last_ops", kwargs={"counter_id": self.object.id}
|
||||||
|
),
|
||||||
|
"slug": "last_ops",
|
||||||
|
"name": _("Last operations"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tab_list
|
||||||
|
|
||||||
|
|
||||||
|
class CounterAdminTabsMixin(TabedViewMixin):
|
||||||
|
tabs_title = _("Counter administration")
|
||||||
|
list_of_tabs = [
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:admin_list"),
|
||||||
|
"slug": "counters",
|
||||||
|
"name": _("Counters"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:product_list"),
|
||||||
|
"slug": "products",
|
||||||
|
"name": _("Products"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:product_list_archived"),
|
||||||
|
"slug": "archive",
|
||||||
|
"name": _("Archived products"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:producttype_list"),
|
||||||
|
"slug": "product_types",
|
||||||
|
"name": _("Product types"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:cash_summary_list"),
|
||||||
|
"slug": "cash_summary",
|
||||||
|
"name": _("Cash register summaries"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:invoices_call"),
|
||||||
|
"slug": "invoices_call",
|
||||||
|
"name": _("Invoices call"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": reverse_lazy("counter:eticket_list"),
|
||||||
|
"slug": "etickets",
|
||||||
|
"name": _("Etickets"),
|
||||||
|
},
|
||||||
|
]
|
113
counter/views/student_card.py
Normal file
113
counter/views/student_card.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2023 © AE UTBM
|
||||||
|
# ae@utbm.fr / ae.info@utbm.fr
|
||||||
|
#
|
||||||
|
# This file is part of the website of the UTBM Student Association (AE UTBM),
|
||||||
|
# https://ae.utbm.fr.
|
||||||
|
#
|
||||||
|
# You can find the source code of the website at https://github.com/ae-utbm/sith
|
||||||
|
#
|
||||||
|
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
|
||||||
|
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
|
||||||
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from django.views.generic.edit import DeleteView, FormView
|
||||||
|
|
||||||
|
from core.utils import FormFragmentTemplateData
|
||||||
|
from core.views import can_edit
|
||||||
|
from counter.forms import StudentCardForm
|
||||||
|
from counter.models import Customer, StudentCard
|
||||||
|
from counter.utils import is_logged_in_counter
|
||||||
|
|
||||||
|
|
||||||
|
class StudentCardDeleteView(DeleteView):
|
||||||
|
"""View used to delete a card from a user. This is a fragment view !"""
|
||||||
|
|
||||||
|
model = StudentCard
|
||||||
|
template_name = "counter/fragments/delete_student_card.jinja"
|
||||||
|
|
||||||
|
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||||
|
self.customer = get_object_or_404(Customer, pk=kwargs["customer_id"])
|
||||||
|
if not is_logged_in_counter(request) and not can_edit(
|
||||||
|
self.get_object(), request.user
|
||||||
|
):
|
||||||
|
raise PermissionDenied()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["action"] = self.request.path
|
||||||
|
context["action_cancel"] = self.get_success_url()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
if not hasattr(self.customer, "student_card"):
|
||||||
|
raise Http404(
|
||||||
|
_("%(name)s has no registered student card")
|
||||||
|
% {"name": self.customer.user.get_full_name()}
|
||||||
|
)
|
||||||
|
return self.customer.student_card
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
return reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": self.customer.pk}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StudentCardFormView(FormView):
|
||||||
|
"""Add a new student card. This is a fragment view !"""
|
||||||
|
|
||||||
|
form_class = StudentCardForm
|
||||||
|
template_name = "counter/fragments/create_student_card.jinja"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_template_data(
|
||||||
|
cls, customer: Customer
|
||||||
|
) -> FormFragmentTemplateData[StudentCardForm]:
|
||||||
|
"""Get necessary data to pre-render the fragment"""
|
||||||
|
return FormFragmentTemplateData(
|
||||||
|
form=cls.form_class(),
|
||||||
|
template=cls.template_name,
|
||||||
|
context={
|
||||||
|
"action": reverse(
|
||||||
|
"counter:add_student_card", kwargs={"customer_id": customer.pk}
|
||||||
|
),
|
||||||
|
"customer": customer,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||||
|
self.customer = get_object_or_404(
|
||||||
|
Customer.objects.select_related("student_card"), pk=kwargs["customer_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_logged_in_counter(request) and not StudentCard.can_create(
|
||||||
|
self.customer, request.user
|
||||||
|
):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form: StudentCardForm) -> HttpResponse:
|
||||||
|
data = form.clean()
|
||||||
|
StudentCard.objects.update_or_create(
|
||||||
|
customer=self.customer, defaults={"uid": data["uid"]}
|
||||||
|
)
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
data = self.get_template_data(self.customer)
|
||||||
|
context.update(data.context)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
return self.request.path
|
@ -145,10 +145,8 @@ class LaunderetteBookView(CanViewMixin, DetailView):
|
|||||||
and self.check_slot("WASHING", h)
|
and self.check_slot("WASHING", h)
|
||||||
and self.check_slot("DRYING", h + timedelta(hours=1))
|
and self.check_slot("DRYING", h + timedelta(hours=1))
|
||||||
)
|
)
|
||||||
or self.slot_type == "WASHING"
|
or (self.slot_type == "WASHING" and self.check_slot("WASHING", h))
|
||||||
and self.check_slot("WASHING", h)
|
or (self.slot_type == "DRYING" and self.check_slot("DRYING", h))
|
||||||
or self.slot_type == "DRYING"
|
|
||||||
and self.check_slot("DRYING", h)
|
|
||||||
):
|
):
|
||||||
free = True
|
free = True
|
||||||
if free and datetime.now().replace(tzinfo=tz.utc) < h:
|
if free and datetime.now().replace(tzinfo=tz.utc) < h:
|
||||||
|
File diff suppressed because it is too large
Load Diff
613
package-lock.json
generated
613
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -34,8 +34,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||||
"@fullcalendar/core": "^6.1.15",
|
|
||||||
"@fullcalendar/timegrid": "^6.1.15",
|
|
||||||
"@hey-api/client-fetch": "^0.4.0",
|
"@hey-api/client-fetch": "^0.4.0",
|
||||||
"@sentry/browser": "^8.34.0",
|
"@sentry/browser": "^8.34.0",
|
||||||
"@zip.js/zip.js": "^2.7.52",
|
"@zip.js/zip.js": "^2.7.52",
|
||||||
|
1415
poetry.lock
generated
1415
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -21,41 +21,38 @@ license = "GPL-3.0-only"
|
|||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.12"
|
python = "^3.12"
|
||||||
Django = "^4.2.14"
|
Django = "^4.2.17"
|
||||||
django-ninja = "^1.3.0"
|
django-ninja = "^1.3.0"
|
||||||
django-ninja-extra = "^0.21.4"
|
django-ninja-extra = "^0.21.8"
|
||||||
Pillow = "^10.4.0"
|
Pillow = "^11.0.0"
|
||||||
mistune = "^3.0.2"
|
mistune = "^3.0.2"
|
||||||
django-jinja = "^2.11"
|
django-jinja = "^2.11.0"
|
||||||
cryptography = "^43.0.0"
|
cryptography = "^44.0.0"
|
||||||
django-phonenumber-field = "^8.0.0"
|
django-phonenumber-field = "^8.0.0"
|
||||||
phonenumbers = "^8.13"
|
phonenumbers = "^8.13.52"
|
||||||
reportlab = "^4.2"
|
reportlab = "^4.2.5"
|
||||||
django-haystack = "^3.2.1"
|
django-haystack = "^3.3.0"
|
||||||
xapian-haystack = "^3.0.1"
|
xapian-haystack = "^3.1.0"
|
||||||
libsass = "^0.23"
|
libsass = "^0.23.0"
|
||||||
django-ordered-model = "^3.7"
|
django-ordered-model = "^3.7.4"
|
||||||
django-simple-captcha = "^0.6.0"
|
django-simple-captcha = "^0.6.0"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.9.0.post0"
|
||||||
sentry-sdk = "^2.16.0"
|
sentry-sdk = "^2.19.2"
|
||||||
Jinja2 = "^3.1"
|
Jinja2 = "^3.1.4"
|
||||||
django-countries = "^7.6.1"
|
django-countries = "^7.6.1"
|
||||||
dict2xml = "^1.7.3"
|
dict2xml = "^1.7.6"
|
||||||
Sphinx = "^5" # Needed for building xapian
|
Sphinx = "^5" # Needed for building xapian
|
||||||
tomli = "^2.0.1"
|
tomli = "^2.2.1"
|
||||||
django-honeypot = "^1.2.1"
|
django-honeypot = "^1.2.1"
|
||||||
# When I introduced pydantic-extra-types, I needed *right now*
|
pydantic-extra-types = "^2.10.1"
|
||||||
# the PhoneNumberValidator class which was on the master branch but not released yet.
|
|
||||||
# Once it's released, switch this to a regular version.
|
|
||||||
pydantic-extra-types = { git = "https://github.com/pydantic/pydantic-extra-types.git", rev = "58db4b0" }
|
|
||||||
|
|
||||||
[tool.poetry.group.prod.dependencies]
|
[tool.poetry.group.prod.dependencies]
|
||||||
# deps used in prod, but unnecessary for development
|
# deps used in prod, but unnecessary for development
|
||||||
|
|
||||||
# The C extra triggers compilation against sytem libs during install.
|
# The C extra triggers compilation against system libs during install.
|
||||||
# Removing it would switch psycopg to a slower full-python implementation
|
# Removing it would switch psycopg to a slower full-python implementation
|
||||||
psycopg = {extras = ["c"], version = "^3.2.1"}
|
psycopg = {extras = ["c"], version = "^3.2.3"}
|
||||||
redis = {extras = ["hiredis"], version = "^5.0.8"}
|
redis = {extras = ["hiredis"], version = "^5.2.0"}
|
||||||
|
|
||||||
[tool.poetry.group.prod]
|
[tool.poetry.group.prod]
|
||||||
optional = true
|
optional = true
|
||||||
@ -63,28 +60,28 @@ optional = true
|
|||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
# deps used for development purposes, but unneeded in prod
|
# deps used for development purposes, but unneeded in prod
|
||||||
django-debug-toolbar = "^4.4.6"
|
django-debug-toolbar = "^4.4.6"
|
||||||
ipython = "^8.26.0"
|
ipython = "^8.30.0"
|
||||||
pre-commit = "^4.0.1"
|
pre-commit = "^4.0.1"
|
||||||
ruff = "^0.6.9" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
|
ruff = "^0.8.3" # Version used in pipeline is controlled by pre-commit hooks in .pre-commit.config.yaml
|
||||||
djhtml = "^3.0.6"
|
djhtml = "^3.0.7"
|
||||||
faker = "^30.3.0"
|
faker = "^33.1.0"
|
||||||
rjsmin = "^1.2.2"
|
rjsmin = "^1.2.3"
|
||||||
|
|
||||||
[tool.poetry.group.tests.dependencies]
|
[tool.poetry.group.tests.dependencies]
|
||||||
# deps used for testing purposes
|
# deps used for testing purposes
|
||||||
freezegun = "^1.5.1" # used to test time-dependent code
|
freezegun = "^1.5.1" # used to test time-dependent code
|
||||||
pytest = "^8.3.2"
|
pytest = "^8.3.4"
|
||||||
pytest-cov = "^5.0.0"
|
pytest-cov = "^6.0.0"
|
||||||
pytest-django = "^4.9.0"
|
pytest-django = "^4.9.0"
|
||||||
model-bakery = "^1.20.0"
|
model-bakery = "^1.20.0"
|
||||||
|
|
||||||
[tool.poetry.group.docs.dependencies]
|
[tool.poetry.group.docs.dependencies]
|
||||||
# deps used to work on the documentation
|
# deps used to work on the documentation
|
||||||
mkdocs = "^1.6.1"
|
mkdocs = "^1.6.1"
|
||||||
mkdocs-material = "^9.5.40"
|
mkdocs-material = "^9.5.47"
|
||||||
mkdocstrings = "^0.26.2"
|
mkdocstrings = "^0.27.0"
|
||||||
mkdocstrings-python = "^1.12.0"
|
mkdocstrings-python = "^1.12.2"
|
||||||
mkdocs-include-markdown-plugin = "^6.2.2"
|
mkdocs-include-markdown-plugin = "^7.1.2"
|
||||||
|
|
||||||
[tool.poetry.group.docs]
|
[tool.poetry.group.docs]
|
||||||
optional = true
|
optional = true
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertNumQueries
|
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import RealGroup, SithFile, User
|
from core.models import RealGroup, SithFile, User
|
||||||
@ -128,9 +128,11 @@ class TestPictureSearch(TestSas):
|
|||||||
def test_num_queries(self):
|
def test_num_queries(self):
|
||||||
"""Test that the number of queries is stable."""
|
"""Test that the number of queries is stable."""
|
||||||
self.client.force_login(subscriber_user.make())
|
self.client.force_login(subscriber_user.make())
|
||||||
with assertNumQueries(5):
|
cache.clear()
|
||||||
|
with self.assertNumQueries(7):
|
||||||
|
# 2 requests to create the session
|
||||||
# 1 request to fetch the user from the db
|
# 1 request to fetch the user from the db
|
||||||
# 2 requests to check the user permissions
|
# 2 requests to check the user permissions, depends on the db engine
|
||||||
# 1 request to fetch the pictures
|
# 1 request to fetch the pictures
|
||||||
# 1 request to count the total number of items in the pagination
|
# 1 request to count the total number of items in the pagination
|
||||||
self.client.get(self.url)
|
self.client.get(self.url)
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
from core.views.widgets.select import (
|
from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple
|
||||||
AutoCompleteSelect,
|
|
||||||
AutoCompleteSelectMultiple,
|
|
||||||
)
|
|
||||||
from sas.models import Album
|
from sas.models import Album
|
||||||
from sas.schemas import AlbumSchema
|
from sas.schemas import AlbumSchema
|
||||||
|
|
||||||
|
@ -95,7 +95,6 @@ INSTALLED_APPS = (
|
|||||||
"com",
|
"com",
|
||||||
"election",
|
"election",
|
||||||
"forum",
|
"forum",
|
||||||
"stock",
|
|
||||||
"trombi",
|
"trombi",
|
||||||
"matmat",
|
"matmat",
|
||||||
"pedagogy",
|
"pedagogy",
|
||||||
@ -164,7 +163,6 @@ TEMPLATES = [
|
|||||||
"ProductType": "counter.models.ProductType",
|
"ProductType": "counter.models.ProductType",
|
||||||
"timezone": "django.utils.timezone",
|
"timezone": "django.utils.timezone",
|
||||||
"get_sith": "com.views.sith",
|
"get_sith": "com.views.sith",
|
||||||
"get_current_language": "django.views.i18n.get_language",
|
|
||||||
},
|
},
|
||||||
"bytecode_cache": {
|
"bytecode_cache": {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
@ -353,6 +351,9 @@ SITH_SEMESTER_START_SPRING = (2, 15) # 15 February
|
|||||||
# Used to determine the valid promos
|
# Used to determine the valid promos
|
||||||
SITH_SCHOOL_START_YEAR = 1999
|
SITH_SCHOOL_START_YEAR = 1999
|
||||||
|
|
||||||
|
# id of the Root account
|
||||||
|
SITH_ROOT_USER_ID = 0
|
||||||
|
|
||||||
SITH_GROUP_ROOT_ID = 1
|
SITH_GROUP_ROOT_ID = 1
|
||||||
SITH_GROUP_PUBLIC_ID = 2
|
SITH_GROUP_PUBLIC_ID = 2
|
||||||
SITH_GROUP_SUBSCRIBERS_ID = 3
|
SITH_GROUP_SUBSCRIBERS_ID = 3
|
||||||
|
@ -26,7 +26,8 @@ def test_sentry_debug_endpoint(
|
|||||||
expected_error: RaisesContext[ZeroDivisionError] | does_not_raise[None],
|
expected_error: RaisesContext[ZeroDivisionError] | does_not_raise[None],
|
||||||
expected_return_code: int | None,
|
expected_return_code: int | None,
|
||||||
):
|
):
|
||||||
with expected_error, override_settings(
|
with (
|
||||||
SENTRY_DSN=sentry_dsn, SENTRY_ENV=sentry_env
|
expected_error,
|
||||||
|
override_settings(SENTRY_DSN=sentry_dsn, SENTRY_ENV=sentry_env),
|
||||||
):
|
):
|
||||||
assert client.get(reverse("sentry-debug")).status_code == expected_return_code
|
assert client.get(reverse("sentry-debug")).status_code == expected_return_code
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright 2016,2017
|
|
||||||
# - Guillaume "Lo-J" Renaud <renaudg779@gmail.com>
|
|
||||||
# - Skia <skia@libskia.so>
|
|
||||||
#
|
|
||||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
|
||||||
# http://ae.utbm.fr.
|
|
||||||
#
|
|
||||||
# This program is free software; you can redistribute it and/or modify it under
|
|
||||||
# the terms of the GNU General Public License a published by the Free Software
|
|
||||||
# Foundation; either version 3 of the License, or (at your option) any later
|
|
||||||
# version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
||||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
||||||
# details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License along with
|
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
|
||||||
#
|
|
||||||
#
|
|
@ -1,188 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("counter", "0011_auto_20161004_2039")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ShoppingList",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("date", models.DateTimeField(verbose_name="date")),
|
|
||||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
|
||||||
("todo", models.BooleanField(verbose_name="todo")),
|
|
||||||
(
|
|
||||||
"comment",
|
|
||||||
models.TextField(verbose_name="comment", blank=True, null=True),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ShoppingListItem",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
|
||||||
(
|
|
||||||
"tobuy_quantity",
|
|
||||||
models.IntegerField(
|
|
||||||
verbose_name="quantity to buy",
|
|
||||||
help_text="quantity to buy during the next shopping session",
|
|
||||||
default=6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"bought_quantity",
|
|
||||||
models.IntegerField(
|
|
||||||
verbose_name="quantity bought",
|
|
||||||
help_text="quantity bought during the last shopping session",
|
|
||||||
default=0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"shopping_lists",
|
|
||||||
models.ManyToManyField(
|
|
||||||
verbose_name="shopping lists",
|
|
||||||
related_name="shopping_items_to_buy",
|
|
||||||
to="stock.ShoppingList",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Stock",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
|
||||||
(
|
|
||||||
"counter",
|
|
||||||
models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
verbose_name="counter",
|
|
||||||
related_name="stock",
|
|
||||||
to="counter.Counter",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="StockItem",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.AutoField(
|
|
||||||
verbose_name="ID",
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
auto_created=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
|
||||||
(
|
|
||||||
"unit_quantity",
|
|
||||||
models.IntegerField(
|
|
||||||
verbose_name="unit quantity",
|
|
||||||
help_text="number of element in one box",
|
|
||||||
default=0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"effective_quantity",
|
|
||||||
models.IntegerField(
|
|
||||||
verbose_name="effective quantity",
|
|
||||||
help_text="number of box",
|
|
||||||
default=0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"minimal_quantity",
|
|
||||||
models.IntegerField(
|
|
||||||
verbose_name="minimal quantity",
|
|
||||||
help_text=(
|
|
||||||
"if the effective quantity is less than the minimal, "
|
|
||||||
"item is added to the shopping list"
|
|
||||||
),
|
|
||||||
default=1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"stock_owner",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="items",
|
|
||||||
to="stock.Stock",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"type",
|
|
||||||
models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="type",
|
|
||||||
related_name="stock_items",
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
to="counter.ProductType",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="shoppinglistitem",
|
|
||||||
name="stockitem_owner",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
null=True,
|
|
||||||
related_name="shopping_item",
|
|
||||||
to="stock.StockItem",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="shoppinglistitem",
|
|
||||||
name="type",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="type",
|
|
||||||
related_name="shoppinglist_items",
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
to="counter.ProductType",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="shoppinglist",
|
|
||||||
name="stock_owner",
|
|
||||||
field=models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
null=True,
|
|
||||||
related_name="shopping_lists",
|
|
||||||
to="stock.Stock",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,51 +0,0 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-09-18 11:33
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
# This migration is here only to delete all the models
|
|
||||||
# of the stock application and will be removed in a subsequent release
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("stock", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="shoppinglistitem",
|
|
||||||
name="shopping_lists",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="shoppinglistitem",
|
|
||||||
name="stockitem_owner",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="shoppinglistitem",
|
|
||||||
name="type",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="stock",
|
|
||||||
name="counter",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="stockitem",
|
|
||||||
name="stock_owner",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="stockitem",
|
|
||||||
name="type",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="ShoppingList",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="ShoppingListItem",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="Stock",
|
|
||||||
),
|
|
||||||
migrations.DeleteModel(
|
|
||||||
name="StockItem",
|
|
||||||
),
|
|
||||||
]
|
|
124
subscription/forms.py
Normal file
124
subscription/forms.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from core.views.forms import SelectDate, SelectDateTime
|
||||||
|
from core.views.widgets.select import AutoCompleteSelectUser
|
||||||
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionDateForm(forms.Form):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["start_date"] = forms.DateTimeField(
|
||||||
|
label=_("Start date"), widget=SelectDateTime, required=True
|
||||||
|
)
|
||||||
|
self.fields["end_date"] = forms.DateTimeField(
|
||||||
|
label=_("End date"), widget=SelectDateTime, required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
initial = kwargs.pop("initial", {})
|
||||||
|
if "subscription_type" not in initial:
|
||||||
|
initial["subscription_type"] = "deux-semestres"
|
||||||
|
if "payment_method" not in initial:
|
||||||
|
initial["payment_method"] = "CARD"
|
||||||
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.errors:
|
||||||
|
# let django deal with the error messages
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
duration, user = self.instance.semester_duration, self.instance.member
|
||||||
|
self.instance.subscription_start = self.instance.compute_start(
|
||||||
|
duration=duration, user=user
|
||||||
|
)
|
||||||
|
self.instance.subscription_end = self.instance.compute_end(
|
||||||
|
duration=duration, start=self.instance.subscription_start, user=user
|
||||||
|
)
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionNewUserForm(SubscriptionForm):
|
||||||
|
"""Form to create subscriptions with the user they belong to.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```py
|
||||||
|
assert not User.objects.filter(email=request.POST.get("email")).exists()
|
||||||
|
form = SubscriptionNewUserForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
# now the user exists and is subscribed
|
||||||
|
user = User.objects.get(email=request.POST.get("email"))
|
||||||
|
assert user.is_subscribed
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "subscription/forms/create_new_user.html"
|
||||||
|
|
||||||
|
__user_fields = forms.fields_for_model(
|
||||||
|
User,
|
||||||
|
["first_name", "last_name", "email", "date_of_birth"],
|
||||||
|
widgets={"date_of_birth": SelectDate},
|
||||||
|
)
|
||||||
|
first_name = __user_fields["first_name"]
|
||||||
|
last_name = __user_fields["last_name"]
|
||||||
|
email = __user_fields["email"]
|
||||||
|
date_of_birth = __user_fields["date_of_birth"]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Subscription
|
||||||
|
fields = ["subscription_type", "payment_method", "location"]
|
||||||
|
|
||||||
|
field_order = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"date_of_birth",
|
||||||
|
"subscription_type",
|
||||||
|
"payment_method",
|
||||||
|
"location",
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = self.cleaned_data["email"]
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
raise ValidationError(_("A user with that email address already exists"))
|
||||||
|
return email
|
||||||
|
|
||||||
|
def clean(self) -> dict[str, Any]:
|
||||||
|
member = User(
|
||||||
|
first_name=self.cleaned_data.get("first_name"),
|
||||||
|
last_name=self.cleaned_data.get("last_name"),
|
||||||
|
email=self.cleaned_data.get("email"),
|
||||||
|
date_of_birth=self.cleaned_data.get("date_of_birth"),
|
||||||
|
)
|
||||||
|
member.generate_username()
|
||||||
|
member.set_password(secrets.token_urlsafe(nbytes=10))
|
||||||
|
self.instance.member = member
|
||||||
|
return super().clean()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.errors:
|
||||||
|
# let django deal with the error messages
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
self.instance.member.save()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionExistingUserForm(SubscriptionForm):
|
||||||
|
"""Form to add a subscription to an existing user."""
|
||||||
|
|
||||||
|
template_name = "subscription/forms/create_existing_user.html"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Subscription
|
||||||
|
fields = ["member", "subscription_type", "payment_method", "location"]
|
||||||
|
widgets = {"member": AutoCompleteSelectUser}
|
@ -93,22 +93,23 @@ class Subscription(models.Model):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
today = localdate()
|
today = localdate()
|
||||||
active_subscriptions = Subscription.objects.exclude(pk=self.pk).filter(
|
threshold = timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
|
||||||
subscription_start__gte=today, subscription_end__lte=today
|
# a user may subscribe if :
|
||||||
|
# - he/she is not currently subscribed
|
||||||
|
# - its current subscription ends in less than a few weeks
|
||||||
|
overlapping_subscriptions = Subscription.objects.exclude(pk=self.pk).filter(
|
||||||
|
member=self.member,
|
||||||
|
subscription_start__lte=today,
|
||||||
|
subscription_end__gte=today + threshold,
|
||||||
)
|
)
|
||||||
for s in active_subscriptions:
|
if overlapping_subscriptions.exists():
|
||||||
if (
|
raise ValidationError(
|
||||||
s.is_valid_now()
|
_("You can not subscribe many time for the same period")
|
||||||
and s.subscription_end - timedelta(weeks=settings.SITH_SUBSCRIPTION_END)
|
)
|
||||||
> date.today()
|
|
||||||
):
|
|
||||||
raise ValidationError(
|
|
||||||
_("You can not subscribe many time for the same period")
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compute_start(
|
def compute_start(
|
||||||
d: date | None = None, duration: int = 1, user: User | None = None
|
d: date | None = None, duration: int | float = 1, user: User | None = None
|
||||||
) -> date:
|
) -> date:
|
||||||
"""Computes the start date of the subscription.
|
"""Computes the start date of the subscription.
|
||||||
|
|
||||||
@ -132,7 +133,7 @@ class Subscription(models.Model):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def compute_end(
|
def compute_end(
|
||||||
duration: int, start: date | None = None, user: User | None = None
|
duration: int | float, start: date | None = None, user: User | None = None
|
||||||
) -> date:
|
) -> date:
|
||||||
"""Compute the end date of the subscription.
|
"""Compute the end date of the subscription.
|
||||||
|
|
||||||
@ -163,3 +164,19 @@ class Subscription(models.Model):
|
|||||||
|
|
||||||
def is_valid_now(self):
|
def is_valid_now(self):
|
||||||
return self.subscription_start <= date.today() <= self.subscription_end
|
return self.subscription_start <= date.today() <= self.subscription_end
|
||||||
|
|
||||||
|
@property
|
||||||
|
def semester_duration(self) -> float:
|
||||||
|
"""Duration of this subscription, in number of semester.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
The `Subscription` object doesn't have to actually exist
|
||||||
|
in the database to access this property
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```py
|
||||||
|
subscription = Subscription(subscription_type="deux-semestres")
|
||||||
|
assert subscription.semester_duration == 2.0
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return settings.SITH_SUBSCRIPTIONS[self.subscription_type]["duration"]
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("existing_user_subscription_form", () => ({
|
||||||
|
loading: false,
|
||||||
|
profileFragment: "" as string,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const userSelect = document.getElementById("id_member") as HTMLSelectElement;
|
||||||
|
userSelect.addEventListener("change", async () => {
|
||||||
|
await this.loadProfile(Number.parseInt(userSelect.value));
|
||||||
|
});
|
||||||
|
await this.loadProfile(Number.parseInt(userSelect.value));
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadProfile(userId: number) {
|
||||||
|
if (!Number.isInteger(userId)) {
|
||||||
|
this.profileFragment = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
const response = await fetch(`/user/${userId}/mini/`);
|
||||||
|
this.profileFragment = await response.text();
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
28
subscription/static/subscription/css/subscription.scss
Normal file
28
subscription/static/subscription/css/subscription.scss
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#subscription-form form {
|
||||||
|
.form-content.existing-user {
|
||||||
|
max-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the form fields take exactly the space they need,
|
||||||
|
* then display the user profile right in the middle of the remaining space. */
|
||||||
|
fieldset {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subscription-form-user-mini-profile {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user_mini_profile {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
|
||||||
|
<div x-data="existing_user_subscription_form" class="form-content existing-user">
|
||||||
|
<fieldset>
|
||||||
|
{{ form.as_p }}
|
||||||
|
</fieldset>
|
||||||
|
<div
|
||||||
|
id="subscription-form-user-mini-profile"
|
||||||
|
x-html="profileFragment"
|
||||||
|
:aria-busy="loading"
|
||||||
|
></div>
|
||||||
|
</div>
|
@ -0,0 +1 @@
|
|||||||
|
{{ form.as_p }}
|
@ -0,0 +1,10 @@
|
|||||||
|
<form
|
||||||
|
hx-post="{{ post_url }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-disabled-elt="find input[type='submit']"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form }}
|
||||||
|
<input type="submit" value="{% trans %}Save{% endtrans %}">
|
||||||
|
</form>
|
@ -0,0 +1,26 @@
|
|||||||
|
<div class="alert alert-green">
|
||||||
|
<div class="alert-main">
|
||||||
|
<h3 class="alert-title">
|
||||||
|
{% trans user=subscription.member %}Subscription created for {{ user }}{% endtrans %}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{% trans trimmed user=subscription.member.get_short_name(), type=subscription.subscription_type, end=subscription.subscription_end %}
|
||||||
|
{{ user }} received its new {{ type }} subscription.
|
||||||
|
It will be active until {{ end }} included.
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="alert-aside">
|
||||||
|
<a class="btn btn-blue" href="{{ subscription.member.get_absolute_url() }}">
|
||||||
|
{% trans %}Go to user profile{% endtrans %}
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-grey" href="{{ url("subscription:subscription") }}">
|
||||||
|
{# We don't know if this fragment is displayed after creating a subscription
|
||||||
|
for a previously existing user or for a newly created one.
|
||||||
|
Thus, we don't know which form should be used to create another subscription
|
||||||
|
in this place.
|
||||||
|
Therefore, we reload the entire page. It just works. #}
|
||||||
|
{% trans %}Create another subscription{% endtrans %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,62 +1,45 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{% from "core/macros.jinja" import tabs %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans %}New subscription{% endtrans %}
|
{% trans %}New subscription{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{# The following statics are bundled with our autocomplete select.
|
||||||
|
However, if one tries to swap a form by another, then the urls in script-once
|
||||||
|
and link-once disappear.
|
||||||
|
So we give them here.
|
||||||
|
If the aforementioned bug is resolved, you can remove this. #}
|
||||||
|
{% block additional_js %}
|
||||||
|
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||||
|
<script
|
||||||
|
type="module"
|
||||||
|
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
|
||||||
|
></script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||||
|
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||||
|
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% macro form_fragment(form_object, post_url) %}
|
||||||
|
{# Include the form fragment inside a with block,
|
||||||
|
in order to inject the right form in the right place #}
|
||||||
|
{% with form=form_object, post_url=post_url %}
|
||||||
|
{% include "subscription/fragments/creation_form.jinja" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h3>{% trans %}New subscription{% endtrans %}</h3>
|
<h3>{% trans %}New subscription{% endtrans %}</h3>
|
||||||
<div id="user_info"></div>
|
<div id="subscription-form">
|
||||||
<form action="" method="post" id="subscription_form">
|
{% with title1=_("Existing member"), title2=_("New member") %}
|
||||||
{% csrf_token %}
|
{{ tabs([
|
||||||
{{ form.non_field_errors() }}
|
(title1, form_fragment(existing_user_form, existing_user_post_url)),
|
||||||
<p>{{ form.member.errors }}<label for="{{ form.member.name }}">{{ form.member.label }}</label> {{ form.member }}</p>
|
(title2, form_fragment(new_user_form, new_user_post_url)),
|
||||||
<div id="new_member">
|
]) }}
|
||||||
<p>{{ form.first_name.errors }}<label for="{{ form.first_name.name }}">{{ form.first_name.label }}</label> {{ form.first_name }}</p>
|
{% endwith %}
|
||||||
<p>{{ form.last_name.errors }}<label for="{{ form.last_name.name }}">{{ form.last_name.label }}</label> {{ form.last_name }}</p>
|
</div>
|
||||||
<p>{{ form.email.errors }}<label for="{{ form.email.name }}">{{ form.email.label }}</label> {{ form.email }}</p>
|
|
||||||
<p>{{ form.date_of_birth.errors }}<label for="{{ form.date_of_birth.name }}">{{ form.date_of_birth.label}}</label> {{ form.date_of_birth }}</p>
|
|
||||||
</div>
|
|
||||||
<p>{{ form.subscription_type.errors }}<label for="{{ form.subscription_type.name }}">{{ form.subscription_type.label }}</label> {{ form.subscription_type }}</p>
|
|
||||||
<p>{{ form.payment_method.errors }}<label for="{{ form.payment_method.name }}">{{ form.payment_method.label }}</label> {{
|
|
||||||
form.payment_method }}</p>
|
|
||||||
<p>{% trans %}Eboutic is reserved to specific users. In doubt, don't use it.{% endtrans %}</p>
|
|
||||||
<p>{{ form.location.errors }}<label for="{{ form.location.name }}">{{ form.location.label }}</label> {{ form.location }}</p>
|
|
||||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block script %}
|
|
||||||
{{ super() }}
|
|
||||||
<script type="text/javascript" charset="utf-8">
|
|
||||||
$( function() {
|
|
||||||
select = $("#id_member");
|
|
||||||
member_block = $("#subscription_form #new_member");
|
|
||||||
user_info = $("#user_info");
|
|
||||||
function display_new_member() {
|
|
||||||
if (select.val()) {
|
|
||||||
member_block.hide();
|
|
||||||
member_block.children().each(function() {
|
|
||||||
$(this).children().each(function() {
|
|
||||||
$(this).removeAttr('required');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
user_info.load("/user/"+select.val()+"/mini");
|
|
||||||
user_info.show();
|
|
||||||
} else {
|
|
||||||
member_block.show();
|
|
||||||
member_block.children().each(function() {
|
|
||||||
$(this).children().each(function() {
|
|
||||||
$(this).prop('required', true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
user_info.empty();
|
|
||||||
user_info.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
select.on("change", display_new_member);
|
|
||||||
display_new_member();
|
|
||||||
} );
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -12,6 +12,8 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
"""Tests focused on the computing of subscription end, start and duration"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
import freezegun
|
import freezegun
|
151
subscription/tests/test_new_susbcription.py
Normal file
151
subscription/tests/test_new_susbcription.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""Tests focused on testing subscription creation"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.timezone import localdate
|
||||||
|
from model_bakery import baker
|
||||||
|
from pytest_django.asserts import assertRedirects
|
||||||
|
from pytest_django.fixtures import SettingsWrapper
|
||||||
|
|
||||||
|
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||||
|
from core.models import User
|
||||||
|
from subscription.forms import SubscriptionExistingUserForm, SubscriptionNewUserForm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_factory",
|
||||||
|
[old_subscriber_user.make, lambda: baker.make(User)],
|
||||||
|
)
|
||||||
|
def test_form_existing_user_valid(
|
||||||
|
user_factory: Callable[[], User], settings: SettingsWrapper
|
||||||
|
):
|
||||||
|
"""Test `SubscriptionExistingUserForm`"""
|
||||||
|
user = user_factory()
|
||||||
|
data = {
|
||||||
|
"member": user,
|
||||||
|
"subscription_type": "deux-semestres",
|
||||||
|
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
|
||||||
|
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||||
|
}
|
||||||
|
form = SubscriptionExistingUserForm(data)
|
||||||
|
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.is_subscribed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_form_existing_user_invalid(settings: SettingsWrapper):
|
||||||
|
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
|
||||||
|
user = subscriber_user.make()
|
||||||
|
# make sure the current subscription will end in a long time
|
||||||
|
last_sub = user.subscriptions.order_by("subscription_end").last()
|
||||||
|
last_sub.subscription_end = localdate() + timedelta(weeks=50)
|
||||||
|
last_sub.save()
|
||||||
|
data = {
|
||||||
|
"member": user,
|
||||||
|
"subscription_type": "deux-semestres",
|
||||||
|
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
|
||||||
|
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||||
|
}
|
||||||
|
form = SubscriptionExistingUserForm(data)
|
||||||
|
|
||||||
|
assert not form.is_valid()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_form_new_user(settings: SettingsWrapper):
|
||||||
|
data = {
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "jdoe@utbm.fr",
|
||||||
|
"date_of_birth": localdate() - relativedelta(years=18),
|
||||||
|
"subscription_type": "deux-semestres",
|
||||||
|
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
|
||||||
|
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||||
|
}
|
||||||
|
form = SubscriptionNewUserForm(data)
|
||||||
|
assert form.is_valid()
|
||||||
|
form.save()
|
||||||
|
user = User.objects.get(email="jdoe@utbm.fr")
|
||||||
|
assert user.username == "jdoe"
|
||||||
|
assert user.is_subscribed
|
||||||
|
|
||||||
|
# if trying to instantiate a new form with the same email,
|
||||||
|
# it should fail
|
||||||
|
form = SubscriptionNewUserForm(data)
|
||||||
|
assert not form.is_valid()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"user_factory", [lambda: baker.make(User, is_superuser=True), board_user.make]
|
||||||
|
)
|
||||||
|
def test_load_page(client: Client, user_factory: Callable[[], User]):
|
||||||
|
"""Just check the page doesn't crash."""
|
||||||
|
client.force_login(user_factory())
|
||||||
|
res = client.get(reverse("subscription:subscription"))
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
|
||||||
|
client.force_login(board_user.make())
|
||||||
|
user = old_subscriber_user.make()
|
||||||
|
response = client.post(
|
||||||
|
reverse("subscription:fragment-existing-user"),
|
||||||
|
{
|
||||||
|
"member": user.id,
|
||||||
|
"subscription_type": "deux-semestres",
|
||||||
|
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
|
||||||
|
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user.refresh_from_db()
|
||||||
|
assert user.is_subscribed
|
||||||
|
current_subscription = user.subscriptions.order_by("-subscription_start").first()
|
||||||
|
assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse(
|
||||||
|
"subscription:creation-success",
|
||||||
|
kwargs={"subscription_id": current_subscription.id},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
|
||||||
|
client.force_login(board_user.make())
|
||||||
|
response = client.post(
|
||||||
|
reverse("subscription:fragment-new-user"),
|
||||||
|
{
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "jdoe@utbm.fr",
|
||||||
|
"date_of_birth": localdate() - relativedelta(years=18),
|
||||||
|
"subscription_type": "deux-semestres",
|
||||||
|
"location": settings.SITH_SUBSCRIPTION_LOCATIONS[0][0],
|
||||||
|
"payment_method": settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user = User.objects.get(email="jdoe@utbm.fr")
|
||||||
|
assert user.is_subscribed
|
||||||
|
current_subscription = user.subscriptions.order_by("-subscription_start").first()
|
||||||
|
assertRedirects(
|
||||||
|
response,
|
||||||
|
reverse(
|
||||||
|
"subscription:creation-success",
|
||||||
|
kwargs={"subscription_id": current_subscription.id},
|
||||||
|
),
|
||||||
|
)
|
@ -15,10 +15,31 @@
|
|||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from subscription.views import NewSubscription, SubscriptionsStatsView
|
from subscription.views import (
|
||||||
|
CreateSubscriptionExistingUserFragment,
|
||||||
|
CreateSubscriptionNewUserFragment,
|
||||||
|
NewSubscription,
|
||||||
|
SubscriptionCreatedFragment,
|
||||||
|
SubscriptionsStatsView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Subscription views
|
# Subscription views
|
||||||
path("", NewSubscription.as_view(), name="subscription"),
|
path("", NewSubscription.as_view(), name="subscription"),
|
||||||
|
path(
|
||||||
|
"fragment/existing-user/",
|
||||||
|
CreateSubscriptionExistingUserFragment.as_view(),
|
||||||
|
name="fragment-existing-user",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"fragment/new-user/",
|
||||||
|
CreateSubscriptionNewUserFragment.as_view(),
|
||||||
|
name="fragment-new-user",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"fragment/<int:subscription_id>/creation-success",
|
||||||
|
SubscriptionCreatedFragment.as_view(),
|
||||||
|
name="creation-success",
|
||||||
|
),
|
||||||
path("stats/", SubscriptionsStatsView.as_view(), name="stats"),
|
path("stats/", SubscriptionsStatsView.as_view(), name="stats"),
|
||||||
]
|
]
|
||||||
|
@ -13,166 +13,96 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.urls import reverse_lazy
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.views.generic.edit import CreateView, FormView
|
from django.utils.timezone import localdate
|
||||||
|
from django.views.generic import CreateView, DetailView, TemplateView
|
||||||
|
from django.views.generic.edit import FormView
|
||||||
|
|
||||||
from core.models import User
|
from subscription.forms import (
|
||||||
from core.views.forms import SelectDate, SelectDateTime
|
SelectionDateForm,
|
||||||
from core.views.widgets.select import AutoCompleteSelectUser
|
SubscriptionExistingUserForm,
|
||||||
|
SubscriptionNewUserForm,
|
||||||
|
)
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
|
|
||||||
class SelectionDateForm(forms.Form):
|
class CanCreateSubscriptionMixin(UserPassesTestMixin):
|
||||||
def __init__(self, *args, **kwargs):
|
def test_func(self):
|
||||||
super().__init__(*args, **kwargs)
|
return self.request.user.can_create_subscription
|
||||||
self.fields["start_date"] = forms.DateTimeField(
|
|
||||||
label=_("Start date"), widget=SelectDateTime, required=True
|
|
||||||
)
|
|
||||||
self.fields["end_date"] = forms.DateTimeField(
|
|
||||||
label=_("End date"), widget=SelectDateTime, required=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionForm(forms.ModelForm):
|
class NewSubscription(CanCreateSubscriptionMixin, TemplateView):
|
||||||
class Meta:
|
|
||||||
model = Subscription
|
|
||||||
fields = ["member", "subscription_type", "payment_method", "location"]
|
|
||||||
widgets = {"member": AutoCompleteSelectUser}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields["member"].required = False
|
|
||||||
self.fields |= forms.fields_for_model(
|
|
||||||
User,
|
|
||||||
fields=["first_name", "last_name", "email", "date_of_birth"],
|
|
||||||
widgets={"date_of_birth": SelectDate},
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean_member(self):
|
|
||||||
subscriber = self.cleaned_data.get("member")
|
|
||||||
if subscriber:
|
|
||||||
subscriber = User.objects.filter(id=subscriber.id).first()
|
|
||||||
return subscriber
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned_data = super().clean()
|
|
||||||
if (
|
|
||||||
cleaned_data.get("member") is None
|
|
||||||
and "last_name" not in self.errors.as_data()
|
|
||||||
and "first_name" not in self.errors.as_data()
|
|
||||||
and "email" not in self.errors.as_data()
|
|
||||||
and "date_of_birth" not in self.errors.as_data()
|
|
||||||
):
|
|
||||||
self.errors.pop("member", None)
|
|
||||||
if self.errors:
|
|
||||||
return cleaned_data
|
|
||||||
if User.objects.filter(email=cleaned_data.get("email")).first() is not None:
|
|
||||||
self.add_error(
|
|
||||||
"email",
|
|
||||||
ValidationError(_("A user with that email address already exists")),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
u = User(
|
|
||||||
last_name=self.cleaned_data.get("last_name"),
|
|
||||||
first_name=self.cleaned_data.get("first_name"),
|
|
||||||
email=self.cleaned_data.get("email"),
|
|
||||||
date_of_birth=self.cleaned_data.get("date_of_birth"),
|
|
||||||
)
|
|
||||||
u.generate_username()
|
|
||||||
u.set_password(secrets.token_urlsafe(nbytes=10))
|
|
||||||
u.save()
|
|
||||||
cleaned_data["member"] = u
|
|
||||||
elif cleaned_data.get("member") is not None:
|
|
||||||
self.errors.pop("last_name", None)
|
|
||||||
self.errors.pop("first_name", None)
|
|
||||||
self.errors.pop("email", None)
|
|
||||||
self.errors.pop("date_of_birth", None)
|
|
||||||
if cleaned_data.get("member") is None:
|
|
||||||
# This should be handled here,
|
|
||||||
# but it is done in the Subscription model's clean method
|
|
||||||
# TODO investigate why!
|
|
||||||
raise ValidationError(
|
|
||||||
_(
|
|
||||||
"You must either choose an existing "
|
|
||||||
"user or create a new one properly"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return cleaned_data
|
|
||||||
|
|
||||||
|
|
||||||
class NewSubscription(CreateView):
|
|
||||||
template_name = "subscription/subscription.jinja"
|
template_name = "subscription/subscription.jinja"
|
||||||
form_class = SubscriptionForm
|
|
||||||
|
|
||||||
def dispatch(self, request, *arg, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
if request.user.can_create_subscription:
|
return super().get_context_data(**kwargs) | {
|
||||||
return super().dispatch(request, *arg, **kwargs)
|
"existing_user_form": SubscriptionExistingUserForm(
|
||||||
raise PermissionDenied
|
initial={"member": self.request.GET.get("member")}
|
||||||
|
),
|
||||||
|
"new_user_form": SubscriptionNewUserForm(),
|
||||||
|
"existing_user_post_url": reverse("subscription:fragment-existing-user"),
|
||||||
|
"new_user_post_url": reverse("subscription:fragment-new-user"),
|
||||||
|
}
|
||||||
|
|
||||||
def get_initial(self):
|
|
||||||
if "member" in self.request.GET:
|
|
||||||
return {
|
|
||||||
"member": self.request.GET["member"],
|
|
||||||
"subscription_type": "deux-semestres",
|
|
||||||
}
|
|
||||||
return {"subscription_type": "deux-semestres"}
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
class CreateSubscriptionFragment(CanCreateSubscriptionMixin, CreateView):
|
||||||
form.instance.subscription_start = Subscription.compute_start(
|
template_name = "subscription/fragments/creation_form.jinja"
|
||||||
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
|
|
||||||
"duration"
|
def get_success_url(self):
|
||||||
],
|
return reverse(
|
||||||
user=form.instance.member,
|
"subscription:creation-success", kwargs={"subscription_id": self.object.id}
|
||||||
)
|
)
|
||||||
form.instance.subscription_end = Subscription.compute_end(
|
|
||||||
duration=settings.SITH_SUBSCRIPTIONS[form.instance.subscription_type][
|
|
||||||
"duration"
|
class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
|
||||||
],
|
"""Create a subscription for a user who already exists."""
|
||||||
start=form.instance.subscription_start,
|
|
||||||
user=form.instance.member,
|
form_class = SubscriptionExistingUserForm
|
||||||
)
|
extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
|
||||||
return super().form_valid(form)
|
|
||||||
|
|
||||||
|
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
|
||||||
|
"""Create a subscription for a user who already exists."""
|
||||||
|
|
||||||
|
form_class = SubscriptionNewUserForm
|
||||||
|
extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionCreatedFragment(CanCreateSubscriptionMixin, DetailView):
|
||||||
|
template_name = "subscription/fragments/creation_success.jinja"
|
||||||
|
model = Subscription
|
||||||
|
pk_url_kwarg = "subscription_id"
|
||||||
|
context_object_name = "subscription"
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionsStatsView(FormView):
|
class SubscriptionsStatsView(FormView):
|
||||||
template_name = "subscription/stats.jinja"
|
template_name = "subscription/stats.jinja"
|
||||||
form_class = SelectionDateForm
|
form_class = SelectionDateForm
|
||||||
|
success_url = reverse_lazy("subscriptions:stats")
|
||||||
|
|
||||||
def dispatch(self, request, *arg, **kwargs):
|
def dispatch(self, request, *arg, **kwargs):
|
||||||
import datetime
|
self.start_date = localdate()
|
||||||
|
|
||||||
self.start_date = datetime.datetime.today()
|
|
||||||
self.end_date = self.start_date
|
self.end_date = self.start_date
|
||||||
res = super().dispatch(request, *arg, **kwargs)
|
|
||||||
if request.user.is_root or request.user.is_board_member:
|
if request.user.is_root or request.user.is_board_member:
|
||||||
return res
|
return super().dispatch(request, *arg, **kwargs)
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
self.form = self.get_form()
|
self.form = self.get_form()
|
||||||
self.start_date = self.form["start_date"]
|
self.start_date = self.form["start_date"]
|
||||||
self.end_date = self.form["end_date"]
|
self.end_date = self.form["end_date"]
|
||||||
res = super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
if request.user.is_root or request.user.is_board_member:
|
|
||||||
return res
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
init = {
|
return {
|
||||||
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
"end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
}
|
}
|
||||||
return init
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
from subscription.models import Subscription
|
|
||||||
|
|
||||||
kwargs = super().get_context_data(**kwargs)
|
kwargs = super().get_context_data(**kwargs)
|
||||||
kwargs["subscriptions_total"] = Subscription.objects.filter(
|
kwargs["subscriptions_total"] = Subscription.objects.filter(
|
||||||
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
|
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
|
||||||
@ -181,6 +111,3 @@ class SubscriptionsStatsView(FormView):
|
|||||||
kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD
|
kwargs["payment_types"] = settings.SITH_COUNTER_PAYMENT_METHOD
|
||||||
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
|
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
|
||||||
return reverse_lazy("subscriptions:stats")
|
|
||||||
|
@ -85,6 +85,7 @@ export default defineConfig((config: UserConfig) => {
|
|||||||
inject({
|
inject({
|
||||||
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
// biome-ignore lint/style/useNamingConvention: that's how it's called
|
||||||
Alpine: "alpinejs",
|
Alpine: "alpinejs",
|
||||||
|
htmx: "htmx.org",
|
||||||
}),
|
}),
|
||||||
viteStaticCopy({
|
viteStaticCopy({
|
||||||
targets: [
|
targets: [
|
||||||
|
Reference in New Issue
Block a user