Compare commits

...
Author SHA1 Message Date
imperosol 1798406dde style: clean footer.scss 2026-10-24 12:57:00 +02:00
imperosol 20fc7d587a style: clean unused classes
- `.form_button` : ancienne interface des clics (changée en 2021)
- `.ui-dialog` : jquery-ui (retiré milieu 2025)
- `td>ul` : déjà déclaré dans `td,th{>ul}`, quelques lignes plus haut
- `.edit-bar` : littéralement jamais utilisé
2026-10-24 12:57:00 +02:00
imperosol 94b108d288 style: remove base.css
Ça a pu être utile dans le passé, mais avec l'évolution des navigateurs (merci à Skia d'avoir précisé dans les commentaires que c'était là pour ça) et l'étoffement du reste du css, il y a seulement trois lignes du fichier qui ont encore réellement une utilité.

Le peu qui sert encore peut donc être déplacé dans `style.scss` et le reste supprimé sans problème.
2026-10-24 12:57:00 +02:00
imperosol 059d5210ac style: replace scss variable by css ones 2026-10-24 12:57:00 +02:00
imperosol 562f75f410 style: remove unused colours 2026-10-24 12:57:00 +02:00
thomas girod 32c30da0fc Merge pull request #1507 from ae-utbm/edt-save
Add user timetable saving
2026-10-24 01:09:43 +02:00
thomas girod d3f3b18660 Merge pull request #1519 from ae-utbm/page-edit-link
Move the club page edit link to the club main page
2026-10-23 20:53:47 +02:00
thomas girod e50b1fd71e Merge pull request #1525 from ae-utbm/show-all-counters-in-stats
Show all counters in user stats view
2026-10-23 16:27:33 +02:00
imperosol 78376eaca4 don't restict user counter stats to bar counters 2026-10-23 16:08:40 +02:00
thomas girod 8d56c72054 Merge pull request #1524 from ae-utbm/remove-gentoken
remove unused Counter methods
2026-10-22 19:10:47 +02:00
imperosol a794f02a8d remove unused Counter methods
`Counter.gen_token` et `Counter.get_random_barman` étaient utilisés avant la refonte du système de connexion des barmen (PR #1413), mais on a oublié de les retirer.
2026-10-22 11:56:04 +02:00
imperosol cb0a2f866d TimetableTabMixin 2026-10-22 11:39:52 +02:00
imperosol 4b4c5a73b6 add tests 2026-10-22 11:39:52 +02:00
imperosol b37b98b932 view to see a user's timetables 2026-10-22 11:39:52 +02:00
imperosol ed888834d6 split timetable parsing and display 2026-10-22 11:39:52 +02:00
imperosol 0657888076 User timetable save 2026-10-22 11:39:52 +02:00
imperosol 3f135777cd add show_my_timetable to VisibilityForm 2026-10-22 11:39:51 +02:00
imperosol 73ebe9c674 add timetable visibility to UserVisibilityForm 2026-10-22 11:39:51 +02:00
imperosol 816cbe3a2c api to save timetable 2026-10-22 11:39:51 +02:00
imperosol a33a4da143 add models 2026-10-22 11:39:51 +02:00
imperosol 2c77ed3c54 add tests 2026-10-22 11:25:40 +02:00
imperosol 3312a6214e move the club page edit link to the club main page 2026-10-22 10:07:26 +02:00
thomas girod 87533a7327 Merge pull request #1521 from ae-utbm/account-dump-msg
Add success message when refunding AE account
2026-10-21 16:35:05 +02:00
imperosol 2db46e3958 add success message when refunding AE account 2026-10-21 16:34:12 +02:00
thomas girod f2469873fd Merge pull request #1522 from ae-utbm/md-smallfix
fix: add back `.markdown` on EasyMde preview editor
2026-10-20 18:38:19 +02:00
imperosol 68c05b5b1d fix: add back .markdown on EasyMde preview editor
A l'origine, l'API retournait le markdown encapsulé dans une div avec la class `.markdown`. Quand on est passé sur notre propre lib WASM pour faire la preview entièrement côté front, on a oublié ce détail.

En même temps, on résout ici un petit glitch d'affichage qui fait qu'on avait le background des blocs de codes qui se mettait en dessous de notre propre style.
2026-10-20 12:52:20 +02:00
thomas girod 2d492e0157 Merge pull request #1511 from ae-utbm/counter-admin-mixin
remove `CounterAdminMixin`
2026-10-14 20:13:43 +02:00
thomas girod 67cfcb66b6 Merge pull request #1512 from ae-utbm/docs-populate
Small documentation fixes
2026-10-13 11:32:02 +02:00
thomas girod b560c6860b Merge pull request #1510 from ae-utbm/forum-message-403
short-circuit `ForumMessageView` redirection on 403
2026-10-13 00:12:07 +02:00
imperosol 15c5a37c99 docs: fix nushell scripts 2026-10-12 14:08:12 +02:00
imperosol 07648f596e docs: describe populated user accounts 2026-10-12 13:51:18 +02:00
imperosol 0c6594b3cd remove CounterAdminMixin 2026-10-09 21:59:37 +02:00
imperosol 22b510e25b short-circuit ForumMessageView redirection on 403
Quand on accède à cette vue, on est immédiatement redirigé vers le topic correspondant (quitte à se faire 403 une fois arrivé là). Comme la redirection est une opération qui a un cout, on peut gagner du temps en mettant un PermissionDenied directement (d'autant plus que les bots aiment beaucoup rechercher les messages du forum, et qu'ils se font systématiquement 403)
2026-10-09 16:09:54 +02:00
64 changed files with 1156 additions and 607 deletions

No files matched your search

+1 -1
View File
@@ -238,7 +238,7 @@ class Club(models.Model):
def can_be_edited_by(self, user: User) -> bool: def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return user.has_perm("club.change_club") or self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool: def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club""" """Return True if the given user can edit the roles of this club"""
+9
View File
@@ -26,6 +26,15 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if user.is_authenticated and user.can_edit(club) %}
<a
href="{{ url("core:page_edit", page_name=club.page.get_full_name()) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-pen-to-square"></i> {% trans %}Edit{% endtrans %}
</a>
{% endif %}
<h3>{{ club.name }}</h3> <h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}> <div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes"> <div id="club-attributes">
+57 -9
View File
@@ -1,8 +1,9 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Callable
import pytest import pytest
from aemark import markdown from aemark import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
@@ -16,13 +17,20 @@ if TYPE_CHECKING:
@pytest.mark.django_db @pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client): @pytest.mark.parametrize("user_factory", [lambda: None, subscriber_user.make])
def test_page_display_on_club_main_page(
client: Client, user_factory: Callable[[], User | None]
):
"""Test the club Page is properly displayed on the club main view""" """Test the club Page is properly displayed on the club main view"""
club = baker.make(Club) club = baker.make(Club)
# create another previous revision, to test that we display the most recent one
baker.make(PageRev, page=club.page, revision=1)
content = "# foo\nLorem ipsum dolor sit amet" content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content) baker.make(PageRev, page=club.page, revision=2, content=content)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
if user := user_factory():
client.force_login(user)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml") soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page").find(class_="markdown") detail_html = soup.find(id="club-page").find(class_="markdown")
@@ -30,20 +38,60 @@ def test_page_display_on_club_main_page(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_club_main_page_without_content(client: Client): @pytest.mark.parametrize(
"""Test the club view works, even if the club page is empty""" ("user_factory", "can_view_tabs", "can_view_edit_button"),
[
(lambda _: None, False, False),
(lambda _: baker.make(User), False, False),
(lambda _: subscriber_user.make(), True, False),
(
# admin user
lambda _: subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_club")]
),
True,
True,
),
(
# club board member
lambda club: (
baker.make(
Membership,
club=club,
role=baker.make(ClubRole, is_board=True),
user=subscriber_user.make(),
).user
),
True,
True,
),
],
ids=["anonymous", "public", "subscriber", "admin", "club board"],
)
@pytest.mark.django_db
def test_club_main_page_displayed_elements(
client: Client, user_factory, can_view_tabs, can_view_edit_button
):
"""Test that the appropriate elements are displayed depending on user status."""
club = baker.make(Club) club = baker.make(Club)
club.page.revisions.all().delete() if user := user_factory(club):
client.force_login(user)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml") soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page") tabs = soup.find(class_="tool_bar")
assert detail_html.find_all("markdown") == [] assert tabs is not None if can_view_tabs else tabs is None
edit_btn = soup.find(
"a",
href=reverse("core:page_edit", kwargs={"page_name": club.page.get_full_name()}),
)
assert edit_btn is not None if can_view_edit_button else edit_btn is None
@pytest.mark.django_db @pytest.mark.django_db
def test_page_revision(client: Client): def test_page_revision(client: Client):
"""Test that accessing a previous revision of the club page works."""
club = baker.make(Club) club = baker.make(Club)
revisions = baker.make( revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"]) PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
+10 -32
View File
@@ -106,20 +106,19 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
if not self.request.user.has_perm("club.view_club"):
# The user will still be able to see the club main page,
# but nothing else.
# It's useless to display a tab with only one item, so return nothing.
return []
tab_list = [ tab_list = [
{ {
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}), "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos", "slug": "infos",
"name": _("Infos"), "name": _("Infos"),
} },
]
if self.request.user.has_perm("club.view_club"):
tab_list.extend(
[
{ {
"url": reverse( "url": reverse("club:club_members", kwargs={"club_id": self.object.id}),
"club:club_members", kwargs={"club_id": self.object.id}
),
"slug": "members", "slug": "members",
"name": _("Members"), "name": _("Members"),
}, },
@@ -130,18 +129,12 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "elderlies", "slug": "elderlies",
"name": _("Old members"), "name": _("Old members"),
}, },
]
)
if self.object.page:
tab_list.append(
{ {
"url": reverse( "url": reverse("club:club_hist", kwargs={"club_id": self.object.id}),
"club:club_hist", kwargs={"club_id": self.object.id}
),
"slug": "history", "slug": "history",
"name": _("History"), "name": _("History"),
} },
) ]
if self.request.user.can_edit(self.object): if self.request.user.can_edit(self.object):
tab_list.extend( tab_list.extend(
[ [
@@ -159,21 +152,6 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "edit", "slug": "edit",
"name": _("Edit"), "name": _("Edit"),
}, },
]
)
if self.object.page and self.request.user.can_edit(self.object.page):
tab_list.append(
{
"url": reverse(
"core:page_edit",
kwargs={"page_name": self.object.page.get_full_name()},
),
"slug": "page_edit",
"name": _("Edit club page"),
}
)
tab_list.extend(
[
{ {
"url": reverse( "url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id} "club:club_sellings", kwargs={"club_id": self.object.id}
-1
View File
@@ -5,7 +5,6 @@
margin-top: 20px; margin-top: 20px;
padding: 0.4em; padding: 0.4em;
width: 80%; width: 80%;
background: $white-color;
h4 { h4 {
margin-top: 1em; margin-top: 1em;
+8 -8
View File
@@ -32,7 +32,7 @@
h3 { h3 {
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px; --box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%); background: var(--bg-yellow);
box-shadow: var(--box-shadow); box-shadow: var(--box-shadow);
padding: .75rem; padding: .75rem;
margin: 0 0 0.5em 0; margin: 0 0 0.5em 0;
@@ -85,7 +85,7 @@
#links_content { #links_content {
overflow: auto; overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
min-height: 20em; min-height: 20em;
padding: 1em; padding: 1em;
@@ -111,7 +111,7 @@
} }
#birthdays_content { #birthdays_content {
box-shadow: $shadow-color 1px 1px 1px; box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
padding: 1em; padding: 1em;
ul.birthdays_year { ul.birthdays_year {
@@ -123,7 +123,7 @@
padding: 0.5em; padding: 0.5em;
&:nth-child(even) { &:nth-child(even) {
background: $secondary-neutral-light-color; background: var(--bg-off-white);
} }
} }
@@ -141,7 +141,7 @@
/* EVENTS TODAY AND NEXT FEW DAYS */ /* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group { .news_events_group {
box-shadow: $shadow-color 1px 1px 1px; box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
margin-left: 0; margin-left: 0;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@@ -153,8 +153,8 @@
display: table-cell; display: table-cell;
padding: 0.6em; padding: 0.6em;
vertical-align: middle; vertical-align: middle;
background: $primary-neutral-dark-color; background: var(--deepblue);
color: $white-color; color: var(--text-white);
text-transform: uppercase; text-transform: uppercase;
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
@@ -180,7 +180,7 @@
} }
.news_event:nth-of-type(even) { .news_event:nth-of-type(even) {
background: $primary-neutral-light-color; background: var(--bg-white);
} }
.news_event { .news_event {
@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-24 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0051_user_cgu_approved_at")]
operations = [
migrations.AddField(
model_name="preferences",
name="show_my_timetable",
field=models.BooleanField(
default=True,
help_text=(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
verbose_name="show your timetable to others",
),
),
]
+8
View File
@@ -799,6 +799,14 @@ class Preferences(models.Model):
), ),
default=False, default=False,
) )
show_my_timetable = models.BooleanField(
_("show your timetable to others"),
help_text=_(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
default=True,
)
notify_on_click = models.BooleanField( notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False _("get a notification for every click"), default=False
) )
@@ -60,6 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
easymde.codemirror.replaceSelection("\n"); easymde.codemirror.replaceSelection("\n");
}, },
previewClass: ["editor-preview", "markdown"], // default class + our own markdown class
previewRender: (plainText) => markdown(plainText), previewRender: (plainText) => markdown(plainText),
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
imageTexts: { imageTexts: {
-123
View File
@@ -1,123 +0,0 @@
/*--------------------------------RESET--------------------------------*/
/*--------------------------------RESET--------------------------------*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
/* list-style: none;*/
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+25 -29
View File
@@ -1,41 +1,37 @@
$first-color: hsl(220, 100%, 50%); /**
$second-color: hsl(48, 100%, 67%); * Sith theme variables
$primary-color: hsl(219.9, 53.7%, 50%); */
$secondary-color: hsl(204, 64%, 44%); :root {
$primary-color-text: hsl(0, 0%, 100%); --bg-yellow: #ffe270;
$secondary-color-text: hsla(0, 0%, 0%, 0.87); --bg-white: #f9fafb;
--bg-off-white: #f0f0f0;
--bg-darkened-off-white: #e3e3e3;
--bg-lightgrey: #d4d4d4;
$primary-light-color: hsl(219.8, 46.4%, 64.9%); --text-white: #f9fafb;
$primary-dark-color: hsl(203, 75%, 40%); --text-black: #2b2b2b;
--text-red: #eb2f06;
$secondary-light-color: hsl(40, 68%, 65%); --blue: #3b69c4;
$secondary-dark-color: hsl(40, 68%, 35%); --lightblue: #1a78b3;
// darkened-deepblue is 10% darker than deepblue
$primary-neutral-color: hsl(219.6, 20.8%, 50%); // This is explicitely declared, because the `darken` scss function
$primary-neutral-light-color: hsl(0, 0%, 94%); // isn't called at compile-time somehow.
$primary-neutral-dark-color: hsl(210, 29%, 29%); --deepblue: #354a5f;
--darkened-deepblue: #23313e;
$secondary-neutral-color: hsl(204, 64%, 44%); }
$secondary-neutral-light-color: hsl(0, 0%, 91%);
$secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
/**
* Brand colours.
* Those are officially defined by their respective companies,
* and so are more appropriate as scss variables.
*/
$faceblue: hsl(221, 44%, 41%); $faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%); $twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da; $discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%); $instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20); $githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow { @mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0, box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px; rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
+3 -3
View File
@@ -17,7 +17,7 @@
} }
.card { .card {
background-color: $primary-neutral-light-color; background-color: var(--bg-off-white);
border-radius: 5px; border-radius: 5px;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
@@ -30,12 +30,12 @@
gap: 20px; gap: 20px;
&:disabled { &:disabled {
background-color: darken($primary-neutral-light-color, 5%); background-color: var(--bg-darkened-off-white);
opacity: 65%; opacity: 65%;
} }
&.clickable:not(:disabled):hover { &.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%); background-color: var(--bg-darkened-off-white);
} }
&.selected { &.selected {
+8 -4
View File
@@ -11,7 +11,7 @@ ui-tab-group {
.tab-headers { .tab-headers {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
background-color: $primary-neutral-light-color; background-color: var(--bg-off-white);
padding: 3px 12px 12px; padding: 3px 12px 12px;
column-gap: 20px; column-gap: 20px;
border-top-left-radius: 5px; border-top-left-radius: 5px;
@@ -25,23 +25,27 @@ ui-tab-group {
background-color: unset; background-color: unset;
position: relative; position: relative;
&:hover {
background-color: var(--bg-lightgrey);
}
&:after { &:after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%); border-bottom: 4px solid var(--bg-lightgrey);
border-radius: 2px; border-radius: 2px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
&:hover:after { &:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%); filter: brightness(85%);
} }
&.active:after { &.active:after {
border-bottom-color: $primary-dark-color; border-bottom-color: var(--lightblue);
} }
} }
} }
+15 -32
View File
@@ -6,88 +6,71 @@ footer.bottom-links {
text-align: center; text-align: center;
} }
text-align: center;
hr {
border: none;
}
@media (max-width: $small-devices) { @media (max-width: $small-devices) {
margin-top: 0.6em; margin-top: 0.6em;
padding: 1.25em; padding: 1.25em;
background-color: $primary-neutral-dark-color; background-color: var(--deepblue);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 1.25em; gap: 1.25em;
> section { > section {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 0.8em; gap: 0.8em;
} }
a { a {
color: $white-color; color: var(--text-white);
width: auto;
&:hover { &:hover {
color: $white-color; text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
} }
} }
.fa-github { .fa-github {
color: $white-color; color: var(--text-white);
} }
hr { hr {
width: 100%; border-top: var(--bg-white) 1px solid;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
} }
} }
@media (min-width: $small-devices) { @media (min-width: $small-devices) {
width: 90%; width: 90%;
margin: 2em auto; margin: 2em auto;
font-size: 90%; font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type { section:first-of-type {
margin: 0.6em 0; margin: 0.6em 0;
color: $white-color;
border-radius: 5px; border-radius: 5px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
background-color: $primary-neutral-dark-color; background-color: var(--deepblue);
box-shadow: $shadow-color 0 0 15px;
a { a {
color: $white-color;
width: auto;
padding: 0.8em; padding: 0.8em;
flex: 1; flex: 1;
font-weight: bold; font-weight: bold;
color: var(--text-white);
&:hover { &:hover {
color: $white-color; text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
} }
} }
} }
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
} }
} }
+10 -14
View File
@@ -16,7 +16,6 @@
input[type="file"] { input[type="file"] {
border: none; border: none;
text-decoration: none; text-decoration: none;
background-color: $background-button-color;
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-size: 1.2em; font-size: 1.2em;
@@ -24,7 +23,7 @@
color: black; color: black;
&:not(.link-like):not(:disabled):hover { &:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%); background: var(--bg-lightgrey);
} }
} }
@@ -53,7 +52,7 @@
.ts-control { .ts-control {
border: none; border: none;
text-decoration: none; text-decoration: none;
background-color: $background-button-color; //background-color: $background-button-color;
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-size: 1.2em; font-size: 1.2em;
@@ -64,7 +63,7 @@
textarea { textarea {
border: none; border: none;
text-decoration: none; text-decoration: none;
background-color: $background-button-color; //background-color: $background-button-color;
padding: 7px; padding: 7px;
font-size: 1.2em; font-size: 1.2em;
border-radius: 5px; border-radius: 5px;
@@ -75,7 +74,7 @@
border: none; border: none;
text-decoration: none; text-decoration: none;
font-size: 1.2em; font-size: 1.2em;
background-color: $background-button-color; //background-color: $background-button-color;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
@@ -83,14 +82,14 @@
a:not(.button) { a:not(.button) {
text-decoration: none; text-decoration: none;
color: $primary-dark-color; color: var(--lightblue);
&:hover { &:hover {
color: $primary-light-color; color: var(--blue);
} }
&:active { &:active {
color: $primary-color; color: var(--blue);
} }
} }
} }
@@ -762,16 +761,13 @@ form {
// a submit input that should look like a regular <a> // a submit input that should look like a regular <a>
input[type="submit"], button { input[type="submit"], button {
&.link-like { &.link-like {
color: $primary-dark-color; color: var(--lightblue);
&:hover { &:hover {
color: $primary-light-color; color: var(--blue);
} }
&.link-red { &.link-red {
color: $red-text-color; color: var(--text-red);
&:hover {
color: $hovered-red-text-color;
}
} }
font-weight: normal; font-weight: normal;
font-size: 100%; font-size: 100%;
+4 -4
View File
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: var(--deepblue);
box-shadow: 3px 3px 3px 0 #dfdfdf; box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0; border-radius: 0;
width: 100%; width: 100%;
@@ -96,7 +96,7 @@ $background-color-hovered: #283747;
border-radius: 0; border-radius: 0;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: var(--deepblue);
width: 45px; width: 45px;
height: 25px; height: 25px;
padding: 0; padding: 0;
@@ -212,7 +212,7 @@ $background-color-hovered: #283747;
background-position: center center; background-position: center center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
background-color: $deepblue; background-color: var(--deepblue);
} }
>.options { >.options {
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
width: 10px; width: 10px;
height: 10px; height: 10px;
padding: 5px; padding: 5px;
background-color: $red-text-color; background-color: var(--text-red);
border-radius: 50%; border-radius: 50%;
position: absolute; position: absolute;
top: -50%; top: -50%;
+11 -5
View File
@@ -1,7 +1,4 @@
.markdown { .markdown {
--bg-lightgrey: rgb(240, 241, 245);
--border-lightgrey: rgb(215, 216, 220);
h1, h1,
h2, h2,
h3, h3,
@@ -28,11 +25,20 @@
font-size: 14px; font-size: 14px;
tab-size: 4; tab-size: 4;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--border-lightgrey); border: 1px solid var(--bg-lightgrey);
background-color: var(--bg-lightgrey); background-color: var(--bg-off-white);
padding: 1px 2px; padding: 1px 2px;
} }
/**
* Some restyling rules for the cases where our own md css
* conflicts with the easymde editor preview css
*/
&.editor-preview {
pre {
background: unset;
}
}
pre code { pre code {
// code that is not inlined // code that is not inlined
margin: 15px; margin: 15px;
+1 -1
View File
@@ -3,7 +3,7 @@
$desktop-size: 500px; $desktop-size: 500px;
nav.navbar { nav.navbar {
background-color: $primary-dark-color; background-color: var(--lightblue);
margin: 1em; margin: 1em;
color: white; color: white;
border-radius: 0.6em; border-radius: 0.6em;
+4 -10
View File
@@ -6,27 +6,21 @@
margin: 30px; margin: 30px;
button { button {
background-color: $secondary-neutral-light-color; background-color: var(--bg-off-white);
min-width: 37px; min-width: 37px;
&:hover { &:hover {
background-color: darken($secondary-neutral-light-color, 10%); background-color: var(--bg-lightgrey);
}
&:disabled {
color: #fff;
background-color: darken($secondary-neutral-light-color, 5%);
} }
&.active, &.active,
&.active:hover { &.active:hover {
background-color: $primary-neutral-color; background-color: var(--deepblue);
color: white; color: white;
border-radius: 50%; border-radius: 50%;
} }
&:first-of-type, .pagination-arrow {
&:last-of-type {
// previous and next buttons // previous and next buttons
&:disabled { &:disabled {
// Hide the arrows when they are disabled, without // Hide the arrows when they are disabled, without
+37 -79
View File
@@ -2,12 +2,21 @@
@import "forms"; @import "forms";
@import "devices"; @import "devices";
/*----- RESET -----*/
* {
margin: 0;
padding: 0;
}
/*--------------------------------GENERAL------------------------------*/ /*--------------------------------GENERAL------------------------------*/
body { body {
position: relative; position: relative;
width: 100%; width: 100%;
font-family: sans-serif; font-family: sans-serif;
background-color: var(--bg-white);
line-height: 1;
} }
[aria-busy] { [aria-busy] {
@@ -57,7 +66,7 @@ body {
.collapse-header { .collapse-header {
color: white; color: white;
background-color: $deepblue; background-color: var(--deepblue);
padding: 5px 10px; padding: 5px 10px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -141,7 +150,7 @@ body {
} }
#info_box { #info_box {
background: $primary-neutral-light-color; background: var(--bg-off-white);
&:before { &:before {
content: "\f05a"; content: "\f05a";
@@ -150,11 +159,11 @@ body {
} }
#alert_box { #alert_box {
background: $second-color; background: var(--bg-yellow);
&:before { &:before {
content: "\f06a"; content: "\f06a";
color: $white-color; color: var(--text-white);
} }
} }
} }
@@ -189,10 +198,10 @@ body {
border-radius: 5px; border-radius: 5px;
&.btn-blue { &.btn-blue {
background-color: $deepblue; background-color: var(--deepblue);
&:not(:disabled):hover { &:not(:disabled):hover {
background-color: darken($deepblue, 10%); background-color: var(--darkened-deepblue);
} }
&:disabled { &:disabled {
@@ -215,7 +224,7 @@ body {
&.btn-green { &.btn-green {
$bg-color: rgba(0, 210, 83, 0.4); $bg-color: rgba(0, 210, 83, 0.4);
background-color: $bg-color; background-color: $bg-color;
color: $black-color; color: var(--text-black);
&:not(:disabled):hover { &:not(:disabled):hover {
background-color: darken($bg-color, 15%); background-color: darken($bg-color, 15%);
@@ -272,8 +281,8 @@ body {
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#content { #content {
padding: 1.5em 3%; padding: 1.5em 3%;
box-shadow: $shadow-color 0 5px 10px; box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
background: $white-color; background: var(--bg-white);
overflow: auto; overflow: auto;
} }
@@ -343,7 +352,7 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
float: right; float: right;
background: $primary-neutral-light-color; background: var(--bg-off-white);
padding: 5px; padding: 5px;
border-radius: 6px; border-radius: 6px;
text-align: center; text-align: center;
@@ -351,7 +360,7 @@ body {
a { a {
padding: 7px; padding: 7px;
display: inline-block; display: inline-block;
color: $black-color; color: var(--text-black);
font-weight: bold; font-weight: bold;
margin: 2px; margin: 2px;
flex: 1; flex: 1;
@@ -359,14 +368,14 @@ body {
white-space: nowrap; white-space: nowrap;
&.selected_tab { &.selected_tab {
background: $primary-color; background: var(--blue);
color: $white-color; color: var(--text-white);
border-radius: 6px; border-radius: 6px;
} }
&:hover { &:hover {
background: $primary-color; background: var(--blue);
color: $white-color; color: var(--text-white);
border-radius: 6px; border-radius: 6px;
} }
} }
@@ -398,6 +407,8 @@ body {
} }
} }
}
/* Fontawesome icons */ /* Fontawesome icons */
.fa-brands, .fa-link { .fa-brands, .fa-link {
color: black; color: black;
@@ -420,6 +431,9 @@ body {
.fa-bluesky, .fa-square-bluesky { .fa-bluesky, .fa-square-bluesky {
color: #0f73ff; color: #0f73ff;
} }
.fa-github {
color: $githubblack;
} }
@media screen and (max-width: $small-devices) { @media screen and (max-width: $small-devices) {
@@ -481,16 +495,6 @@ dt {
margin-top: 25px; margin-top: 25px;
} }
.edit-bar {
display: block;
margin: 4px;
a {
display: inline-block;
margin: 4px;
}
}
.important { .important {
font-size: 1.2em; font-size: 1.2em;
font-weight: bold; font-weight: bold;
@@ -503,7 +507,6 @@ table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
border-radius: 5px; border-radius: 5px;
-moz-border-radius: 5px;
overflow: hidden; overflow: hidden;
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0, box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px; rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
@@ -515,10 +518,6 @@ table {
} }
} }
th {
padding: 4px;
}
td, td,
th { th {
vertical-align: middle; vertical-align: middle;
@@ -543,10 +542,6 @@ td {
border-collapse: collapse; border-collapse: collapse;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
>ul {
margin-top: 0;
}
} }
th, th,
@@ -556,23 +551,23 @@ thead td {
} }
thead { thead {
background-color: $deepblue; background-color: var(--deepblue);
color: white; color: white;
} }
tbody > tr { tbody > tr {
&:nth-child(even):not(.highlight) { &:nth-child(even):not(.highlight) {
background: $primary-neutral-light-color; background: var(--bg-off-white);
} }
&.clickable:hover { &.clickable:hover {
cursor: pointer; cursor: pointer;
background: $secondary-neutral-light-color; background: var(--bg-lightgrey);
width: 100%; width: 100%;
} }
&.highlight { &.highlight {
color: $primary-dark-color; color: var(--lightblue);
font-style: italic; font-style: italic;
} }
} }
@@ -598,7 +593,7 @@ em {
} }
.highlight { .highlight {
background: $second-color; background: var(--bg-yellow);
font-weight: bold; font-weight: bold;
} }
@@ -700,15 +695,15 @@ u,
height: 18em; height: 18em;
overflow: hidden; overflow: hidden;
border: 1px solid black; border: 1px solid black;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
&:hover { &:hover {
box-shadow: 1px 1px 5px $second-color; box-shadow: 1px 1px 5px var(--bg-yellow);
} }
} }
.matmat_user a { .matmat_user a {
color: $primary-neutral-dark-color; color: var(--deepblue);
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -723,7 +718,6 @@ u,
display: block; display: block;
margin: 10px; margin: 10px;
padding: 10px; padding: 10px;
background: $white-color;
} }
textarea { textarea {
@@ -731,43 +725,7 @@ textarea {
margin-top: 10px; margin-top: 10px;
} }
.ui-dialog .ui-dialog-buttonpane {
bottom: 0;
position: absolute;
width: 97%;
}
#cash_summary_form label, #cash_summary_form label,
.inline { .inline {
display: inline; display: inline;
} }
.form_button {
width: 150px;
height: 120px;
padding: 2px;
display: inline-block;
font-size: 0.8em;
span {
width: 70px;
float: right;
}
img {
max-width: 50px;
max-height: 50px;
float: left;
}
strong {
font-weight: bold;
font-size: 1.2em;
}
button {
vertical-align: middle;
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ main {
border-radius: 50%; border-radius: 50%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: $primary-neutral-light-color; background-color: var(--bg-white);
> span { > span {
font-size: small; font-size: small;
+1 -1
View File
@@ -62,7 +62,7 @@
} }
&:enabled>.fa { &:enabled>.fa {
background-color: #354a5f; background-color: var(--deepblue);
color: white; color: white;
} }
-1
View File
@@ -23,7 +23,6 @@
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" /> <meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %} {% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}"> <link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
@@ -29,5 +29,13 @@
</span> </span>
{{ form.show_my_stats.errors }} {{ form.show_my_stats.errors }}
</fieldset> </fieldset>
<fieldset class="form-group">
{{ form.show_my_timetable }}
{{ form.show_my_timetable.label_tag() }}
<span class="helptext">
{{ form.show_my_timetable.help_text }}
</span>
{{ form.show_my_timetable.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}"> <input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form> </form>
+10 -4
View File
@@ -102,6 +102,7 @@
@click.prevent="{{ page }}--" @click.prevent="{{ page }}--"
:disabled="{{ page }} <= 1" :disabled="{{ page }} <= 1"
@keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)" @keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)"
class="pagination-arrow"
> >
<i class="fa fa-caret-left"></i> <i class="fa fa-caret-left"></i>
</button> </button>
@@ -112,6 +113,7 @@
@click.prevent="{{ page }}++" @click.prevent="{{ page }}++"
:disabled="{{ page }} >= {{ nb_pages }}" :disabled="{{ page }} >= {{ nb_pages }}"
@keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)" @keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)"
class="pagination-arrow"
> >
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
</button> </button>
@@ -163,12 +165,14 @@
href="?{{ querystring(request, page=current_page.previous_page_number()) }}" href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{%- endif -%} {%- endif -%}
> >
<button> <button class="pagination-arrow">
<i class="fa fa-caret-left"></i> <i class="fa fa-caret-left"></i>
</button> </button>
</a> </a>
{% else %} {% else %}
<button disabled="disabled"><i class="fa fa-caret-left"></i></button> <button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-left"></i>
</button>
{% endif %} {% endif %}
{% for i in paginator.get_elided_page_range(current_page.number) %} {% for i in paginator.get_elided_page_range(current_page.number) %}
{% if i == current_page.number %} {% if i == current_page.number %}
@@ -201,12 +205,14 @@
{%- else -%} {%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}" href="?{{querystring(request, page=current_page.next_page_number())}}"
{%- endif -%} {%- endif -%}
><button> ><button class="pagination-arrow">
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
</button> </button>
</a> </a>
{% else %} {% else %}
<button disabled="disabled"><i class="fa fa-caret-right"></i></button> <button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-right"></i>
</button>
{% endif %} {% endif %}
</nav> </nav>
{% endmacro %} {% endmacro %}
+2 -2
View File
@@ -31,10 +31,10 @@
{% for sum in purchase_sums %} {% for sum in purchase_sums %}
<div> <div>
<span>{{ sum["counter__name"] }}</span> <span>{{ sum["counter__name"] }}</span>
<span>{{ sum["total"] }} €</span> <span>{{ "%.2f"|format(sum["total"]) }} €</span>
</div> </div>
{% endfor %} {% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div> <div><b>Total : </b><b>{{ "%.2f"|format(total_purchases) }} €</b></div>
</div> </div>
</div> </div>
</div> </div>
+6
View File
@@ -84,6 +84,7 @@ from core.views import (
notification, notification,
send_file, send_file,
) )
from timetable.views import UserTimetableView
register_converter(FourDigitYearConverter, "yyyy") register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm") register_converter(TwoDigitMonthConverter, "mm")
@@ -184,6 +185,11 @@ urlpatterns = [
name="user_account_detail", name="user_account_detail",
), ),
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"), path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
path(
"user/<int:user_id>/edt/",
UserTimetableView.as_view(),
name="user_timetable",
),
path( path(
"user/<int:user_id>/gift/create/", "user/<int:user_id>/gift/create/",
GiftCreateView.as_view(), GiftCreateView.as_view(),
+10 -2
View File
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model( __preferences_fields = forms.fields_for_model(
Preferences, Preferences,
["show_my_stats"], ["show_my_stats", "show_my_timetable"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})}, widgets={
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
},
) )
show_my_stats = __preferences_fields["show_my_stats"] show_my_stats = __preferences_fields["show_my_stats"]
show_my_timetable = __preferences_fields["show_my_timetable"]
def __init__( def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
@@ -347,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
if instance: if instance:
initial = initial or {} initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats initial["show_my_stats"] = instance.preferences.show_my_stats
initial["show_my_timetable"] = instance.preferences.show_my_timetable
super().__init__(*args, initial=initial, instance=instance, **kwargs) super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002 def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit) instance = super().save(commit=commit)
if commit: if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"] instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.show_my_timetable = self.cleaned_data[
"show_my_timetable"
]
instance.preferences.save() instance.preferences.save()
return instance return instance
+16 -2
View File
@@ -1,6 +1,14 @@
import copy import copy
import inspect import inspect
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack from typing import (
TYPE_CHECKING,
Any,
ClassVar,
LiteralString,
Protocol,
TypedDict,
Unpack,
)
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -12,11 +20,17 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
class TabItem(TypedDict):
url: str
slug: str
name: str
class TabedViewMixin(View): class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template.""" """Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list | None] = None list_of_tabs: ClassVar[list[TabItem] | None] = None
tabs_title: ClassVar[str | None] = None tabs_title: ClassVar[str | None] = None
def get_tabs_title(self): def get_tabs_title(self):
+2 -3
View File
@@ -413,7 +413,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["perm_time"] = list( kwargs["perm_time"] = list(
self.object.permanencies.filter(end__isnull=False, counter__type="BAR") self.object.permanencies.filter(end__isnull=False)
.values("counter", "counter__name") .values("counter", "counter__name")
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0))) .annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
.order_by("-total") .order_by("-total")
@@ -422,8 +422,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0) [perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
) )
kwargs["purchase_sums"] = list( kwargs["purchase_sums"] = list(
self.object.customer.buyings.filter(counter__type="BAR") self.object.customer.buyings.values("counter", "counter__name")
.values("counter", "counter__name")
.annotate(total=Sum(F("unit_price") * F("quantity"))) .annotate(total=Sum(F("unit_price") * F("quantity")))
.order_by("-total") .order_by("-total")
) )
-11
View File
@@ -647,13 +647,6 @@ class Counter(models.Model):
or user in self.sellers.all() or user in self.sellers.all()
) )
def gen_token(self) -> None:
"""Generate a new token for this counter."""
self.token = "".join(
random.choice(string.ascii_letters + string.digits) for _ in range(30)
)
self.save()
@cached_property @cached_property
def barmen_list(self) -> list[User]: def barmen_list(self) -> list[User]:
"""Returns the barman list as list of User.""" """Returns the barman list as list of User."""
@@ -661,10 +654,6 @@ class Counter(models.Model):
p.user for p in self.permanencies.filter(end=None).select_related("user") p.user for p in self.permanencies.filter(end=None).select_related("user")
] ]
def get_random_barman(self) -> User:
"""Return a random user being currently a barman."""
return random.choice(self.barmen_list)
def update_activity(self) -> None: def update_activity(self) -> None:
"""Update the barman activity to prevent timeout.""" """Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now()) self.permanencies.filter(end=None).update(activity=timezone.now())
@@ -71,7 +71,7 @@
padding: 0.5em; padding: 0.5em;
margin: 0.2em; margin: 0.2em;
height: 100%; height: 100%;
background: $secondary-neutral-light-color; background: var(--bg-off-white);
img { img {
max-width: 70%; max-width: 70%;
+19 -7
View File
@@ -83,8 +83,17 @@
{% endmacro %} {% endmacro %}
{% block content %} {% block content %}
<div class="alert alert-yellow">
<div class="alert-main">
{%- trans trimmed -%}
You can see product details.
However, you cannot edit it, thus you won't be able
to submit this form.
{%- endtrans -%}
</div>
</div>
{% if object %} {% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2> <h2>{{ object }}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p> <p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p> <p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %} {% else %}
@@ -127,19 +136,17 @@
{{ form.price_formset.management_form }} {{ form.price_formset.management_form }}
<div x-ref="formContainer"> <div x-ref="formContainer">
{%- for form in form.price_formset.forms -%} {%- for form in form.price_formset.forms -%}
<div> <div>{{ price_form(form) }}</div>
{{ price_form(form) }}
</div>
{%- endfor -%} {%- endfor -%}
</div> </div>
<template x-ref="formTemplate"> <template x-ref="formTemplate">
<div> <div>{{ price_form(form.price_formset.empty_form) }}</div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template> </template>
{% if user.has_perm("counter.change_product") %}
<button class="btn btn-grey" @click.prevent="addForm()"> <button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %} <i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button> </button>
{% endif %}
</div> </div>
<br /> <br />
@@ -165,11 +172,16 @@
<template x-ref="formTemplate"> <template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }} {{ action_form(form.action_formset.empty_form) }}
</template> </template>
{% if user.has_perm("counter.change_product") %}
<button @click.prevent="addForm()" class="btn btn-grey"> <button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %} <i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button> </button>
{% endif %}
</div> </div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div> <div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
{% if user.has_perm("counter.change_product") %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% endif %}
</form> </form>
{% endblock %} {% endblock %}
@@ -80,9 +80,11 @@
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom"> <div class="row margin-bottom">
{% if user.has_perm("counter.add_product") %}
<a href="{{ url('counter:new_product') }}" class="btn btn-blue"> <a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i> {% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a> </a>
{% endif %}
<button <button
class="btn btn-blue" class="btn btn-blue"
@click="downloadCsv()" @click="downloadCsv()"
@@ -13,12 +13,12 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<p> {% if user.has_perm("counter.add_producttype") %}
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue"> <a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
{% trans %}New product type{% endtrans %} {% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i> <i class="fa fa-plus"></i>
</a> </a>
</p> {% endif %}
{% if product_types %} {% if product_types %}
<aside> <aside>
<p> <p>
+36 -22
View File
@@ -48,7 +48,7 @@ from counter.models import (
Selling, Selling,
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminTabsMixin
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView): class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
@@ -84,17 +84,18 @@ class CounterEditView(
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id}) return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit a counter's main informations (for the counter's admin).""" """Edit a counter's main infos."""
model = Counter model = Counter
form_class = modelform_factory(Counter, fields=["name", "club", "type"]) form_class = modelform_factory(Counter, fields=["name", "club", "type"])
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
current_tab = "counters" current_tab = "counters"
permission_required = "counter.change_counter"
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""Create a counter (for the admins).""" """Create a counter (for the admins)."""
model = Counter model = Counter
@@ -105,9 +106,10 @@ class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
) )
template_name = "core/create.jinja" template_name = "core/create.jinja"
current_tab = "counters" current_tab = "counters"
permission_required = "counter.add_counter"
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView): class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
"""Delete a counter (for the admins).""" """Delete a counter (for the admins)."""
model = Counter model = Counter
@@ -115,30 +117,33 @@ class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("counter:admin_list") success_url = reverse_lazy("counter:admin_list")
current_tab = "counters" current_tab = "counters"
permission_required = "counter.delete_counter"
# Product management # Product management
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
"""A list view for the admins.""" """A list view for the admins."""
model = ProductType model = ProductType
template_name = "counter/product_type_list.jinja" template_name = "counter/product_type_list.jinja"
current_tab = "product_types" current_tab = "product_types"
context_object_name = "product_types" context_object_name = "product_types"
permission_required = "counter.view_producttype"
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""A create view for the admins.""" """A create view for the admins."""
model = ProductType model = ProductType
fields = ["name", "description", "comment", "icon"] fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja" template_name = "core/create.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.add_producttype"
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""An edit view for the admins.""" """An edit view for the admins."""
model = ProductType model = ProductType
@@ -146,23 +151,26 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
fields = ["name", "description", "comment", "icon"] fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id" pk_url_kwarg = "type_id"
current_tab = "products" current_tab = "products"
permission_required = "counter.change_producttype"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
current_tab = "products" current_tab = "products"
template_name = "counter/product_list.jinja" template_name = "counter/product_list.jinja"
permission_required = "counter.view_product"
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""A create view for the admins.""" """A create view for the admins."""
model = Product model = Product
form_class = ProductForm form_class = ProductForm
template_name = "counter/product_form.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.add_product"
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""An edit view for the admins.""" """An edit view for the admins."""
model = Product model = Product
@@ -170,6 +178,13 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
pk_url_kwarg = "product_id" pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja" template_name = "counter/product_form.jinja"
current_tab = "products" current_tab = "products"
permission_required = "counter.change_product"
def has_permission(self):
return self.request.user.has_perm("counter.change_product") or (
self.request.method == "GET"
and self.request.user.has_perm("counter.view_product")
)
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
@@ -299,7 +314,6 @@ class RefillingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs): 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() self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta( if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -324,7 +338,6 @@ class SellingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs): 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() self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta( if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -376,30 +389,31 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
return kwargs return kwargs
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class CounterRefillingListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
"""List of refillings on a counter.""" """List of refillings on a counter."""
model = Refilling model = Refilling
template_name = "counter/refilling_list.jinja" template_name = "counter/refilling_list.jinja"
current_tab = "counters" current_tab = "counters"
paginate_by = 30 paginate_by = 30
permission_required = "counter.view_refilling"
def dispatch(self, request, *args, **kwargs): def get_queryset(self):
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"]) self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
self.queryset = Refilling.objects.filter(counter__id=self.counter.id) return Refilling.objects.filter(counter=self.counter).order_by("-date")
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"counter": self.counter}
kwargs["counter"] = self.counter
return kwargs
class RefoundAccountView(UserPassesTestMixin, FormView): class RefoundAccountView(UserPassesTestMixin, SuccessMessageMixin, FormView):
"""Create a selling with the same amount as the current user money.""" """Create a selling with the same amount as the current user money."""
template_name = "counter/refound_account.jinja" template_name = "counter/refound_account.jinja"
form_class = CloseCustomerAccountForm form_class = CloseCustomerAccountForm
success_message = _("%(user)s's AE account has been emptied")
def test_func(self): def test_func(self):
return self.request.user.is_root or self.request.user.is_in_group( return self.request.user.is_root or self.request.user.is_in_group(
+8 -5
View File
@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
@@ -21,28 +21,30 @@ from django.views.generic.edit import CreateView, UpdateView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm from counter.forms import EticketForm
from counter.models import Eticket, Selling from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin from counter.views.mixins import CounterAdminTabsMixin
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
"""A list view for the admins.""" """A list view for the admins."""
model = Eticket model = Eticket
template_name = "counter/eticket_list.jinja" template_name = "counter/eticket_list.jinja"
ordering = ["id"] ordering = ["id"]
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.view_eticket"
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView): class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""Create an eticket.""" """Create an eticket."""
model = Eticket model = Eticket
template_name = "core/create.jinja" template_name = "core/create.jinja"
form_class = EticketForm form_class = EticketForm
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.add_eticket"
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit an eticket.""" """Edit an eticket."""
model = Eticket model = Eticket
@@ -50,6 +52,7 @@ class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
form_class = EticketForm form_class = EticketForm
pk_url_kwarg = "eticket_id" pk_url_kwarg = "eticket_id"
current_tab = "etickets" current_tab = "etickets"
permission_required = "counter.change_eticket"
class EticketPDFView(CanViewMixin, DetailView): class EticketPDFView(CanViewMixin, DetailView):
+35 -35
View File
@@ -13,38 +13,13 @@
# #
# #
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
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): class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
return self.object return self.object
@@ -89,45 +64,70 @@ class CounterTabsMixin(TabedViewMixin):
class CounterAdminTabsMixin(TabedViewMixin): class CounterAdminTabsMixin(TabedViewMixin):
tabs_title = _("Counter administration") tabs_title = _("Counter administration")
list_of_tabs = [
def get_list_of_tabs(self):
user = self.request.user
res = [
{ {
"url": reverse_lazy("counter:admin_list"), "url": reverse_lazy("counter:admin_list"),
"slug": "counters", "slug": "counters",
"name": _("Counters"), "name": _("Counters"),
}, }
]
if user.has_perm("counter.view_product"):
res.append(
{ {
"url": reverse_lazy("counter:product_list"), "url": reverse_lazy("counter:product_list"),
"slug": "products", "slug": "products",
"name": _("Products"), "name": _("Products"),
}, }
)
if user.has_perm("counter.view_productformula"):
res.append(
{ {
"url": reverse_lazy("counter:product_formula_list"), "url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas", "slug": "formulas",
"name": _("Formulas"), "name": _("Formulas"),
}, }
)
if user.has_perm("counter.view_producttype"):
res.append(
{ {
"url": reverse_lazy("counter:product_type_list"), "url": reverse_lazy("counter:product_type_list"),
"slug": "product_types", "slug": "product_types",
"name": _("Product types"), "name": _("Product types"),
}, }
)
if user.has_perm("counter.view_returnableproduct"):
res.append(
{ {
"url": reverse_lazy("counter:returnable_list"), "url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products", "slug": "returnable_products",
"name": _("Returnable products"), "name": _("Returnable products"),
}, }
)
if user.has_perm("counter.view_cashregistersummary"):
res.append(
{ {
"url": reverse_lazy("counter:cash_summary_list"), "url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary", "slug": "cash_summary",
"name": _("Cash register summaries"), "name": _("Cash register summaries"),
}, }
)
if user.has_perm("counter.view_invoicecall"):
res.append(
{ {
"url": reverse_lazy("counter:invoices_call"), "url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call", "slug": "invoices_call",
"name": _("Invoices call"), "name": _("Invoices call"),
}, }
)
if user.has_perm("counter.view_eticket"):
res.append(
{ {
"url": reverse_lazy("counter:eticket_list"), "url": reverse_lazy("counter:eticket_list"),
"slug": "etickets", "slug": "etickets",
"name": _("Etickets"), "name": _("Etickets"),
}, }
] )
return res
+5 -7
View File
@@ -48,7 +48,7 @@ des scripts complets mais qui restent lisibles,
``` ```
Ok, c'est de la triche, on installe un package externe. Ok, c'est de la triche, on installe un package externe.
Mais bon, ça marche, et l'équivalent pur bash Mais bon, ça marche, et l'équivalent pur bash
serait carrément plus moche. serait un pur enfer.
=== "nu" === "nu"
@@ -59,20 +59,18 @@ des scripts complets mais qui restent lisibles,
Nombre de lignes total : Nombre de lignes total :
```nu ```nu
ls **/*.py | insert linecount { get name | open | lines | length } | math sum ls **/*.py | par-each { open $in.name | lines | length } | math sum
``` ```
Vous pouvez aussi exlure les lignes vides Vous pouvez aussi exlure les lignes vides
et les les lignes de commentaire : et les les lignes de commentaire :
```nu ```nu
ls **/*.py | ls **/*.py |
insert linecount { par-each {
get name | open $in.name |
open |
lines | lines |
each { str trim } | each { str trim } |
filter { |l| not ($l | str starts-with "#") } | # commentaires where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
filter { |l| ($l | str length) > 0 } | # lignes vides
length length
} | } |
math sum math sum
+33 -9
View File
@@ -18,7 +18,8 @@ il n'est pas possible de développer le site sur ce système d'exploitation.
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
OS ni de mettre un dual boot sur votre ordinateur : `WSL`. OS ni de mettre un dual boot sur votre ordinateur : `WSL`.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11. - **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou
Windows 11.
- **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install) - **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install)
```shell ```shell
@@ -43,7 +44,7 @@ il suffit d'utiliser la commande suivante :
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
``` ```
!!!note !!!success
À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
@@ -101,10 +102,10 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
ln -s /usr/bin/valkey-server /usr/bin/redis-server ln -s /usr/bin/valkey-server /usr/bin/redis-server
``` ```
=== "macOS" === "macOS"
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_. Pour installer les dépendances, il est fortement recommandé
d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Il est également nécessaire d'avoir installé xcode Il est également nécessaire d'avoir installé xcode
```bash ```bash
@@ -116,12 +117,15 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!note !!!note
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor` Si vous rencontrez des erreurs lors de votre configuration,
n'hésitez pas à vérifier l'état de votre installation homebrew
avec :code:`brew doctor`
!!!note !!!note
Python ne fait pas parti des dépendances puisqu'il est automatiquement Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets. installé par uv. Il est cependant parfois nécessaire d'installer
les headers Python nécessaire à la compilation de certains paquets.
## Finaliser l'installation ## Finaliser l'installation
@@ -145,7 +149,6 @@ uv run ./manage.py install_xapian
de texte à l'écran. de texte à l'écran.
C'est normal, il ne faut pas avoir peur. C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables La modification de la configuration passe par la modification de variables
d'environnement. d'environnement.
Ces variables sont stockées dans un fichier `.env`. Ces variables sont stockées dans un fichier `.env`.
@@ -183,12 +186,33 @@ Il suffit ensuite d'utiliser cette commande :
uv run ./manage.py runserver uv run ./manage.py runserver
``` ```
!!!note
Le serveur est alors accessible à l'adresse Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000) [http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/). ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
Pour vous connecter, vous pouvez utiliser un des comptes utilisateur
créés durant l'installation :
| username | description |
|------------------|----------------------------------------------------------------------------|
| `root` | super-utilisateur<br/>il peut tout faire et tout voir |
| `subscriber` | simple cotisant |
| `old_subscriber` | ancien cotisant |
| `public` | non-cotisant |
| `krophil` | cotisant<br/>barman Foyer<br/>président du Troll Penché |
| `sli` | cotisant<br/>VP Troll |
| `skia` | cotisant<br/>barman MDE<br/>membre du bureau AE<br/>ancien membre du Troll |
| `comunity` | cotisant<br/>membre du bureau PdF<br/>admin communication |
| `counter` | cotisant<br/>admin comptoirs |
| `comptable` | cotisant<br/>admin comptabilité |
| `rbatsbak` | cotisant<br/>membre du Troll |
En plus de ces dernier, quelques centaines d'utilisateurs
supplémentaires sont générés procéduralement,
afin de rajouter un peu de volume de données.
Tous les utilisateurs ont le mot de passe `plop`.
!!!note !!!note
Le serveur de développement se charge de lancer redis Le serveur de développement se charge de lancer redis
+1 -1
View File
@@ -67,7 +67,7 @@
.fa-plus, .fa-plus,
.fa-minus { .fa-minus {
cursor: pointer; cursor: pointer;
background-color: #354a5f; background-color: var(--deepblue);
color: white; color: white;
border-radius: 50%; border-radius: 50%;
padding: 5px; padding: 5px;
+1 -1
View File
@@ -288,7 +288,7 @@ $min_col_width: 100px;
border: none; border: none;
color: black; color: black;
text-decoration: none; text-decoration: none;
background-color: $primary-neutral-light-color; background-color: var(--bg-white);
padding: 0.4em; padding: 0.4em;
margin: 0.1em; margin: 0.1em;
font-size: 1.18em; font-size: 1.18em;
+9 -8
View File
@@ -13,7 +13,8 @@
} }
.topic { .topic {
border: solid $primary-neutral-color 1px; border: solid var(--darkened-deepblue) 1px;
border-radius: 5px;
padding: 1px; padding: 1px;
margin: 1px; margin: 1px;
@@ -23,7 +24,7 @@
} }
a { a {
color: $black-color; color: var(--text-black);
} }
a:hover { a:hover {
@@ -56,7 +57,7 @@
} }
.forum { .forum {
background: $primary-neutral-light-color; background: var(--bg-white);
padding: 1px; padding: 1px;
margin: 1px; margin: 1px;
@@ -66,7 +67,7 @@
} }
a { a {
color: $black-color; color: var(--text-black);
} }
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
@@ -91,7 +92,7 @@
.category { .category {
margin-top: 5px; margin-top: 5px;
background: $secondary-color; background: var(--deepblue);
color: white; color: white;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
.title { .title {
@@ -105,12 +106,12 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
background: $secondary-neutral-light-color; background: var(--bg-off-white);
border-radius: 5px; border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid; border: 1px var(--bg-lightgrey) solid;
&:nth-child(odd) { &:nth-child(odd) {
background: $primary-neutral-light-color; background: var(--bg-white);
} }
.message-header { .message-header {
+5 -2
View File
@@ -346,13 +346,16 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
return kwargs return kwargs
class ForumMessageView(SingleObjectMixin, RedirectView): class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
model = ForumMessage model = ForumMessage
pk_url_kwarg = "message_id" pk_url_kwarg = "message_id"
permanent = False permanent = False
def get_redirect_url(self, *args, **kwargs): def test_func(self) -> bool:
self.object = self.get_object() self.object = self.get_object()
return self.request.user.can_view(self.object)
def get_redirect_url(self, *args, **kwargs):
return self.object.get_url() return self.object.get_url()
+32 -33
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-02 12:56+0200\n" "POT-Creation-Date: 2026-10-19 19:45+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -236,7 +236,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py #: club/forms.py com/forms.py counter/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@@ -517,6 +517,22 @@ msgstr "Url dupliquée"
msgid "This link doesn't match with the url base of its type." msgid "This link doesn't match with the url base of its type."
msgstr "Ce lien ne correspond pas à l'url de base de son type." msgstr "Ce lien ne correspond pas à l'url de base de son type."
#: club/templates/club/club_detail.jinja club/views.py
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja
#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file.jinja core/templates/core/group_list.jinja
#: core/templates/core/page/base.jinja core/templates/core/user_tools.jinja
#: core/views/user.py counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/templates/club/club_detail.jinja com/templates/com/news_list.jinja #: club/templates/club/club_detail.jinja com/templates/com/news_list.jinja
msgid "Links" msgid "Links"
msgstr "Liens" msgstr "Liens"
@@ -1019,26 +1035,6 @@ msgstr "Historique"
msgid "Tools" msgid "Tools"
msgstr "Outils" msgstr "Outils"
#: club/views.py com/templates/com/news_admin_list.jinja
#: com/templates/com/poster_list.jinja com/templates/com/screen_list.jinja
#: com/templates/com/weekmail.jinja core/templates/core/file.jinja
#: core/templates/core/group_list.jinja core/templates/core/page/base.jinja
#: core/templates/core/user_tools.jinja core/views/user.py
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/views.py
msgid "Edit club page"
msgstr "Éditer la page de club"
#: club/views.py #: club/views.py
msgid "Sellings" msgid "Sellings"
msgstr "Vente" msgstr "Vente"
@@ -1093,7 +1089,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py subscription/forms.py #: com/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -4102,7 +4098,6 @@ msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja #: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja #: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go" msgid "Go"
msgstr "Valider" msgstr "Valider"
@@ -4237,9 +4232,12 @@ msgid "Remove price"
msgstr "Retirer le prix" msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#, python-format msgid ""
msgid "Edit product %(name)s" "You can see product details. However, you cannot edit it, thus you won't be "
msgstr "Édition du produit %(name)s" "able to submit this form."
msgstr ""
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
"ne serez donc pas en mesure de soumettre le formulaire."
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "Creation date" msgid "Creation date"
@@ -4411,6 +4409,11 @@ msgstr ""
msgid "returnable product : %(returnable)s -> %(returned)s" msgid "returnable product : %(returnable)s -> %(returned)s"
msgstr "produit consigné : %(returnable)s -> %(returned)s" msgstr "produit consigné : %(returnable)s -> %(returned)s"
#: counter/views/admin.py
#, python-format
msgid "%(user)s's AE account has been emptied"
msgstr "Le compte de %(user)s a été vidé"
#: counter/views/cash.py #: counter/views/cash.py
msgid "10 cents" msgid "10 cents"
msgstr "10 centimes" msgstr "10 centimes"
@@ -5985,12 +5988,8 @@ msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation" msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/stats.jinja #: subscription/templates/subscription/stats.jinja
msgid "Total subscriptions" msgid "All subscriptions"
msgstr "Cotisations totales" msgstr "Toutes les cotisations"
#: subscription/templates/subscription/stats.jinja
msgid "Subscriptions by type"
msgstr "Cotisations par type"
#: subscription/templates/subscription/subscription.jinja #: subscription/templates/subscription/subscription.jinja
msgid "Existing member" msgid "Existing member"
+2 -6
View File
@@ -23,7 +23,6 @@ $pedagogy-white-text: #f0f0f0;
overflow-wrap: break-word; overflow-wrap: break-word;
.closed td.title { .closed td.title {
color: lighten($black-color, 10%);
font-style: italic; font-style: italic;
} }
@@ -69,9 +68,6 @@ $pedagogy-white-text: #f0f0f0;
min-width: 300px; min-width: 300px;
} }
.info-container { .info-container {
// Darken the background color just enough to make this part
// stand out a little, while not being too perceptible
background: darken($white-color, 1%);
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
border-radius: 5px; border-radius: 5px;
@@ -158,9 +154,9 @@ $pedagogy-white-text: #f0f0f0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
background: $primary-neutral-light-color; background: var(--bg-off-white);
border-radius: 5px; border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid; border: 1px var(--bg-lightgrey) solid;
&.reported { &.reported {
border: #fc8181 1px solid; border: #fc8181 1px solid;
+2 -2
View File
@@ -27,7 +27,7 @@ main {
font-size: 1.2em; font-size: 1.2em;
line-height: 1.2em; line-height: 1.2em;
color: black; color: black;
background-color: $primary-neutral-light-color; background-color: var(--bg-white);
border-radius: 5px; border-radius: 5px;
font-weight: bold; font-weight: bold;
@@ -36,7 +36,7 @@ main {
} }
&:disabled { &:disabled {
background-color: $primary-neutral-light-color; background-color: var(--bg-white);
color: #d4d4d4; color: #d4d4d4;
} }
} }
+1 -1
View File
@@ -239,7 +239,7 @@
>.tools { >.tools {
flex: 1; flex: 1;
.btn { .btn {
background-color: $primary-neutral-light-color; background-color: var(--bg-white);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
+16
View File
@@ -1 +1,17 @@
# Register your models here. # Register your models here.
from django.contrib import admin
from timetable.models import Timetable, TimetableSlot
class TimetableSlotInline(admin.TabularInline):
model = TimetableSlot
@admin.register(Timetable)
class TimetableAdmin(admin.ModelAdmin):
list_display = ("user", "semester")
search_fields = ("user__nick_name", "user__first_name", "user__last_name")
autocomplete_fields = ("user",)
inlines = (TimetableSlotInline,)
list_select_related = ("user",)
+37
View File
@@ -0,0 +1,37 @@
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import PermissionDenied
from core.utils import get_semester_code
from timetable.models import Timetable, TimetableSlot
from timetable.schemas import CreateTimetableSchema, TimetableSchema
@api_controller("/edt", urls_namespace="timetable")
class TimetableController(ControllerBase):
@route.put(
"/{int:user_id}",
auth=SessionAuth(),
response={200: TimetableSchema},
url_name="save",
)
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
user = self.context.request.user
if not user.has_perm("pedagogy.add_timetable") and not (
user.has_perm("timetable.add_self_timetable") and user.id == user_id
):
raise PermissionDenied
timetable, created = Timetable.objects.get_or_create(
semester=get_semester_code(), user_id=user_id
)
if not created:
timetable.slots.all().delete()
if user.preferences.show_my_timetable != data.is_viewable:
user.preferences.show_my_timetable = data.is_viewable
user.preferences.save()
slots = TimetableSlot.objects.bulk_create(
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
)
return TimetableSchema(
id=timetable.id, semester=timetable.semester, slots=slots
)
+141
View File
@@ -0,0 +1,141 @@
# Generated by Django 5.2.17 on 2026-10-03 21:43
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import core.utils
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="Timetable",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"semester",
models.CharField(
blank=True,
default=core.utils.get_semester_code,
validators=[
django.core.validators.RegexValidator("(A|P)\\d{2}")
],
verbose_name="semester",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="timetables",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "timetable",
"verbose_name_plural": "timetables",
"permissions": [("add_self_timetable", "Can add its own timetable")],
},
),
migrations.CreateModel(
name="TimetableSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"weekday",
models.SmallIntegerField(
choices=[
(1, "Monday"),
(2, "Tuesday"),
(3, "Wednesday"),
(4, "Thursday"),
(5, "Friday"),
(6, "Saturday"),
(7, "Sunday"),
],
verbose_name="weekday",
),
),
(
"start_at",
models.PositiveSmallIntegerField(
validators=[django.core.validators.MaxValueValidator(95)],
verbose_name="start at",
),
),
(
"end_at",
models.PositiveSmallIntegerField(
validators=[django.core.validators.MaxValueValidator(95)],
verbose_name="end at",
),
),
(
"course_type",
models.CharField(max_length=12, verbose_name="course type"),
),
("room", models.CharField(max_length=32, verbose_name="room")),
("ue", models.CharField(max_length=32, verbose_name="ue")),
(
"week_group",
models.CharField(
blank=True,
choices=[("A", "A"), ("B", "B")],
default="",
max_length=1,
verbose_name="week group",
),
),
(
"timetable",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="slots",
to="timetable.timetable",
verbose_name="timetable",
),
),
],
options={
"verbose_name": "timetable slot",
"verbose_name_plural": "timetable slots",
},
),
migrations.AddConstraint(
model_name="timetable",
constraint=models.UniqueConstraint(
fields=("user", "semester"), name="timetable_unique_user_semester"
),
),
migrations.AddConstraint(
model_name="timetableslot",
constraint=models.CheckConstraint(
condition=models.Q(("end_at__gt", models.F("start_at"))),
name="timetable_slot_end_after_start",
),
),
]
+101
View File
@@ -1 +1,102 @@
# Create your models here. # Create your models here.
from datetime import timedelta
from django.core.validators import MaxValueValidator, RegexValidator
from django.db import models
from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.utils import get_semester_code
class Timetable(models.Model):
user = models.ForeignKey(
User,
verbose_name=_("user"),
related_name="timetables",
on_delete=models.CASCADE,
)
semester = models.CharField(
_("semester"),
default=get_semester_code,
validators=[RegexValidator(r"(A|P)\d{2}")],
blank=True,
)
class Meta:
verbose_name = _("timetable")
verbose_name_plural = _("timetables")
constraints = [
models.UniqueConstraint(
fields=("user", "semester"), name="timetable_unique_user_semester"
)
]
permissions = [("add_self_timetable", "Can add its own timetable")]
def __str__(self) -> str:
return f"{self.user} {self.semester}"
class TimetableSlot(models.Model):
MINUTES_PER_SLOT = 15
class WeekDay(models.IntegerChoices):
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
class WeekGroup(models.TextChoices):
A = "A"
B = "B"
BOTH = ""
timetable = models.ForeignKey(
Timetable,
verbose_name=_("timetable"),
related_name="slots",
on_delete=models.CASCADE,
)
weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay)
# there are 4 * 24 = 96 quarters in a day, and we start counting at 0
start_at = models.PositiveSmallIntegerField(
_("start at"), validators=[MaxValueValidator(95)]
)
end_at = models.PositiveSmallIntegerField(
_("end at"), validators=[MaxValueValidator(95)]
)
course_type = models.CharField(_("course type"), max_length=12)
room = models.CharField(_("room"), max_length=32)
ue = models.CharField(_("ue"), max_length=32)
week_group = models.CharField(
_("week group"),
max_length=1,
choices=WeekGroup,
default=WeekGroup.BOTH,
blank=True,
)
class Meta:
verbose_name = _("timetable slot")
verbose_name_plural = _("timetable slots")
constraints = [
models.CheckConstraint(
condition=Q(end_at__gt=F("start_at")),
name="timetable_slot_end_after_start",
)
]
def __str__(self) -> str:
return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}"
@property
def start_hour(self):
return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT)
@property
def end_hour(self):
return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT)
+35
View File
@@ -0,0 +1,35 @@
from typing import Annotated
from annotated_types import Ge, Le
from ninja import ModelSchema, Schema
from pydantic import Field
from timetable.models import Timetable, TimetableSlot
class TimetableSlotSchema(ModelSchema):
weekday: TimetableSlot.WeekDay = Field(
description=(
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
)
)
start_at: Annotated[int, Ge(0), Le(96)]
end_at: Annotated[int, Ge(0), Le(96)]
class Meta:
model = TimetableSlot
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
class CreateTimetableSchema(Schema):
is_viewable: bool
slots: list[TimetableSlotSchema]
class TimetableSchema(ModelSchema):
slots: list[TimetableSlotSchema]
class Meta:
model = Timetable
fields = ["id", "semester"]
@@ -1,4 +1,6 @@
import html2canvas from "html2canvas"; import html2canvas from "html2canvas";
import { AlertMessage } from "#core:utils/alert-message";
import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi";
// see https://regex101.com/r/QHSaPM/3 // see https://regex101.com/r/QHSaPM/3
const TIMETABLE_ROW_RE: RegExp = const TIMETABLE_ROW_RE: RegExp =
@@ -76,17 +78,88 @@ function parseSlots(s: string): TimetableSlot[] {
} }
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("timetableGenerator", () => ({ /**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", (userId: number) => ({
content: DEFAULT_TIMETABLE, content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string }, error: null as { incorrectRow?: string } | null,
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[], courses: [] as TimetableSlot[],
edtPublic: false,
saving: false,
timetableSavedMessage: new AlertMessage(),
generate() {
try {
this.courses = parseSlots(this.content);
this.error = null;
} catch (err) {
type ParseRowError = { cause: { row: string } };
this.error = { incorrectRow: (err as ParseRowError)?.cause?.row };
return;
}
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
async saveSith() {
this.saving = true;
const res = await timetableSaveTimetable({
// biome-ignore lint/style/useNamingConvention: api is snake case
path: { user_id: userId },
body: {
// biome-ignore lint/style/useNamingConvention: api is snake case
is_viewable: this.edtPublic,
slots: this.courses.map((c) => ({
// biome-ignore lint/style/useNamingConvention: api is snake case
start_at: c.startSlot,
// biome-ignore lint/style/useNamingConvention: api is snake case
end_at: c.endSlot,
weekday: (WEEKDAYS.indexOf(c.weekday) + 1) as ApiWeekDay,
// biome-ignore lint/style/useNamingConvention: api is snake case
week_group: c.weekGroup,
// biome-ignore lint/style/useNamingConvention: api is snake case
course_type: c.courseType,
room: c.room,
ue: c.ueCode,
})),
},
});
if (res.response?.ok) {
this.timetableSavedMessage.display(
gettext("This timetable has been saved in your profile"),
{ success: true },
);
} else {
this.timetableSavedMessage.display(
interpolate(gettext("Error %d: timetable save failed"), res.response?.status),
{ success: false },
);
}
this.saving = false;
},
}));
/**
* Alpine data dedicated to timetable display only.
* It can interact with the `timetableGenerator` data with x-model and x-modelable
* (for example of this, see timetable/generator.jinja)
*/
Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({
displayedWeekdays: [] as WeekDay[],
startSlot: 0, startSlot: 0,
endSlot: 0, endSlot: 0,
table: { table: { height: 0, width: 0 },
height: 0, slots: slots,
width: 0,
},
colors: {} as Record<string, string>, colors: {} as Record<string, string>,
colorPalette: [ colorPalette: [
@@ -103,18 +176,14 @@ document.addEventListener("alpine:init", () => {
"#1b5e20", "#1b5e20",
], ],
generate() { init() {
try { this.$watch("slots", () => this.display());
this.courses = parseSlots(this.content); },
this.error = null;
} catch (err) {
this.error = { incorrectRow: err?.cause?.row };
return;
}
display() {
// color each UE // color each UE
let colorIndex = 0; let colorIndex = 0;
for (const slot of this.courses) { for (const slot of this.slots) {
if (!this.colors[slot.ueCode]) { if (!this.colors[slot.ueCode]) {
this.colors[slot.ueCode] = this.colors[slot.ueCode] =
this.colorPalette[colorIndex % this.colorPalette.length]; this.colorPalette[colorIndex % this.colorPalette.length];
@@ -123,13 +192,13 @@ document.addEventListener("alpine:init", () => {
} }
this.displayedWeekdays = WEEKDAYS.filter((day) => this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.courses.some((slot: TimetableSlot) => slot.weekday === day), this.slots.some((slot: TimetableSlot) => slot.weekday === day),
); );
this.startSlot = this.courses.reduce( this.startSlot = this.slots.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot), (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4, 25 * 4,
); );
this.endSlot = this.courses.reduce( this.endSlot = this.slots.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1, 1,
); );
@@ -138,7 +207,7 @@ document.addEventListener("alpine:init", () => {
}, },
getStyle(slot: TimetableSlot) { getStyle(slot: TimetableSlot) {
const hasWeekGroup = slot.weekGroup !== undefined; const hasWeekGroup = !!slot.weekGroup;
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH; const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0; const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
return { return {
@@ -150,15 +219,19 @@ document.addEventListener("alpine:init", () => {
}; };
}, },
getHours(): [string, object][] { /**
* Return the hours that should be displayed on this timetable,
* with their `top` placing
*/
getHours(): [string, { top: `${number}px` }][] {
let hour: number = Number.parseInt( let hour: number = Number.parseInt(
this.courses this.slots
.map((c: TimetableSlot) => c.startHour) .map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0], .split(":")[0],
10, 10,
); );
const res: [string, object][] = []; const res: [string, { top: `${number}px` }][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) { for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]); res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
hour += 1; hour += 1;
@@ -169,16 +242,5 @@ document.addEventListener("alpine:init", () => {
getWidth() { getWidth() {
return this.displayedWeekdays.length * SLOT_WIDTH + 20; return this.displayedWeekdays.length * SLOT_WIDTH + 20;
}, },
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
})); }));
}); });
@@ -1,12 +1,12 @@
@import "core/static/core/colors"; @import "core/static/core/colors";
#timetable { .timetable {
--hour-side-width: 60px; --hour-side-width: 60px;
display: block; display: block;
margin: 2em auto; margin: 2em auto;
.header { .header {
background-color: $white-color; background-color: var(--bg-white);
font-weight: bold; font-weight: bold;
box-shadow: none; box-shadow: none;
width: calc(100% - var(--hour-side-width) - 10px); width: calc(100% - var(--hour-side-width) - 10px);
+39 -41
View File
@@ -1,11 +1,13 @@
{% extends 'core/base.jinja' %} {% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}"> <link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script> <script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@@ -32,7 +34,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="timetableGenerator"> <div x-data="timetableGenerator({{ user.id }})">
<form @submit.prevent="generate()"> <form @submit.prevent="generate()">
<h1>{% trans %}Timetable generator{% endtrans %}</h1> <h1>{% trans %}Timetable generator{% endtrans %}</h1>
<template x-if="error !== null" x-cloak> <template x-if="error !== null" x-cloak>
@@ -60,44 +62,40 @@
</div> </div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}"> <input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form> </form>
<div {{ display_timetable("courses") }}
id="timetable" <template x-if="courses.length > 0">
x-show="table.height > 0 && table.width > 0" <section>
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}" {% if user.has_perm("timetable.add_self_timetable") %}
> <div id="timetable-save" class="margin-bottom">
<div class="header"> <h3>{% trans %}Save on your profile{% endtrans %}</h3>
<template x-for="weekday in displayedWeekdays"> <form>
<span x-text="weekday"></span> <fieldset>
</template> <input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
</div> <label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
<div class="content"> <span class="helptext">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)"> {% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
<template x-for="[hour, style] in getHours()"> You can change this at any time in <a href="{{ url }}">your preferences</a>
<div class="hour" :style="style"> {% endtrans %}
<div x-text="hour"></div> </span>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div> </fieldset>
</div> <button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
</template> {% trans %}Save timetable{% endtrans %}
</div>
<div class="courses">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button> </button>
<p
class="alert"
:class="timetableSavedMessage.success ? 'alert-green' : 'alert-red'"
x-show="timetableSavedMessage.open"
x-transition.duration.500ms
x-text="timetableSavedMessage.content"
></p>
</form>
</div>
{% endif %}
<h3 class="margin-bottom">{% trans %}Export{% endtrans %}</h3>
<button class="margin-bottom btn btn-blue" @click="savePng">
{% trans %}Export to PNG{% endtrans %}
</button>
</section>
</template>
</div> </div>
{% endblock content %} {% endblock content %}
@@ -0,0 +1,46 @@
{# HTML+AlpineJS to display a timetable
Args:
ues: either a JS variable name or an array of object
satisfying the TimetableSlot interface
reactive: if True and `ues` is a variable used in an Alpine context,
then this timetable will react to modifications and
change its own display
#}
{% macro display_timetable(ues) %}
<div
x-data="timetableDisplay({{ ues }})"
x-model="{{ ues }}"
x-modelable="slots"
class="timetable"
x-show="slots.length > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in slots">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,52 @@
{% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
{%- endblock -%}
{% block content %}
{% for timetable in timetables %}
<details
class="accordion"
name="{{ timetable.semester }}"
{% if loop.first %}open{% endif %}
>
<summary>{{ timetable.semester }}</summary>
<div class="accordion-content">
{{ display_timetable(timetable.semester) }}
</div>
</details>
{% endfor %}
{% endblock %}
{%- block script -%}
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
<script>
{%- for timetable in timetables -%}
const {{ timetable.semester }} = [
{%- for slot in timetable.slots.all() -%}
{
courseType: "{{ slot.course_type }}",
room: "{{ slot.room }}",
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
startSlot: {{ slot.start_at }},
endSlot: {{ slot.end_at }},
ueCode: "{{ slot.ue }}",
{%- if slot.week_group -%}
weekGroup: "{{ slot.week_group }}",
{%- endif -%}
weekday: "{{ weekdays[slot.weekday] }}"
},
{%- endfor -%}
];
{%- endfor -%}
</script>
{%- endblock -%}
-1
View File
@@ -1 +0,0 @@
# Create your tests here.
View File
Whitespace-only changes.
+20
View File
@@ -0,0 +1,20 @@
from django.test import TestCase
from django.urls import reverse
from core.baker_recipes import subscriber_user
from timetable.models import TimetableSlot
class TestSaveTimetable(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.user = subscriber_user.make()
cls.payload = {
"is_viewable": True,
"slots": [
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
],
}
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
+31
View File
@@ -0,0 +1,31 @@
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("can_save", [True, False])
def test_generator_ok(client: Client, can_save):
user = baker.make(User)
if can_save:
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
client.force_login(user)
res = client.get(reverse("timetable:generator"))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
elem = soup.find(id="timetable-save")
if can_save:
assert elem is not None
else:
assert elem is None
+5 -2
View File
@@ -1,5 +1,8 @@
from django.urls import path from django.urls import path
from timetable.views import GeneratorView from timetable.views import GeneratorView, UserTimetableView
urlpatterns = [path("", GeneratorView.as_view(), name="generator")] urlpatterns = [
path("", GeneratorView.as_view(), name="generator"),
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
]
+50 -2
View File
@@ -1,6 +1,54 @@
# Create your views here. from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic.detail import DetailView
from core.models import User
from core.views import TabedViewMixin
class GeneratorView(TemplateView): class TimetableTabsMixin(TabedViewMixin):
tabs_title = _("Timetables")
def get_list_of_tabs(self):
res = [
{
"url": reverse("timetable:generator"),
"slug": "generator",
"name": _("Generator"),
}
]
if self.request.user.timetables.exists():
res.append(
{
"url": reverse(
"core:user_timetable", kwargs={"user_id": self.request.user.id}
),
"slug": "saved",
"name": _("My timetables"),
}
)
return res
class GeneratorView(TimetableTabsMixin, TemplateView):
template_name = "timetable/generator.jinja" template_name = "timetable/generator.jinja"
current_tab = "generator"
class UserTimetableView(UserPassesTestMixin, TimetableTabsMixin, DetailView):
model = User
pk_url_kwarg = "user_id"
template_name = "timetable/user_timetable.jinja"
current_tab = "saved"
def test_func(self) -> bool | None:
return self.request.user.id == self.kwargs[
"user_id"
] or self.request.user.has_perm("timetable.view_timetable")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"timetables": list(self.object.timetables.prefetch_related("slots"))
}