mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-14 14:46:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33289c1c04 | ||
|
|
ead76e49b9 | ||
|
|
e87a4755b9 | ||
|
|
c80f4ee2b8 | ||
|
|
a4964836e4 | ||
|
|
0b5b3f6e38 | ||
|
|
32bc7e4061 | ||
|
|
f29010b5dc | ||
|
|
05d6c01957 |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
margin-top: 20px;
|
||||
padding: 0.4em;
|
||||
width: 80%;
|
||||
background: $white-color;
|
||||
|
||||
h4 {
|
||||
margin-top: 1em;
|
||||
|
||||
@@ -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: var(--bg-yellow);
|
||||
background: lighten($second-color, 5%);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: .75rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
min-height: 20em;
|
||||
padding: 1em;
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
#birthdays_content {
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
padding: 1em;
|
||||
|
||||
ul.birthdays_year {
|
||||
@@ -123,7 +123,7 @@
|
||||
padding: 0.5em;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--bg-off-white);
|
||||
background: $secondary-neutral-light-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
/* EVENTS TODAY AND NEXT FEW DAYS */
|
||||
.news_events_group {
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -153,8 +153,8 @@
|
||||
display: table-cell;
|
||||
padding: 0.6em;
|
||||
vertical-align: middle;
|
||||
background: var(--deepblue);
|
||||
color: var(--text-white);
|
||||
background: $primary-neutral-dark-color;
|
||||
color: $white-color;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
.news_event:nth-of-type(even) {
|
||||
background: var(--bg-white);
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
|
||||
.news_event {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-24 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0051_user_cgu_approved_at")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="preferences",
|
||||
name="show_my_timetable",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"Allow subscribers to see your timetable and "
|
||||
"to compare it to theirs (if you have filled it)"
|
||||
),
|
||||
verbose_name="show your timetable to others",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -799,6 +799,14 @@ 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
|
||||
)
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
/**
|
||||
* Sith theme variables
|
||||
*/
|
||||
:root {
|
||||
--bg-yellow: #ffe270;
|
||||
--bg-white: #f9fafb;
|
||||
--bg-off-white: #f0f0f0;
|
||||
--bg-darkened-off-white: #e3e3e3;
|
||||
--bg-lightgrey: #d4d4d4;
|
||||
$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);
|
||||
|
||||
--text-white: #f9fafb;
|
||||
--text-black: #2b2b2b;
|
||||
--text-red: #eb2f06;
|
||||
$primary-light-color: hsl(219.8, 46.4%, 64.9%);
|
||||
$primary-dark-color: hsl(203, 75%, 40%);
|
||||
|
||||
--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;
|
||||
}
|
||||
$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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-off-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
@@ -30,12 +30,12 @@
|
||||
gap: 20px;
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
opacity: 65%;
|
||||
}
|
||||
|
||||
&.clickable:not(:disabled):hover {
|
||||
background-color: var(--bg-darkened-off-white);
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
||||
@@ -11,7 +11,7 @@ ui-tab-group {
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
background-color: var(--bg-off-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
padding: 3px 12px 12px;
|
||||
column-gap: 20px;
|
||||
border-top-left-radius: 5px;
|
||||
@@ -25,27 +25,23 @@ 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 var(--bg-lightgrey);
|
||||
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
filter: brightness(85%);
|
||||
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||
}
|
||||
|
||||
&.active:after {
|
||||
border-bottom-color: var(--lightblue);
|
||||
border-bottom-color: $primary-dark-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ footer.bottom-links {
|
||||
@media (max-width: $small-devices) {
|
||||
margin-top: 0.6em;
|
||||
padding: 1.25em;
|
||||
background-color: var(--deepblue);
|
||||
background-color: $primary-neutral-dark-color;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -28,24 +28,24 @@ footer.bottom-links {
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-white);
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-white);
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: var(--text-white);
|
||||
color: $white-color;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
height: 0px;
|
||||
border: none;
|
||||
border-top: 0.5px solid var(--bg-white);
|
||||
border-top: 0.5px solid $white-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,22 +59,24 @@ footer.bottom-links {
|
||||
|
||||
section:first-of-type {
|
||||
margin: 0.6em 0;
|
||||
color: var(--text-white);
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: var(--deepblue);
|
||||
box-shadow: var(--bg-white) 0 0 15px;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
text-shadow: 0.5px 0.5px 0.5px var(--bg-white);
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-10
@@ -16,6 +16,7 @@
|
||||
input[type="file"] {
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
background-color: $background-button-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.2em;
|
||||
@@ -23,7 +24,7 @@
|
||||
color: black;
|
||||
|
||||
&:not(.link-like):not(:disabled):hover {
|
||||
background: var(--bg-lightgrey);
|
||||
background: hsl(0, 0%, 83%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +53,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;
|
||||
@@ -63,7 +64,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;
|
||||
@@ -74,7 +75,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;
|
||||
@@ -82,14 +83,14 @@
|
||||
|
||||
a:not(.button) {
|
||||
text-decoration: none;
|
||||
color: var(--lightblue);
|
||||
color: $primary-dark-color;
|
||||
|
||||
&:hover {
|
||||
color: var(--blue);
|
||||
color: $primary-light-color;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--blue);
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,13 +762,16 @@ form {
|
||||
// a submit input that should look like a regular <a>
|
||||
input[type="submit"], button {
|
||||
&.link-like {
|
||||
color: var(--lightblue);
|
||||
color: $primary-dark-color;
|
||||
&:hover {
|
||||
color: var(--blue);
|
||||
color: $primary-light-color;
|
||||
}
|
||||
|
||||
&.link-red {
|
||||
color: var(--text-red);
|
||||
color: $red-text-color;
|
||||
&:hover {
|
||||
color: $hovered-red-text-color;
|
||||
}
|
||||
}
|
||||
font-weight: normal;
|
||||
font-size: 100%;
|
||||
|
||||
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--deepblue);
|
||||
background-color: $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: var(--deepblue);
|
||||
background-color: $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: var(--deepblue);
|
||||
background-color: $deepblue;
|
||||
}
|
||||
|
||||
>.options {
|
||||
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 5px;
|
||||
background-color: var(--text-red);
|
||||
background-color: $red-text-color;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.markdown {
|
||||
--bg-lightgrey: rgb(240, 241, 245);
|
||||
--border-lightgrey: rgb(215, 216, 220);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -25,8 +28,8 @@
|
||||
font-size: 14px;
|
||||
tab-size: 4;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-lightgrey);
|
||||
background-color: var(--bg-off-white);
|
||||
border: 1px solid var(--border-lightgrey);
|
||||
background-color: var(--bg-lightgrey);
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
$desktop-size: 500px;
|
||||
|
||||
nav.navbar {
|
||||
background-color: var(--lightblue);
|
||||
background-color: $primary-dark-color;
|
||||
margin: 1em;
|
||||
color: white;
|
||||
border-radius: 0.6em;
|
||||
|
||||
@@ -6,21 +6,27 @@
|
||||
margin: 30px;
|
||||
|
||||
button {
|
||||
background-color: var(--bg-off-white);
|
||||
background-color: $secondary-neutral-light-color;
|
||||
min-width: 37px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-lightgrey);
|
||||
background-color: darken($secondary-neutral-light-color, 10%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #fff;
|
||||
background-color: darken($secondary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&.active:hover {
|
||||
background-color: var(--deepblue);
|
||||
background-color: $primary-neutral-color;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pagination-arrow {
|
||||
&:first-of-type,
|
||||
&:last-of-type {
|
||||
// previous and next buttons
|
||||
&:disabled {
|
||||
// Hide the arrows when they are disabled, without
|
||||
|
||||
+24
-23
@@ -57,7 +57,7 @@ body {
|
||||
|
||||
.collapse-header {
|
||||
color: white;
|
||||
background-color: var(--deepblue);
|
||||
background-color: $deepblue;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -141,7 +141,7 @@ body {
|
||||
}
|
||||
|
||||
#info_box {
|
||||
background: var(--bg-off-white);
|
||||
background: $primary-neutral-light-color;
|
||||
|
||||
&:before {
|
||||
content: "\f05a";
|
||||
@@ -150,11 +150,11 @@ body {
|
||||
}
|
||||
|
||||
#alert_box {
|
||||
background: var(--bg-yellow);
|
||||
background: $second-color;
|
||||
|
||||
&:before {
|
||||
content: "\f06a";
|
||||
color: var(--text-white);
|
||||
color: $white-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,10 +189,10 @@ body {
|
||||
border-radius: 5px;
|
||||
|
||||
&.btn-blue {
|
||||
background-color: var(--deepblue);
|
||||
background-color: $deepblue;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: var(--darkened-deepblue);
|
||||
background-color: darken($deepblue, 10%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
@@ -215,7 +215,7 @@ body {
|
||||
&.btn-green {
|
||||
$bg-color: rgba(0, 210, 83, 0.4);
|
||||
background-color: $bg-color;
|
||||
color: var(--text-black);
|
||||
color: $black-color;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background-color: darken($bg-color, 15%);
|
||||
@@ -272,8 +272,8 @@ body {
|
||||
/*--------------------------------CONTENT------------------------------*/
|
||||
#content {
|
||||
padding: 1.5em 3%;
|
||||
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
|
||||
background: var(--bg-white);
|
||||
box-shadow: $shadow-color 0 5px 10px;
|
||||
background: $white-color;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
float: right;
|
||||
background: var(--bg-off-white);
|
||||
background: $primary-neutral-light-color;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
@@ -351,7 +351,7 @@ body {
|
||||
a {
|
||||
padding: 7px;
|
||||
display: inline-block;
|
||||
color: var(--text-black);
|
||||
color: $black-color;
|
||||
font-weight: bold;
|
||||
margin: 2px;
|
||||
flex: 1;
|
||||
@@ -359,14 +359,14 @@ body {
|
||||
white-space: nowrap;
|
||||
|
||||
&.selected_tab {
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--blue);
|
||||
color: var(--text-white);
|
||||
background: $primary-color;
|
||||
color: $white-color;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
@@ -556,23 +556,23 @@ thead td {
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: var(--deepblue);
|
||||
background-color: $deepblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
tbody>tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: var(--bg-off-white);
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
background: var(--bg-lightgrey);
|
||||
background: $secondary-neutral-light-color;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.highlight {
|
||||
color: var(--lightblue);
|
||||
color: $primary-dark-color;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
@@ -598,7 +598,7 @@ em {
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: var(--bg-yellow);
|
||||
background: $second-color;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -700,15 +700,15 @@ u,
|
||||
height: 18em;
|
||||
overflow: hidden;
|
||||
border: 1px solid black;
|
||||
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 1px 1px 5px var(--bg-yellow);
|
||||
box-shadow: 1px 1px 5px $second-color;
|
||||
}
|
||||
}
|
||||
|
||||
.matmat_user a {
|
||||
color: var(--deepblue);
|
||||
color: $primary-neutral-dark-color;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -723,6 +723,7 @@ u,
|
||||
display: block;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
background: $white-color;
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
||||
@@ -71,7 +71,7 @@ main {
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--bg-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
|
||||
> span {
|
||||
font-size: small;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
&:enabled>.fa {
|
||||
background-color: var(--deepblue);
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,5 +29,13 @@
|
||||
</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>
|
||||
@@ -102,7 +102,6 @@
|
||||
@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>
|
||||
@@ -113,7 +112,6 @@
|
||||
@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>
|
||||
@@ -165,14 +163,12 @@
|
||||
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button class="pagination-arrow">
|
||||
<button>
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<i class="fa fa-caret-left"></i>
|
||||
</button>
|
||||
<button disabled="disabled"><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 %}
|
||||
@@ -205,14 +201,12 @@
|
||||
{%- else -%}
|
||||
href="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button class="pagination-arrow">
|
||||
><button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
</a>
|
||||
{% else %}
|
||||
<button disabled="disabled" class="pagination-arrow">
|
||||
<i class="fa fa-caret-right"></i>
|
||||
</button>
|
||||
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -84,6 +84,7 @@ from core.views import (
|
||||
notification,
|
||||
send_file,
|
||||
)
|
||||
from timetable.views import UserTimetableView
|
||||
|
||||
register_converter(FourDigitYearConverter, "yyyy")
|
||||
register_converter(TwoDigitMonthConverter, "mm")
|
||||
@@ -184,6 +185,11 @@ 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(),
|
||||
|
||||
+10
-2
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
|
||||
|
||||
__preferences_fields = forms.fields_for_model(
|
||||
Preferences,
|
||||
["show_my_stats"],
|
||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
||||
["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 = __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
|
||||
@@ -347,12 +351,16 @@ 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
|
||||
|
||||
|
||||
+16
-2
@@ -1,6 +1,14 @@
|
||||
import copy
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
LiteralString,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Unpack,
|
||||
)
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import render_to_string
|
||||
@@ -12,11 +20,17 @@ 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 | None] = None
|
||||
list_of_tabs: ClassVar[list[TabItem] | 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: var(--bg-off-white);
|
||||
background: $secondary-neutral-light-color;
|
||||
|
||||
img {
|
||||
max-width: 70%;
|
||||
|
||||
@@ -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 un pur enfer.
|
||||
serait carrément plus moche.
|
||||
|
||||
=== "nu"
|
||||
|
||||
@@ -59,18 +59,20 @@ des scripts complets mais qui restent lisibles,
|
||||
|
||||
Nombre de lignes total :
|
||||
```nu
|
||||
ls **/*.py | par-each { open $in.name | lines | length } | math sum
|
||||
ls **/*.py | insert linecount { get name | open | lines | length } | math sum
|
||||
```
|
||||
|
||||
Vous pouvez aussi exlure les lignes vides
|
||||
et les les lignes de commentaire :
|
||||
```nu
|
||||
ls **/*.py |
|
||||
par-each {
|
||||
open $in.name |
|
||||
insert linecount {
|
||||
get name |
|
||||
open |
|
||||
lines |
|
||||
each { str trim } |
|
||||
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
|
||||
filter { |l| not ($l | str starts-with "#") } | # commentaires
|
||||
filter { |l| ($l | str length) > 0 } | # lignes vides
|
||||
length
|
||||
} |
|
||||
math sum
|
||||
|
||||
+11
-35
@@ -18,8 +18,7 @@ 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
|
||||
@@ -44,7 +43,7 @@ il suffit d'utiliser la commande suivante :
|
||||
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
```
|
||||
|
||||
!!!success
|
||||
!!!note
|
||||
|
||||
À 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
|
||||
@@ -102,10 +101,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
|
||||
@@ -117,15 +116,12 @@ 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
|
||||
|
||||
@@ -149,6 +145,7 @@ 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`.
|
||||
@@ -186,32 +183,11 @@ Il suffit ensuite d'utiliser cette commande :
|
||||
uv run ./manage.py runserver
|
||||
```
|
||||
|
||||
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/).
|
||||
!!!note
|
||||
|
||||
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`.
|
||||
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/).
|
||||
|
||||
!!!note
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.fa-plus,
|
||||
.fa-minus {
|
||||
cursor: pointer;
|
||||
background-color: var(--deepblue);
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 5px;
|
||||
|
||||
@@ -288,7 +288,7 @@ $min_col_width: 100px;
|
||||
border: none;
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
background-color: var(--bg-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
padding: 0.4em;
|
||||
margin: 0.1em;
|
||||
font-size: 1.18em;
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
}
|
||||
|
||||
.topic {
|
||||
border: solid var(--darkened-deepblue) 1px;
|
||||
border-radius: 5px;
|
||||
border: solid $primary-neutral-color 1px;
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -24,7 +23,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-black);
|
||||
color: $black-color;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@@ -57,7 +56,7 @@
|
||||
}
|
||||
|
||||
.forum {
|
||||
background: var(--bg-white);
|
||||
background: $primary-neutral-light-color;
|
||||
padding: 1px;
|
||||
margin: 1px;
|
||||
|
||||
@@ -67,7 +66,7 @@
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-black);
|
||||
color: $black-color;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
@@ -92,7 +91,7 @@
|
||||
|
||||
.category {
|
||||
margin-top: 5px;
|
||||
background: var(--deepblue);
|
||||
background: $secondary-color;
|
||||
color: white;
|
||||
border-radius: 10px 10px 0 0;
|
||||
.title {
|
||||
@@ -106,12 +105,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-off-white);
|
||||
background: $secondary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
border: 1px var(--bg-lightgrey) solid;
|
||||
border: 1px darken($secondary-neutral-light-color, 10%) solid;
|
||||
|
||||
&:nth-child(odd) {
|
||||
background: var(--bg-white);
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
|
||||
+2
-5
@@ -346,16 +346,13 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
|
||||
class ForumMessageView(SingleObjectMixin, RedirectView):
|
||||
model = ForumMessage
|
||||
pk_url_kwarg = "message_id"
|
||||
permanent = False
|
||||
|
||||
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):
|
||||
self.object = self.get_object()
|
||||
return self.object.get_url()
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ $pedagogy-white-text: #f0f0f0;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
.closed td.title {
|
||||
color: lighten($black-color, 10%);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,9 @@ $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;
|
||||
@@ -154,9 +158,9 @@ $pedagogy-white-text: #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg-off-white);
|
||||
background: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
border: 1px var(--bg-lightgrey) solid;
|
||||
border: 1px darken($secondary-neutral-light-color, 10%) solid;
|
||||
|
||||
&.reported {
|
||||
border: #fc8181 1px solid;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -227,6 +228,11 @@ 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 = (
|
||||
@@ -261,10 +267,12 @@ 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();
|
||||
},
|
||||
|
||||
@@ -277,11 +285,17 @@ document.addEventListener("alpine:init", () => {
|
||||
* the list of identified users are updated.
|
||||
*/
|
||||
async updatePicture(): Promise<void> {
|
||||
window.history.replaceState(
|
||||
{ sasPictureId: this.currentPicture.id },
|
||||
"",
|
||||
this.currentPicture.sas_url,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
this.moderationError = "";
|
||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||
|
||||
@@ -27,7 +27,7 @@ main {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: black;
|
||||
background-color: var(--bg-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -36,7 +36,7 @@ main {
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
>.tools {
|
||||
flex: 1;
|
||||
.btn {
|
||||
background-color: var(--bg-white);
|
||||
background-color: $primary-neutral-light-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
# 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",)
|
||||
@@ -0,0 +1,37 @@
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
|
||||
from core.utils import get_semester_code
|
||||
from timetable.models import Timetable, TimetableSlot
|
||||
from timetable.schemas import CreateTimetableSchema, TimetableSchema
|
||||
|
||||
|
||||
@api_controller("/edt", urls_namespace="timetable")
|
||||
class TimetableController(ControllerBase):
|
||||
@route.put(
|
||||
"/{int:user_id}",
|
||||
auth=SessionAuth(),
|
||||
response={200: TimetableSchema},
|
||||
url_name="save",
|
||||
)
|
||||
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
|
||||
user = self.context.request.user
|
||||
if not user.has_perm("pedagogy.add_timetable") and not (
|
||||
user.has_perm("timetable.add_self_timetable") and user.id == user_id
|
||||
):
|
||||
raise PermissionDenied
|
||||
timetable, created = Timetable.objects.get_or_create(
|
||||
semester=get_semester_code(), user_id=user_id
|
||||
)
|
||||
if not created:
|
||||
timetable.slots.all().delete()
|
||||
if user.preferences.show_my_timetable != data.is_viewable:
|
||||
user.preferences.show_my_timetable = data.is_viewable
|
||||
user.preferences.save()
|
||||
slots = TimetableSlot.objects.bulk_create(
|
||||
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
|
||||
)
|
||||
return TimetableSchema(
|
||||
id=timetable.id, semester=timetable.semester, slots=slots
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-03 21:43
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Timetable",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"semester",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default=core.utils.get_semester_code,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("(A|P)\\d{2}")
|
||||
],
|
||||
verbose_name="semester",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="timetables",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="user",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "timetable",
|
||||
"verbose_name_plural": "timetables",
|
||||
"permissions": [("add_self_timetable", "Can add its own timetable")],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TimetableSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"weekday",
|
||||
models.SmallIntegerField(
|
||||
choices=[
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
],
|
||||
verbose_name="weekday",
|
||||
),
|
||||
),
|
||||
(
|
||||
"start_at",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[django.core.validators.MaxValueValidator(95)],
|
||||
verbose_name="start at",
|
||||
),
|
||||
),
|
||||
(
|
||||
"end_at",
|
||||
models.PositiveSmallIntegerField(
|
||||
validators=[django.core.validators.MaxValueValidator(95)],
|
||||
verbose_name="end at",
|
||||
),
|
||||
),
|
||||
(
|
||||
"course_type",
|
||||
models.CharField(max_length=12, verbose_name="course type"),
|
||||
),
|
||||
("room", models.CharField(max_length=32, verbose_name="room")),
|
||||
("ue", models.CharField(max_length=32, verbose_name="ue")),
|
||||
(
|
||||
"week_group",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[("A", "A"), ("B", "B")],
|
||||
default="",
|
||||
max_length=1,
|
||||
verbose_name="week group",
|
||||
),
|
||||
),
|
||||
(
|
||||
"timetable",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="slots",
|
||||
to="timetable.timetable",
|
||||
verbose_name="timetable",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "timetable slot",
|
||||
"verbose_name_plural": "timetable slots",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timetable",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "semester"), name="timetable_unique_user_semester"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="timetableslot",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(("end_at__gt", models.F("start_at"))),
|
||||
name="timetable_slot_end_after_start",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1,102 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,35 @@
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import Ge, Le
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field
|
||||
|
||||
from timetable.models import Timetable, TimetableSlot
|
||||
|
||||
|
||||
class TimetableSlotSchema(ModelSchema):
|
||||
weekday: TimetableSlot.WeekDay = Field(
|
||||
description=(
|
||||
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
|
||||
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
|
||||
)
|
||||
)
|
||||
start_at: Annotated[int, Ge(0), Le(96)]
|
||||
end_at: Annotated[int, Ge(0), Le(96)]
|
||||
|
||||
class Meta:
|
||||
model = TimetableSlot
|
||||
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
|
||||
|
||||
|
||||
class CreateTimetableSchema(Schema):
|
||||
is_viewable: bool
|
||||
slots: list[TimetableSlotSchema]
|
||||
|
||||
|
||||
class TimetableSchema(ModelSchema):
|
||||
slots: list[TimetableSlotSchema]
|
||||
|
||||
class Meta:
|
||||
model = Timetable
|
||||
fields = ["id", "semester"]
|
||||
+89
-33
@@ -1,4 +1,6 @@
|
||||
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 =
|
||||
@@ -76,17 +78,82 @@ 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 },
|
||||
displayedWeekdays: [] as WeekDay[],
|
||||
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[]) => ({
|
||||
displayedWeekdays: [] as WeekDay[],
|
||||
startSlot: 0,
|
||||
endSlot: 0,
|
||||
table: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
table: { height: 0, width: 0 },
|
||||
slots: slots,
|
||||
|
||||
colors: {} as Record<string, string>,
|
||||
colorPalette: [
|
||||
@@ -103,18 +170,14 @@ document.addEventListener("alpine:init", () => {
|
||||
"#1b5e20",
|
||||
],
|
||||
|
||||
generate() {
|
||||
try {
|
||||
this.courses = parseSlots(this.content);
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
this.error = { incorrectRow: err?.cause?.row };
|
||||
return;
|
||||
}
|
||||
init() {
|
||||
this.$watch("slots", () => this.display());
|
||||
},
|
||||
|
||||
display() {
|
||||
// color each UE
|
||||
let colorIndex = 0;
|
||||
for (const slot of this.courses) {
|
||||
for (const slot of this.slots) {
|
||||
if (!this.colors[slot.ueCode]) {
|
||||
this.colors[slot.ueCode] =
|
||||
this.colorPalette[colorIndex % this.colorPalette.length];
|
||||
@@ -123,13 +186,13 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
|
||||
this.displayedWeekdays = WEEKDAYS.filter((day) =>
|
||||
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
|
||||
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
|
||||
);
|
||||
this.startSlot = this.courses.reduce(
|
||||
this.startSlot = this.slots.reduce(
|
||||
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
|
||||
25 * 4,
|
||||
);
|
||||
this.endSlot = this.courses.reduce(
|
||||
this.endSlot = this.slots.reduce(
|
||||
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
|
||||
1,
|
||||
);
|
||||
@@ -138,7 +201,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
getStyle(slot: TimetableSlot) {
|
||||
const hasWeekGroup = slot.weekGroup !== undefined;
|
||||
const hasWeekGroup = !!slot.weekGroup;
|
||||
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
|
||||
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
|
||||
return {
|
||||
@@ -150,15 +213,19 @@ document.addEventListener("alpine:init", () => {
|
||||
};
|
||||
},
|
||||
|
||||
getHours(): [string, object][] {
|
||||
/**
|
||||
* Return the hours that should be displayed on this timetable,
|
||||
* with their `top` placing
|
||||
*/
|
||||
getHours(): [string, { top: `${number}px` }][] {
|
||||
let hour: number = Number.parseInt(
|
||||
this.courses
|
||||
this.slots
|
||||
.map((c: TimetableSlot) => c.startHour)
|
||||
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
|
||||
.split(":")[0],
|
||||
10,
|
||||
);
|
||||
const res: [string, object][] = [];
|
||||
const res: [string, { top: `${number}px` }][] = [];
|
||||
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
|
||||
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
|
||||
hour += 1;
|
||||
@@ -169,16 +236,5 @@ 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: var(--bg-white);
|
||||
background-color: $white-color;
|
||||
font-weight: bold;
|
||||
box-shadow: none;
|
||||
width: calc(100% - var(--hour-side-width) - 10px);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
{% extends 'core/base.jinja' %}
|
||||
{% 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/generator-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
@@ -60,44 +62,40 @@
|
||||
</div>
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
|
||||
</form>
|
||||
<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>
|
||||
{{ 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>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{# HTML+AlpineJS to display a timetable
|
||||
|
||||
Args:
|
||||
ues: either a JS variable name or an array of object
|
||||
satisfying the TimetableSlot interface
|
||||
reactive: if True and `ues` is a variable used in an Alpine context,
|
||||
then this timetable will react to modifications and
|
||||
change its own display
|
||||
#}
|
||||
{% macro display_timetable(ues) %}
|
||||
<div
|
||||
x-data="timetableDisplay({{ ues }})"
|
||||
x-model="{{ ues }}"
|
||||
x-modelable="slots"
|
||||
class="timetable"
|
||||
x-show="slots.length > 0"
|
||||
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
|
||||
>
|
||||
<div class="header">
|
||||
<template x-for="weekday in displayedWeekdays">
|
||||
<span x-text="weekday"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
|
||||
<template x-for="[hour, style] in getHours()">
|
||||
<div class="hour" :style="style">
|
||||
<div x-text="hour"></div>
|
||||
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="courses">
|
||||
<template x-for="course in slots">
|
||||
<div class="slot" :style="getStyle(course)">
|
||||
<span class="course-type" x-text="course.courseType"></span>
|
||||
<span x-text="course.ueCode"></span>
|
||||
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
|
||||
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
|
||||
<span x-text="course.room"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% from "timetable/macros.jinja" import display_timetable %}
|
||||
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{% block content %}
|
||||
{% for timetable in timetables %}
|
||||
<details
|
||||
class="accordion"
|
||||
name="{{ timetable.semester }}"
|
||||
{% if loop.first %}open{% endif %}
|
||||
>
|
||||
<summary>{{ timetable.semester }}</summary>
|
||||
<div class="accordion-content">
|
||||
{{ display_timetable(timetable.semester) }}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{%- block script -%}
|
||||
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
|
||||
<script>
|
||||
{%- for timetable in timetables -%}
|
||||
const {{ timetable.semester }} = [
|
||||
{%- for slot in timetable.slots.all() -%}
|
||||
{
|
||||
courseType: "{{ slot.course_type }}",
|
||||
room: "{{ slot.room }}",
|
||||
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
|
||||
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
|
||||
startSlot: {{ slot.start_at }},
|
||||
endSlot: {{ slot.end_at }},
|
||||
ueCode: "{{ slot.ue }}",
|
||||
{%- if slot.week_group -%}
|
||||
weekGroup: "{{ slot.week_group }}",
|
||||
{%- endif -%}
|
||||
weekday: "{{ weekdays[slot.weekday] }}"
|
||||
},
|
||||
{%- endfor -%}
|
||||
];
|
||||
{%- endfor -%}
|
||||
</script>
|
||||
{%- endblock -%}
|
||||
@@ -1 +0,0 @@
|
||||
# Create your tests here.
|
||||
Whitespace-only changes.
@@ -0,0 +1,20 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from timetable.models import TimetableSlot
|
||||
|
||||
|
||||
class TestSaveTimetable(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls) -> None:
|
||||
cls.user = subscriber_user.make()
|
||||
cls.payload = {
|
||||
"is_viewable": True,
|
||||
"slots": [
|
||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
|
||||
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
|
||||
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
|
||||
],
|
||||
}
|
||||
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from core.models import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test import Client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("can_save", [True, False])
|
||||
def test_generator_ok(client: Client, can_save):
|
||||
user = baker.make(User)
|
||||
if can_save:
|
||||
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
|
||||
client.force_login(user)
|
||||
|
||||
res = client.get(reverse("timetable:generator"))
|
||||
assert res.status_code == 200
|
||||
soup = BeautifulSoup(res.text, "lxml")
|
||||
elem = soup.find(id="timetable-save")
|
||||
|
||||
if can_save:
|
||||
assert elem is not None
|
||||
else:
|
||||
assert elem is None
|
||||
+5
-2
@@ -1,5 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from timetable.views import GeneratorView
|
||||
from timetable.views import GeneratorView, UserTimetableView
|
||||
|
||||
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
|
||||
urlpatterns = [
|
||||
path("", GeneratorView.as_view(), name="generator"),
|
||||
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
|
||||
]
|
||||
+50
-2
@@ -1,6 +1,54 @@
|
||||
# Create your views here.
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.detail import DetailView
|
||||
|
||||
from core.models import User
|
||||
from core.views import TabedViewMixin
|
||||
|
||||
|
||||
class GeneratorView(TemplateView):
|
||||
class TimetableTabsMixin(TabedViewMixin):
|
||||
tabs_title = _("Timetables")
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
res = [
|
||||
{
|
||||
"url": reverse("timetable:generator"),
|
||||
"slug": "generator",
|
||||
"name": _("Generator"),
|
||||
}
|
||||
]
|
||||
if self.request.user.timetables.exists():
|
||||
res.append(
|
||||
{
|
||||
"url": reverse(
|
||||
"core:user_timetable", kwargs={"user_id": self.request.user.id}
|
||||
),
|
||||
"slug": "saved",
|
||||
"name": _("My timetables"),
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
class GeneratorView(TimetableTabsMixin, TemplateView):
|
||||
template_name = "timetable/generator.jinja"
|
||||
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"))
|
||||
}
|
||||
Reference in new issue
Block a user