5 Commits
Author SHA1 Message Date
imperosol 731747aa6c style: clean footer.scss 2026-10-15 14:24:00 +02:00
imperosol 85dc1eb3de 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-15 14:24:00 +02:00
imperosol 92058a7932 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-15 14:24:00 +02:00
imperosol be2c54bbd1 style: replace scss variable by css ones 2026-10-15 14:24:00 +02:00
imperosol d99218e65f style: remove unused colours 2026-10-15 14:24:00 +02:00
47 changed files with 252 additions and 1109 deletions

No files matched your search

-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 {
@@ -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, 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
) )
-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);
} }
} }
} }
+17 -34
View File
@@ -2,92 +2,75 @@
@import "devices"; @import "devices";
footer.bottom-links { footer.bottom-links {
>section>a { > section > a {
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%;
+2 -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,8 +25,8 @@
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;
} }
+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
+52 -94
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,28 +407,33 @@ body {
} }
} }
/* Fontawesome icons */ }
.fa-brands, .fa-link {
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black; color: black;
} }
.fa-facebook { .fa-facebook {
color: $faceblue; color: $faceblue;
} }
.fa-discord { .fa-discord {
color: $discordblurple; color: $discordblurple;
} }
.fa-square-instagram::before, .fa-instagram::before { .fa-square-instagram::before, .fa-instagram::before {
background: $instagradient; background: $instagradient;
background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
.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,25 +518,21 @@ table {
} }
} }
th {
padding: 4px;
}
td, td,
th { th {
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
padding: 5px 10px; padding: 5px 10px;
>input[type="checkbox"] { > input[type="checkbox"] {
padding: unset; padding: unset;
} }
>ul { > ul {
margin-top: 0; margin-top: 0;
} }
>input[type="checkbox"] { > input[type="checkbox"] {
padding: unset; padding: unset;
} }
} }
@@ -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,13 +29,5 @@
</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 %}
-6
View File
@@ -84,7 +84,6 @@ 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")
@@ -185,11 +184,6 @@ 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(),
+2 -10
View File
@@ -336,14 +336,10 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model( __preferences_fields = forms.fields_for_model(
Preferences, Preferences,
["show_my_stats", "show_my_timetable"], ["show_my_stats"],
widgets={ widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
"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
@@ -351,16 +347,12 @@ 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
+2 -16
View File
@@ -1,14 +1,6 @@
import copy import copy
import inspect import inspect
from typing import ( from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
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
@@ -20,17 +12,11 @@ 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[TabItem] | None] = None list_of_tabs: ClassVar[list | None] = None
tabs_title: ClassVar[str | None] = None tabs_title: ClassVar[str | None] = None
def get_tabs_title(self): def get_tabs_title(self):
@@ -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%;
+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 {
+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;
+5 -19
View File
@@ -2,7 +2,6 @@ import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types"; import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
@@ -228,11 +227,6 @@ document.addEventListener("alpine:init", () => {
* Error message when a moderation operation fails * Error message when a moderation operation fails
**/ **/
moderationError: "", 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() { async init() {
this.pictures = ( this.pictures = (
@@ -267,12 +261,10 @@ document.addEventListener("alpine:init", () => {
if (!event.state || event.state.sasPictureId === undefined) { if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10), (i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
) as PictureWithIdentifications; ) as PictureWithIdentifications;
}); });
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture(); await this.updatePicture();
}, },
@@ -285,17 +277,11 @@ document.addEventListener("alpine:init", () => {
* the list of identified users are updated. * the list of identified users are updated.
*/ */
async updatePicture(): Promise<void> { async updatePicture(): Promise<void> {
const updateArgs = { window.history.replaceState(
data: { sasPictureId: this.currentPicture.id }, { sasPictureId: this.currentPicture.id },
unused: "", "",
url: this.currentPicture.sas_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);
}
this.moderationError = ""; this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture); const index: number = this.pictures.indexOf(this.currentPicture);
+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,17 +1 @@
# 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
@@ -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. # 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 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 =
@@ -78,82 +76,17 @@ function parseSlots(s: string): TimetableSlot[] {
} }
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
/**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", () => ({ Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE, content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string } | null, error: null as { incorrectRow?: string },
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[]) => ({
displayedWeekdays: [] as WeekDay[], displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0, startSlot: 0,
endSlot: 0, endSlot: 0,
table: { height: 0, width: 0 }, table: {
slots: slots, height: 0,
width: 0,
},
colors: {} as Record<string, string>, colors: {} as Record<string, string>,
colorPalette: [ colorPalette: [
@@ -170,14 +103,18 @@ document.addEventListener("alpine:init", () => {
"#1b5e20", "#1b5e20",
], ],
init() { generate() {
this.$watch("slots", () => this.display()); try {
}, 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.slots) { for (const slot of this.courses) {
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];
@@ -186,13 +123,13 @@ document.addEventListener("alpine:init", () => {
} }
this.displayedWeekdays = WEEKDAYS.filter((day) => 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), (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4, 25 * 4,
); );
this.endSlot = this.slots.reduce( this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1, 1,
); );
@@ -201,7 +138,7 @@ document.addEventListener("alpine:init", () => {
}, },
getStyle(slot: TimetableSlot) { getStyle(slot: TimetableSlot) {
const hasWeekGroup = !!slot.weekGroup; const hasWeekGroup = slot.weekGroup !== undefined;
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 {
@@ -213,19 +150,15 @@ 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.slots this.courses
.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, { top: `${number}px` }][] = []; const res: [string, object][] = [];
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;
@@ -236,5 +169,16 @@ 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);
+40 -38
View File
@@ -1,13 +1,11 @@
{% 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/timetable-index.ts') }}"></script> <script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@@ -62,40 +60,44 @@
</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>
{{ display_timetable("courses") }} <div
<template x-if="courses.length > 0"> id="timetable"
<section> x-show="table.height > 0 && table.width > 0"
{% if user.has_perm("timetable.add_self_timetable") %} :style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
<div id="timetable-save" class="margin-bottom"> >
<h3>{% trans %}Save on your profile{% endtrans %}</h3> <div class="header">
<form> <template x-for="weekday in displayedWeekdays">
<fieldset> <span x-text="weekday"></span>
<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> </template>
</div> </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 %} {% 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 django.urls import path
from timetable.views import GeneratorView, UserTimetableView from timetable.views import GeneratorView
urlpatterns = [ urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
path("", GeneratorView.as_view(), name="generator"),
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
]
+2 -50
View File
@@ -1,54 +1,6 @@
from django.contrib.auth.mixins import UserPassesTestMixin # Create your views here.
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 TimetableTabsMixin(TabedViewMixin): class GeneratorView(TemplateView):
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"))
}