7 Commits
Author SHA1 Message Date
imperosol b8ef4e2fdc style: replace scss variable by css ones 2026-10-14 13:47:51 +02:00
imperosol 182976406f style: remove unused colours 2026-10-14 13:47:51 +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 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
48 changed files with 264 additions and 916 deletions

No files matched your search

-1
View File
@@ -5,7 +5,6 @@
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
+8 -8
View File
@@ -32,7 +32,7 @@
h3 {
--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);
padding: .75rem;
margin: 0 0 0.5em 0;
@@ -85,7 +85,7 @@
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
min-height: 20em;
padding: 1em;
@@ -111,7 +111,7 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
@@ -123,7 +123,7 @@
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
}
}
@@ -141,7 +141,7 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
margin-left: 0;
margin-bottom: 0.5em;
@@ -153,8 +153,8 @@
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: $primary-neutral-dark-color;
color: $white-color;
background: var(--deepblue);
color: var(--text-white);
text-transform: uppercase;
text-align: center;
font-weight: bold;
@@ -180,7 +180,7 @@
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
background: var(--bg-white);
}
.news_event {
@@ -1,22 +0,0 @@
# 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,14 +799,6 @@ class Preferences(models.Model):
),
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(
_("get a notification for every click"), default=False
)
+26 -30
View File
@@ -1,42 +1,38 @@
$first-color: hsl(220, 100%, 50%);
$second-color: hsl(48, 100%, 67%);
$primary-color: hsl(219.9, 53.7%, 50%);
$secondary-color: hsl(204, 64%, 44%);
$primary-color-text: hsl(0, 0%, 100%);
$secondary-color-text: hsla(0, 0%, 0%, 0.87);
/**
* Sith theme variables
*/
:root {
--bg-yellow: #ffe270;
--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%);
$primary-dark-color: hsl(203, 75%, 40%);
--text-white: #f9fafb;
--text-black: #2b2b2b;
--text-red: #eb2f06;
$secondary-light-color: hsl(40, 68%, 65%);
$secondary-dark-color: hsl(40, 68%, 35%);
$primary-neutral-color: hsl(219.6, 20.8%, 50%);
$primary-neutral-light-color: hsl(0, 0%, 94%);
$primary-neutral-dark-color: hsl(210, 29%, 29%);
$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;
--blue: #3b69c4;
--lightblue: #1a78b3;
// darkened-deepblue is 10% darker than deepblue
// This is explicitely declared, because the `darken` scss function
// isn't called at compile-time somehow.
--deepblue: #354a5f;
--darkened-deepblue: #23313e;
}
/**
* Brand colours.
* Those are officially defined by their respective companies,
* and so are more appropriate as scss variables.
*/
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
}
+3 -3
View File
@@ -17,7 +17,7 @@
}
.card {
background-color: $primary-neutral-light-color;
background-color: var(--bg-off-white);
border-radius: 5px;
position: relative;
box-sizing: border-box;
@@ -30,12 +30,12 @@
gap: 20px;
&:disabled {
background-color: darken($primary-neutral-light-color, 5%);
background-color: var(--bg-darkened-off-white);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%);
background-color: var(--bg-darkened-off-white);
}
&.selected {
+8 -4
View File
@@ -11,7 +11,7 @@ ui-tab-group {
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: $primary-neutral-light-color;
background-color: var(--bg-off-white);
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
@@ -25,23 +25,27 @@ ui-tab-group {
background-color: unset;
position: relative;
&:hover {
background-color: var(--bg-lightgrey);
}
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-bottom: 4px solid var(--bg-lightgrey);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%);
filter: brightness(85%);
}
&.active:after {
border-bottom-color: $primary-dark-color;
border-bottom-color: var(--lightblue);
}
}
}
+11 -13
View File
@@ -9,7 +9,7 @@ footer.bottom-links {
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: $primary-neutral-dark-color;
background-color: var(--deepblue);
display: flex;
flex-direction: column;
@@ -28,24 +28,24 @@ footer.bottom-links {
}
a {
color: $white-color;
color: var(--text-white);
width: auto;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
color: var(--text-white);
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
}
}
.fa-github {
color: $white-color;
color: var(--text-white);
}
hr {
width: 100%;
height: 0px;
height: 0;
border: none;
border-top: 0.5px solid $white-color;
border-top: 0.5px solid var(--bg-white);
}
}
@@ -59,24 +59,22 @@ footer.bottom-links {
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
color: var(--text-white);
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
background-color: var(--deepblue);
box-shadow: var(--bg-white) 0 0 15px;
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
text-shadow: 0.5px 0.5px 0.5px var(--bg-white);
}
}
}
+10 -14
View File
@@ -16,7 +16,6 @@
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -24,7 +23,7 @@
color: black;
&:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%);
background: var(--bg-lightgrey);
}
}
@@ -53,7 +52,7 @@
.ts-control {
border: none;
text-decoration: none;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -64,7 +63,7 @@
textarea {
border: none;
text-decoration: none;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
@@ -75,7 +74,7 @@
border: none;
text-decoration: none;
font-size: 1.2em;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
@@ -83,14 +82,14 @@
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
color: var(--lightblue);
&:hover {
color: $primary-light-color;
color: var(--blue);
}
&:active {
color: $primary-color;
color: var(--blue);
}
}
}
@@ -762,16 +761,13 @@ form {
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
color: var(--lightblue);
&:hover {
color: $primary-light-color;
color: var(--blue);
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
color: var(--text-red);
}
font-weight: normal;
font-size: 100%;
+4 -4
View File
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
.header {
box-sizing: border-box;
background-color: $deepblue;
background-color: var(--deepblue);
box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
@@ -96,7 +96,7 @@ $background-color-hovered: #283747;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: $deepblue;
background-color: var(--deepblue);
width: 45px;
height: 25px;
padding: 0;
@@ -212,7 +212,7 @@ $background-color-hovered: #283747;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
background-color: $deepblue;
background-color: var(--deepblue);
}
>.options {
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
width: 10px;
height: 10px;
padding: 5px;
background-color: $red-text-color;
background-color: var(--text-red);
border-radius: 50%;
position: absolute;
top: -50%;
+2 -5
View File
@@ -1,7 +1,4 @@
.markdown {
--bg-lightgrey: rgb(240, 241, 245);
--border-lightgrey: rgb(215, 216, 220);
h1,
h2,
h3,
@@ -28,8 +25,8 @@
font-size: 14px;
tab-size: 4;
border-radius: 4px;
border: 1px solid var(--border-lightgrey);
background-color: var(--bg-lightgrey);
border: 1px solid var(--bg-lightgrey);
background-color: var(--bg-off-white);
padding: 1px 2px;
}
+1 -1
View File
@@ -3,7 +3,7 @@
$desktop-size: 500px;
nav.navbar {
background-color: $primary-dark-color;
background-color: var(--lightblue);
margin: 1em;
color: white;
border-radius: 0.6em;
+4 -10
View File
@@ -6,27 +6,21 @@
margin: 30px;
button {
background-color: $secondary-neutral-light-color;
background-color: var(--bg-off-white);
min-width: 37px;
&:hover {
background-color: darken($secondary-neutral-light-color, 10%);
}
&:disabled {
color: #fff;
background-color: darken($secondary-neutral-light-color, 5%);
background-color: var(--bg-lightgrey);
}
&.active,
&.active:hover {
background-color: $primary-neutral-color;
background-color: var(--deepblue);
color: white;
border-radius: 50%;
}
&:first-of-type,
&:last-of-type {
.pagination-arrow {
// previous and next buttons
&:disabled {
// Hide the arrows when they are disabled, without
+23 -24
View File
@@ -57,7 +57,7 @@ body {
.collapse-header {
color: white;
background-color: $deepblue;
background-color: var(--deepblue);
padding: 5px 10px;
display: flex;
align-items: center;
@@ -141,7 +141,7 @@ body {
}
#info_box {
background: $primary-neutral-light-color;
background: var(--bg-off-white);
&:before {
content: "\f05a";
@@ -150,11 +150,11 @@ body {
}
#alert_box {
background: $second-color;
background: var(--bg-yellow);
&:before {
content: "\f06a";
color: $white-color;
color: var(--text-white);
}
}
}
@@ -189,10 +189,10 @@ body {
border-radius: 5px;
&.btn-blue {
background-color: $deepblue;
background-color: var(--deepblue);
&:not(:disabled):hover {
background-color: darken($deepblue, 10%);
background-color: var(--darkened-deepblue);
}
&:disabled {
@@ -215,7 +215,7 @@ body {
&.btn-green {
$bg-color: rgba(0, 210, 83, 0.4);
background-color: $bg-color;
color: $black-color;
color: var(--text-black);
&:not(:disabled):hover {
background-color: darken($bg-color, 15%);
@@ -272,8 +272,8 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1.5em 3%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
background: var(--bg-white);
overflow: auto;
}
@@ -343,7 +343,7 @@ body {
display: flex;
flex-wrap: wrap;
float: right;
background: $primary-neutral-light-color;
background: var(--bg-off-white);
padding: 5px;
border-radius: 6px;
text-align: center;
@@ -351,7 +351,7 @@ body {
a {
padding: 7px;
display: inline-block;
color: $black-color;
color: var(--text-black);
font-weight: bold;
margin: 2px;
flex: 1;
@@ -359,14 +359,14 @@ body {
white-space: nowrap;
&.selected_tab {
background: $primary-color;
color: $white-color;
background: var(--blue);
color: var(--text-white);
border-radius: 6px;
}
&:hover {
background: $primary-color;
color: $white-color;
background: var(--blue);
color: var(--text-white);
border-radius: 6px;
}
}
@@ -556,23 +556,23 @@ thead td {
}
thead {
background-color: $deepblue;
background-color: var(--deepblue);
color: white;
}
tbody>tr {
&:nth-child(even):not(.highlight) {
background: $primary-neutral-light-color;
background: var(--bg-off-white);
}
&.clickable:hover {
cursor: pointer;
background: $secondary-neutral-light-color;
background: var(--bg-lightgrey);
width: 100%;
}
&.highlight {
color: $primary-dark-color;
color: var(--lightblue);
font-style: italic;
}
}
@@ -598,7 +598,7 @@ em {
}
.highlight {
background: $second-color;
background: var(--bg-yellow);
font-weight: bold;
}
@@ -700,15 +700,15 @@ u,
height: 18em;
overflow: hidden;
border: 1px solid black;
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
&:hover {
box-shadow: 1px 1px 5px $second-color;
box-shadow: 1px 1px 5px var(--bg-yellow);
}
}
.matmat_user a {
color: $primary-neutral-dark-color;
color: var(--deepblue);
height: 100%;
width: 100%;
margin: 0;
@@ -723,7 +723,6 @@ u,
display: block;
margin: 10px;
padding: 10px;
background: $white-color;
}
textarea {
+1 -1
View File
@@ -71,7 +71,7 @@ main {
border-radius: 50%;
justify-content: center;
align-items: center;
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
> span {
font-size: small;
+1 -1
View File
@@ -62,7 +62,7 @@
}
&:enabled>.fa {
background-color: #354a5f;
background-color: var(--deepblue);
color: white;
}
@@ -29,13 +29,5 @@
</span>
{{ form.show_my_stats.errors }}
</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 %}">
</form>
+10 -4
View File
@@ -102,6 +102,7 @@
@click.prevent="{{ page }}--"
:disabled="{{ page }} <= 1"
@keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-left"></i>
</button>
@@ -112,6 +113,7 @@
@click.prevent="{{ page }}++"
:disabled="{{ page }} >= {{ nb_pages }}"
@keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-right"></i>
</button>
@@ -163,12 +165,14 @@
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
<button class="pagination-arrow">
<i class="fa fa-caret-left"></i>
</button>
</a>
{% 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 %}
{% for i in paginator.get_elided_page_range(current_page.number) %}
{% if i == current_page.number %}
@@ -201,12 +205,14 @@
{%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}"
{%- endif -%}
><button>
><button class="pagination-arrow">
<i class="fa fa-caret-right"></i>
</button>
</a>
{% 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 %}
</nav>
{% endmacro %}
-6
View File
@@ -84,7 +84,6 @@ from core.views import (
notification,
send_file,
)
from timetable.views import UserTimetableView
register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm")
@@ -185,11 +184,6 @@ urlpatterns = [
name="user_account_detail",
),
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(
"user/<int:user_id>/gift/create/",
GiftCreateView.as_view(),
+2 -10
View File
@@ -336,14 +336,10 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats", "show_my_timetable"],
widgets={
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
},
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
)
show_my_stats = __preferences_fields["show_my_stats"]
show_my_timetable = __preferences_fields["show_my_timetable"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
@@ -351,16 +347,12 @@ class UserVisibilityForm(forms.ModelForm):
if instance:
initial = initial or {}
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)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
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()
return instance
+2 -16
View File
@@ -1,14 +1,6 @@
import copy
import inspect
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
LiteralString,
Protocol,
TypedDict,
Unpack,
)
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string
@@ -20,17 +12,11 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeString
class TabItem(TypedDict):
url: str
slug: str
name: str
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list[TabItem] | None] = None
list_of_tabs: ClassVar[list | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self):
@@ -71,7 +71,7 @@
padding: 0.5em;
margin: 0.2em;
height: 100%;
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
img {
max-width: 70%;
+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.
Mais bon, ça marche, et l'équivalent pur bash
serait carrément plus moche.
serait un pur enfer.
=== "nu"
@@ -59,20 +59,18 @@ des scripts complets mais qui restent lisibles,
Nombre de lignes total :
```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
et les les lignes de commentaire :
```nu
ls **/*.py |
insert linecount {
get name |
open |
par-each {
open $in.name |
lines |
each { str trim } |
filter { |l| not ($l | str starts-with "#") } | # commentaires
filter { |l| ($l | str length) > 0 } | # lignes vides
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
length
} |
math sum
+35 -11
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
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)
```shell
@@ -43,7 +44,7 @@ il suffit d'utiliser la commande suivante :
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
é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
```
=== "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
```bash
@@ -116,12 +117,15 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!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
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
@@ -145,7 +149,6 @@ uv run ./manage.py install_xapian
de texte à l'écran.
C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables
d'environnement.
Ces variables sont stockées dans un fichier `.env`.
@@ -183,11 +186,32 @@ Il suffit ensuite d'utiliser cette commande :
uv run ./manage.py runserver
```
!!!note
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost: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
+1 -1
View File
@@ -67,7 +67,7 @@
.fa-plus,
.fa-minus {
cursor: pointer;
background-color: #354a5f;
background-color: var(--deepblue);
color: white;
border-radius: 50%;
padding: 5px;
+1 -1
View File
@@ -288,7 +288,7 @@ $min_col_width: 100px;
border: none;
color: black;
text-decoration: none;
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
padding: 0.4em;
margin: 0.1em;
font-size: 1.18em;
+9 -8
View File
@@ -13,7 +13,8 @@
}
.topic {
border: solid $primary-neutral-color 1px;
border: solid var(--darkened-deepblue) 1px;
border-radius: 5px;
padding: 1px;
margin: 1px;
@@ -23,7 +24,7 @@
}
a {
color: $black-color;
color: var(--text-black);
}
a:hover {
@@ -56,7 +57,7 @@
}
.forum {
background: $primary-neutral-light-color;
background: var(--bg-white);
padding: 1px;
margin: 1px;
@@ -66,7 +67,7 @@
}
a {
color: $black-color;
color: var(--text-black);
}
a:hover {
text-decoration: underline;
@@ -91,7 +92,7 @@
.category {
margin-top: 5px;
background: $secondary-color;
background: var(--deepblue);
color: white;
border-radius: 10px 10px 0 0;
.title {
@@ -105,12 +106,12 @@
display: flex;
flex-direction: column;
gap: 10px;
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
border: 1px var(--bg-lightgrey) solid;
&:nth-child(odd) {
background: $primary-neutral-light-color;
background: var(--bg-white);
}
.message-header {
+5 -2
View File
@@ -346,13 +346,16 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
return kwargs
class ForumMessageView(SingleObjectMixin, RedirectView):
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
model = ForumMessage
pk_url_kwarg = "message_id"
permanent = False
def get_redirect_url(self, *args, **kwargs):
def test_func(self) -> bool:
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()
+2 -6
View File
@@ -23,7 +23,6 @@ $pedagogy-white-text: #f0f0f0;
overflow-wrap: break-word;
.closed td.title {
color: lighten($black-color, 10%);
font-style: italic;
}
@@ -69,9 +68,6 @@ $pedagogy-white-text: #f0f0f0;
min-width: 300px;
}
.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;
grid-template-columns: 1fr 1fr;
border-radius: 5px;
@@ -158,9 +154,9 @@ $pedagogy-white-text: #f0f0f0;
display: flex;
flex-direction: column;
gap: 10px;
background: $primary-neutral-light-color;
background: var(--bg-off-white);
border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
border: 1px var(--bg-lightgrey) solid;
&.reported {
border: #fc8181 1px solid;
+5 -19
View File
@@ -2,7 +2,6 @@ import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
@@ -228,11 +227,6 @@ document.addEventListener("alpine:init", () => {
* Error message when a moderation operation fails
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
**/
pushstate: History.Push,
async init() {
this.pictures = (
@@ -267,12 +261,10 @@ document.addEventListener("alpine:init", () => {
if (!event.state || event.state.sasPictureId === undefined) {
return;
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
) as PictureWithIdentifications;
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
@@ -285,17 +277,11 @@ document.addEventListener("alpine:init", () => {
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
window.history.replaceState(
{ sasPictureId: this.currentPicture.id },
"",
this.currentPicture.sas_url,
);
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
+2 -2
View File
@@ -27,7 +27,7 @@ main {
font-size: 1.2em;
line-height: 1.2em;
color: black;
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
border-radius: 5px;
font-weight: bold;
@@ -36,7 +36,7 @@ main {
}
&:disabled {
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
color: #d4d4d4;
}
}
+1 -1
View File
@@ -239,7 +239,7 @@
>.tools {
flex: 1;
.btn {
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
display: flex;
justify-content: center;
align-items: center;
-16
View File
@@ -1,17 +1 @@
# 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
@@ -1,37 +0,0 @@
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
@@ -1,141 +0,0 @@
# 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,102 +1 @@
# 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
@@ -1,35 +0,0 @@
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,6 +1,4 @@
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
const TIMETABLE_ROW_RE: RegExp =
@@ -78,82 +76,17 @@ function parseSlots(s: string): TimetableSlot[] {
}
document.addEventListener("alpine:init", () => {
/**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string } | null,
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({
body: {
// biome-ignore lint/style/useNamingConvention: api is snake case
is_viewable: this.edtPublic,
slots: this.courses.map((c) =>
Object.assign({}, 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,
}),
),
},
});
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[]) => ({
error: null as { incorrectRow?: string },
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0,
endSlot: 0,
table: { height: 0, width: 0 },
slots: slots,
table: {
height: 0,
width: 0,
},
colors: {} as Record<string, string>,
colorPalette: [
@@ -170,14 +103,18 @@ document.addEventListener("alpine:init", () => {
"#1b5e20",
],
init() {
this.$watch("slots", () => this.display());
},
generate() {
try {
this.courses = parseSlots(this.content);
this.error = null;
} catch (err) {
this.error = { incorrectRow: err?.cause?.row };
return;
}
display() {
// color each UE
let colorIndex = 0;
for (const slot of this.slots) {
for (const slot of this.courses) {
if (!this.colors[slot.ueCode]) {
this.colors[slot.ueCode] =
this.colorPalette[colorIndex % this.colorPalette.length];
@@ -186,13 +123,13 @@ document.addEventListener("alpine:init", () => {
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.slots.reduce(
this.startSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4,
);
this.endSlot = this.slots.reduce(
this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1,
);
@@ -201,7 +138,7 @@ document.addEventListener("alpine:init", () => {
},
getStyle(slot: TimetableSlot) {
const hasWeekGroup = !!slot.weekGroup;
const hasWeekGroup = slot.weekGroup !== undefined;
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
return {
@@ -213,19 +150,15 @@ document.addEventListener("alpine:init", () => {
};
},
/**
* Return the hours that should be displayed on this timetable,
* with their `top` placing
*/
getHours(): [string, { top: `${number}px` }][] {
getHours(): [string, object][] {
let hour: number = Number.parseInt(
this.slots
this.courses
.map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0],
10,
);
const res: [string, { top: `${number}px` }][] = [];
const res: [string, object][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
hour += 1;
@@ -236,5 +169,16 @@ document.addEventListener("alpine:init", () => {
getWidth() {
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";
.timetable {
#timetable {
--hour-side-width: 60px;
display: block;
margin: 2em auto;
.header {
background-color: $white-color;
background-color: var(--bg-white);
font-weight: bold;
box-shadow: none;
width: calc(100% - var(--hour-side-width) - 10px);
+41 -39
View File
@@ -1,13 +1,11 @@
{% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{% extends 'core/base.jinja' %}
{%- 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>
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
@@ -62,40 +60,44 @@
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form>
{{ display_timetable("courses") }}
<template x-if="courses.length > 0">
<section>
{% if user.has_perm("timetable.add_self_timetable") %}
<div id="timetable-save" class="margin-bottom">
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
<form>
<fieldset>
<input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
<label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
<span class="helptext">
{% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
You can change this at any time in <a href="{{ url }}">your preferences</a>
{% endtrans %}
</span>
</fieldset>
<button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
{% trans %}Save timetable{% endtrans %}
</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
id="timetable"
x-show="table.height > 0 && table.width > 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 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>
</div>
{% endblock content %}
@@ -1,46 +0,0 @@
{# 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 %}
@@ -1,52 +0,0 @@
{% 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
@@ -0,0 +1 @@
# Create your tests here.
View File
Whitespace-only changes.
-20
View File
@@ -1,20 +0,0 @@
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
@@ -1,31 +0,0 @@
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
+2 -5
View File
@@ -1,8 +1,5 @@
from django.urls import path
from timetable.views import GeneratorView, UserTimetableView
from timetable.views import GeneratorView
urlpatterns = [
path("", GeneratorView.as_view(), name="generator"),
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
]
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
+2 -50
View File
@@ -1,54 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
# Create your views here.
from django.views.generic import TemplateView
from django.views.generic.detail import DetailView
from core.models import User
from core.views import TabedViewMixin
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):
class GeneratorView(TemplateView):
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"))
}