This commit is contained in:
Dominic Grimm 2023-02-28 11:29:52 +01:00
parent d9be1b6783
commit c9b11aa165
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
12 changed files with 474 additions and 251 deletions

72
bvplan/Cargo.lock generated
View file

@ -183,6 +183,18 @@ dependencies = [
"syn",
]
[[package]]
name = "actix-web-static-files"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf6d1ef6d7a60e084f9e0595e2a5234abda14e76c105ecf8e2d0e8800c41a1f"
dependencies = [
"actix-web",
"derive_more",
"futures-util",
"static-files",
]
[[package]]
name = "addr2line"
version = "0.19.0"
@ -316,6 +328,17 @@ dependencies = [
"askama_shared",
]
[[package]]
name = "askama_actix"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c52f74f8382a142ecfc052100b21abc33f2c069e20fe345808e7ed914b179449"
dependencies = [
"actix-web",
"askama",
"askama_shared",
]
[[package]]
name = "askama_derive"
version = "0.11.2"
@ -603,8 +626,10 @@ name = "bvplan"
version = "0.1.0"
dependencies = [
"actix-web",
"actix-web-static-files",
"anyhow",
"askama",
"askama_actix",
"celery",
"chrono",
"diesel",
@ -620,6 +645,7 @@ dependencies = [
"scraper",
"serde",
"serde_json",
"static-files",
"stdext",
"tikv-jemallocator",
"tokio",
@ -711,6 +737,16 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "change-detection"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159fa412eae48a1d94d0b9ecdb85c97ce56eb2a347c62394d3fdbf221adabc1a"
dependencies = [
"path-matchers",
"path-slash",
]
[[package]]
name = "chrono"
version = "0.4.23"
@ -1366,6 +1402,12 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad0a93d233ebf96623465aad4046a8d3aa4da22d4f4beba5388838c8a434bbb4"
[[package]]
name = "glob"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "globset"
version = "0.4.10"
@ -1381,9 +1423,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.15"
version = "0.3.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4"
checksum = "5be7b54589b581f624f566bf5d8eb2bab1db736c51528720b6bd36b96b55924d"
dependencies = [
"bytes",
"fnv",
@ -2037,6 +2079,21 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d01a5bd0424d00070b0098dd17ebca6f961a959dead1dbcbbbc1d1cd8d3deeba"
[[package]]
name = "path-matchers"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36cd9b72a47679ec193a5f0229d9ab686b7bd45e1fbc59ccf953c9f3d83f7b2b"
dependencies = [
"glob",
]
[[package]]
name = "path-slash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498a099351efa4becc6a19c72aa9270598e8fd274ca47052e37455241c88b696"
[[package]]
name = "percent-encoding"
version = "2.2.0"
@ -2836,6 +2893,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "static-files"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64712ea1e3e140010e1d9605872ba205afa2ab5bd38191cc6ebd248ae1f6a06b"
dependencies = [
"change-detection",
"mime_guess",
"path-slash",
]
[[package]]
name = "stdext"
version = "0.3.1"

View file

@ -5,6 +5,7 @@ edition = "2021"
[[bin]]
name = "web"
build = "build.rs"
[[bin]]
name = "worker"
@ -17,8 +18,10 @@ panic = "abort"
[dependencies]
actix-web = "4.2.1"
actix-web-static-files = "4.0.1"
anyhow = { version = "1.0.66", features = ["backtrace"] }
askama = { version = "0.11.1", features = ["serde-json"] }
askama_actix = "0.13.0"
celery = { git = "https://github.com/rusty-celery/rusty-celery.git", branch = "main" }
chrono = "0.4.23"
diesel = { version = "2.0.2", features = ["i-implement-a-third-party-backend-and-opt-into-breaking-changes", "postgres", "chrono", "r2d2"] }
@ -34,6 +37,7 @@ reqwest = "0.11.13"
scraper = "0.14.0"
serde = "1.0.148"
serde_json = "1.0.93"
static-files = "0.2.3"
stdext = "0.3.1"
tokio = { version = "1.22.0", features = ["full"] }
untis = { git = "https://git.dergrimm.net/dergrimm/untis.rs.git", branch = "main" }
@ -41,3 +45,6 @@ url = "2.3.1"
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = "0.5"
[build-dependencies]
static-files = "0.2.3"

View file

@ -1,3 +1,15 @@
FROM codycraven/sassc:latest as css
WORKDIR /usr/src/scss
RUN mkdir dist
WORKDIR /usr/src/scss/src
COPY ./scss .
RUN find . -name "*.scss" -type f | xargs -I % sh -c 'sassc % > ../dist/$(basename -- "%" .scss).css'
FROM tdewolff/minify:latest as static
WORKDIR /usr/src/static
COPY --from=css /usr/src/scss/dist ./css
RUN minify . -r -o .
FROM docker.io/lukemathwalker/cargo-chef:latest-rust-1.65.0 as chef
FROM chef as diesel
@ -13,6 +25,9 @@ FROM chef as builder
WORKDIR /usr/src/backend
COPY --from=planner /usr/src/backend/recipe.json .
RUN cargo chef cook --recipe-path recipe.json
RUN rm -rf ./src
COPY ./build.rs .
COPY --from=static /usr/src/static ./static
COPY ./templates ./templates
COPY ./src ./src
RUN cargo build

5
bvplan/build.rs Normal file
View file

@ -0,0 +1,5 @@
use static_files::resource_dir;
fn main() -> std::io::Result<()> {
resource_dir("./static").build()
}

102
bvplan/scss/bvplan.scss Normal file
View file

@ -0,0 +1,102 @@
html,
body {
height: 100%;
margin: 0;
}
body {
min-height: 100%;
margin: 0 5vw;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: Arial, Helvetica, sans-serif;
}
#info {
margin-top: 2vh;
display: flex;
justify-content: space-between;
& > .column {
margin-top: auto;
}
}
#title-wrapper {
color: #ee7f00;
& > * {
margin: 0;
}
}
#title {
font-weight: bold;
}
#tenant-info {
text-align: right;
}
#plan {
width: 100%;
margin-top: 1%;
border-collapse: collapse;
text-align: center;
caption {
font-weight: bold;
}
th,
td {
border: thin solid black;
}
thead {
background-color: black;
color: white;
}
th {
padding: 0.5rem 0.25rem;
}
td {
padding: 0.25rem;
}
thead,
tbody {
font-size: 0.85em;
}
tbody tr {
&:not(:nth-child(even)) {
background-color: #fad3a6;
}
&:nth-child(even) {
background-color: #fdecd9;
}
}
}
#footer {
margin: auto 0 2vh;
text-align: center;
p {
margin: 0.25rem;
}
hr {
border: thin solid black;
margin: 1rem 0;
}
.element {
margin: 0.5rem 0;
}
}

View file

@ -5,14 +5,28 @@ use tikv_jemallocator::Jemalloc;
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
use actix_web::{get, middleware, web::Data, App, HttpResponse, HttpServer};
use actix_web::{get, http, middleware, web, App, HttpResponse, HttpServer};
use actix_web_static_files::ResourceFiles;
use r2d2_redis::redis;
use std::ops::DerefMut;
use bvplan::*;
use templates::TemplateToResponseWithStatusCode;
pub mod static_dir {
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
}
async fn not_found() -> HttpResponse {
templates::StatusCode {
status_code: http::StatusCode::NOT_FOUND,
message: None,
}
.to_response_with_status_code(http::StatusCode::NOT_FOUND)
}
#[get("/")]
async fn index(redis_pool: Data<cache::RedisPool>) -> HttpResponse {
async fn index(redis_pool: web::Data<cache::RedisPool>) -> HttpResponse {
let redis_conn = &mut match redis_pool.get() {
Ok(x) => x,
Err(_) => return HttpResponse::InternalServerError().finish(),
@ -37,12 +51,19 @@ async fn index(redis_pool: Data<cache::RedisPool>) -> HttpResponse {
async fn main() -> std::io::Result<()> {
env_logger::init();
let server = HttpServer::new(move || {
HttpServer::new(move || {
let generated = static_dir::generate();
App::new()
.app_data(Data::new(cache::pool().unwrap()))
.app_data(web::Data::new(cache::pool().unwrap()))
.wrap(middleware::Compress::default())
.wrap(middleware::Logger::default())
.service(index)
});
server.bind("0.0.0.0:80").unwrap().run().await
.service(ResourceFiles::new("/static", generated))
.default_service(web::route().to(not_found))
})
.bind("0.0.0.0:80")
.unwrap()
.run()
.await
}

View file

@ -37,16 +37,16 @@ pub mod keys {
#[derive(Deserialize, Serialize, Debug)]
pub struct Substitution {
#[serde(rename = "tm")]
pub time: (usize, Option<usize>),
#[serde(rename = "p")]
pub period: usize,
#[serde(rename = "cl")]
pub classes: Vec<String>,
#[serde(rename = "ps")]
pub prev_subject: String,
pub prev_subject: Option<String>,
#[serde(rename = "s")]
pub subject: Option<String>,
#[serde(rename = "t")]
pub teachers: Vec<String>,
pub teachers: Option<Vec<String>>,
#[serde(rename = "pr")]
pub prev_room: Option<String>,
#[serde(rename = "r")]

View file

@ -1,9 +1,51 @@
use actix_web::{body::BoxBody, http, HttpResponse, HttpResponseBuilder, ResponseError};
use askama::Template;
use std::fmt;
use crate::cache;
struct ActixError(askama::Error);
impl fmt::Debug for ActixError {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
<askama::Error as fmt::Debug>::fmt(&self.0, f)
}
}
impl fmt::Display for ActixError {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
<askama::Error as fmt::Display>::fmt(&self.0, f)
}
}
impl ResponseError for ActixError {}
pub trait TemplateToResponseWithStatusCode {
fn to_response_with_status_code(&self, code: http::StatusCode) -> HttpResponse<BoxBody>;
}
impl<T: askama_actix::Template> TemplateToResponseWithStatusCode for T {
fn to_response_with_status_code(&self, code: http::StatusCode) -> HttpResponse<BoxBody> {
match self.render() {
Ok(buffer) => HttpResponseBuilder::new(code)
.content_type(http::header::HeaderValue::from_static(T::MIME_TYPE))
.body(buffer),
Err(err) => HttpResponse::from_error(ActixError(err)),
}
}
}
#[derive(Template)]
#[template(path = "bvplan.html")]
pub struct BVplan {
pub data: cache::keys::substitutions::SubstitutionQuery,
}
#[derive(Template)]
#[template(path = "status_code.html")]
pub struct StatusCode {
pub status_code: http::StatusCode,
pub message: Option<String>,
}

View file

@ -250,6 +250,7 @@ fn get_period(times: &Vec<StartEndTime>, start: bool, time: NaiveTime) -> Option
fn cache_substitutions(
db_conn: &mut PgConnection,
redis_conn: &mut cache::Connection,
schoolyear_id: i32,
substitution_query_id: i64,
last_import_time: NaiveDateTime,
) -> Result<()> {
@ -287,6 +288,147 @@ fn cache_substitutions(
.order(db::schema::timegrid_time_unit::start_time.asc())
.load::<StartEndTime>(db_conn)?;
let mut substitutions = db::schema::substitutions::table
.filter(db::schema::substitutions::substitution_query_id.eq(substitution_query_id))
.load::<db::models::Substitution>(db_conn)
.unwrap()
.into_iter()
.map(|s| {
if let Some(subst_subject) = db::schema::substitution_subjects::table
.filter(db::schema::substitution_subjects::substitution_id.eq(s.id))
.order(db::schema::substitution_subjects::position.asc())
.first::<db::models::SubstitutionSubject>(db_conn)
.optional()?
{
let subst_class_ids = db::schema::substitution_classes::table
.select(db::schema::substitution_classes::class_id)
.filter(db::schema::substitution_classes::substitution_id.eq(s.id))
.order(db::schema::substitution_classes::position.asc())
.load::<i32>(db_conn)?;
let classes = db::schema::classes::table
.select(db::schema::classes::name)
.filter(db::schema::classes::id.eq_any(subst_class_ids))
.load::<String>(db_conn)?;
let subst_teachers = db::schema::substitution_teachers::table
.filter(db::schema::substitution_teachers::substitution_id.eq(s.id))
.order(db::schema::substitution_teachers::position.asc())
.load::<db::models::SubstitutionTeacher>(db_conn)?;
let (prev_room, room) = if let Some(r) = db::schema::substitution_rooms::table
.filter(db::schema::substitution_rooms::substitution_id.eq(s.id))
.order(db::schema::substitution_rooms::position.asc())
.first::<db::models::SubstitutionRoom>(db_conn)
.optional()?
{
let name = if let Some(id) = r.room_id {
Some(
db::schema::rooms::table
.select(db::schema::rooms::name)
.filter(db::schema::rooms::id.eq(id))
.first::<String>(db_conn)?,
)
} else {
None
};
match s.subst_type {
db::models::SubstitutionType::Cancel => (name, None),
_ => (
if let Some(id) = r.original_id {
Some(
db::schema::rooms::table
.select(db::schema::rooms::name)
.filter(db::schema::rooms::id.eq(id))
.first::<String>(db_conn)?,
)
} else {
None
},
name,
),
}
} else {
(None, None)
};
let (prev_subject, subject) = {
let name = db::schema::subjects::table
.select(db::schema::subjects::name)
.filter(db::schema::subjects::id.eq(subst_subject.subject_id))
.first::<String>(db_conn)?;
match s.subst_type {
db::models::SubstitutionType::Cancel => (Some(name), None),
_ => {
if let Some(prev_id) = subst_subject.original_id {
(
Some(
db::schema::subjects::table
.select(db::schema::subjects::name)
.filter(db::schema::subjects::id.eq(prev_id))
.first::<String>(db_conn)?,
),
Some(name),
)
} else {
(Some(name.to_owned()), Some(name))
}
}
}
};
Ok(Some(cache::keys::substitutions::Substitution {
period: get_period(&times, true, s.start_time)
.context("Could not find period from start time")?,
classes,
prev_subject,
subject,
teachers: match s.subst_type {
db::models::SubstitutionType::Cancel => None,
_ => {
let x = db::schema::teachers::table
.select(db::schema::teachers::display_name)
.filter(
db::schema::teachers::id.eq_any(
subst_teachers
.iter()
.filter_map(|t| t.teacher_id)
.collect::<Vec<i32>>(),
),
)
.load::<String>(db_conn)?;
if x.is_empty() {
None
} else {
Some(x)
}
}
},
prev_room,
room,
text: s.text,
}))
} else {
Ok(None)
}
})
.collect::<Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter_map(|s| s)
.collect::<Vec<_>>();
substitutions.sort_by_key(|x| {
(
x.classes.get(0).and_then(|x| {
x.split_once(' ')
.and_then(|y| y.0.parse::<u32>().map_or(None, |s| Some(s)))
}),
x.period,
)
});
let query = cache::keys::substitutions::SubstitutionQuery {
date: format!(
"{} {}",
@ -304,123 +446,15 @@ fn cache_substitutions(
week_type,
queried_at: queried_at.format("%d.%m.%Y %R").to_string(),
last_import_time: last_import_time.format("%d.%m.%Y %R").to_string(),
schoolyear: "schoolyear".to_string(),
tenant: "OHG Furtwangen".to_string(),
substitutions: db::schema::substitutions::table
.filter(db::schema::substitutions::substitution_query_id.eq(substitution_query_id))
.load::<db::models::Substitution>(db_conn)
.unwrap()
.into_iter()
.map(|s| {
if let Some(subst_subject) = db::schema::substitution_subjects::table
.filter(db::schema::substitution_subjects::substitution_id.eq(s.id))
.order(db::schema::substitution_subjects::position.asc())
.first::<db::models::SubstitutionSubject>(db_conn)
.optional()?
{
let subst_class_ids = db::schema::substitution_classes::table
.select(db::schema::substitution_classes::class_id)
.filter(db::schema::substitution_classes::substitution_id.eq(s.id))
.order(db::schema::substitution_classes::position.asc())
.load::<i32>(db_conn)?;
let classes = db::schema::classes::table
.select(db::schema::classes::name)
.filter(db::schema::classes::id.eq_any(subst_class_ids))
.load::<String>(db_conn)?;
let subst_teachers = db::schema::substitution_teachers::table
.filter(db::schema::substitution_teachers::substitution_id.eq(s.id))
.order(db::schema::substitution_teachers::position.asc())
.load::<db::models::SubstitutionTeacher>(db_conn)?;
let (prev_room, room) = if let Some(r) = db::schema::substitution_rooms::table
.filter(db::schema::substitution_rooms::substitution_id.eq(s.id))
.order(db::schema::substitution_rooms::position.asc())
.first::<db::models::SubstitutionRoom>(db_conn)
.optional()?
{
let name = if let Some(id) = r.room_id {
Some(
db::schema::rooms::table
.select(db::schema::rooms::name)
.filter(db::schema::rooms::id.eq(id))
.first::<String>(db_conn)?,
)
} else {
None
};
match s.subst_type {
db::models::SubstitutionType::Cancel => (name, None),
_ => (
if let Some(id) = r.original_id {
Some(
db::schema::rooms::table
.select(db::schema::rooms::name)
.filter(db::schema::rooms::id.eq(id))
.first::<String>(db_conn)?,
)
} else {
None
},
name,
),
}
} else {
(None, None)
};
let prev_subject = db::schema::subjects::table
.select(db::schema::subjects::name)
.filter(db::schema::subjects::id.eq(subst_subject.subject_id))
.first::<String>(db_conn)?;
let start_period = get_period(&times, true, s.start_time)
.context("Could not find period from start time")?;
Ok(Some(cache::keys::substitutions::Substitution {
time: (start_period, None),
classes,
prev_subject: prev_subject.to_owned(),
subject: match s.subst_type {
db::models::SubstitutionType::Cancel => None,
_ => match subst_subject.original_id {
Some(id) => Some(
db::schema::subjects::table
.select(db::schema::subjects::name)
.filter(db::schema::subjects::id.eq(id))
.first::<String>(db_conn)?,
),
None => Some(prev_subject),
},
},
teachers: match s.subst_type {
db::models::SubstitutionType::Cancel => vec![],
_ => db::schema::teachers::table
.select(db::schema::teachers::display_name)
.filter(
db::schema::teachers::id.eq_any(
subst_teachers
.iter()
.filter_map(|t| t.teacher_id)
.collect::<Vec<i32>>(),
),
)
.load::<String>(db_conn)?,
},
prev_room,
room,
text: s.text,
}))
} else {
Ok(None)
}
})
.collect::<Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter_map(|s| s)
.collect::<Vec<_>>(),
schoolyear: db::schema::schoolyears::table
.select(db::schema::schoolyears::name)
.filter(db::schema::schoolyears::id.eq(schoolyear_id))
.first::<String>(db_conn)?,
tenant: db::schema::tenants::table
.select(db::schema::tenants::name)
.filter(db::schema::tenants::active)
.first::<String>(db_conn)?,
substitutions,
};
fs::write(
@ -497,9 +531,13 @@ pub async fn get_substitutions() -> TaskResult<()> {
}
// thread::sleep(dur);
if let Err(e) =
cache_substitutions(db_conn, redis_conn, substitution_query_id, last_import_time)
{
if let Err(e) = cache_substitutions(
db_conn,
redis_conn,
schoolyear_id,
substitution_query_id,
last_import_time,
) {
return Err(TaskError::UnexpectedError(format!("{:?}", e)));
}

0
bvplan/static/.keep Normal file
View file

View file

@ -4,9 +4,10 @@
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="BVplan" />
<title>BVplan | dergrimm.net</title>
<meta name="generator" content="BVplan" />
<link rel="stylesheet" type="text/css" href="/static/css/bvplan.css" />
<script id="data-element-count" type="application/json">{{ data.substitutions.len()|json|safe }}</script>
@ -16,114 +17,6 @@
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<style>
html, body {
height: 100%;
margin: 0;
}
body {
min-height: 100%;
margin: 0 5vw;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: Arial, Helvetica, sans-serif;
}
#info {
margin-top: 1vh;
display: flex;
justify-content: space-between;
}
#info > .column {
margin-top: auto;
}
#title-wrapper {
color: #ee7f00;
}
#title-wrapper > * {
margin: 0;
}
#title {
font-weight: bold;
}
#tenant-info {
text-align: right;
}
#plan {
width: 100%;
margin-top: 1%;
border-collapse: collapse;
text-align: center;
font-size: small;
}
#plan thead,
#plan tbody {
font-size: 0.9em;
}
#plan > caption {
font-weight: bold;
}
#plan th,
#plan td {
border: thin solid black;
}
#plan thead {
background-color: black;
color: white;
}
#plan th {
padding: 0.5rem 0.25rem;
}
#plan td {
padding: 0.25rem;
}
#plan tbody {
overflow: auto;
}
#plan tbody tr:not(:nth-child(even)) {
background-color: #fad3a6;
}
#plan tbody tr:nth-child(even) {
background-color: #fdecd9;
}
#footer {
margin: auto 0 1vh;
text-align: center;
}
#footer p {
margin: 0.25rem;
}
#footer > hr {
border: thin solid black;
margin: 1rem 0;
}
#footer .message,
#footer .powered-by {
margin: 0.5rem 0;
}
</style>
</head>
<body>
<header id="info">
@ -173,16 +66,16 @@
<tbody>
{% for subst in data.substitutions %}
<tr>
<td>{{ subst.period }}</td>
<td>{{ subst.classes|join(", ") }}</td>
<td>
{% match subst.time.1 %}
{% match subst.prev_subject %}
{% when Some with (x) %}
{{ subst.time.0 }} - {{ x }}
{{ x }}
{% when None %}
{{ subst.time.0 }}
---
{% endmatch %}
</td>
<td>{{ subst.classes|join(", ") }}</td>
<td>{{ subst.prev_subject }}</td>
<td>
{% match subst.subject %}
{% when Some with (x) %}
@ -191,7 +84,14 @@
---
{% endmatch %}
</td>
<td>{{ subst.teachers|join(", ") }}</td>
<td>
{% match subst.teachers %}
{% when Some with (x) %}
{{ x|join(", ") }}
{% when None %}
---
{% endmatch %}
</td>
<td>
{% match subst.prev_room %}
{% when Some with (x) %}
@ -222,7 +122,7 @@
</main>
<footer id="footer">
<div class="message">
<div class="element">
<p>
BVplan - der bessre Vertretungsplan sogar mit UTF-8 Support!
Wow
@ -234,12 +134,12 @@
</p>
</div>
<hr />
<p class="powered-by">
<p class="element">
<code>
Powered by Dominic Grimm &lt;<a
href="mailto:dominic@dergrimm.net"
>dominic@dergrimm.net</a
>&gt;
>&gt;, Untis sucks
</code>
</p>
</footer>
@ -284,7 +184,7 @@
}
window.scrollTo(0, 0);
setTimeout($(window).height() > $(window).height() ? scrollDown : reload, waitDelay);
setTimeout($(document).height() > $(window).height() ? scrollDown : reload, waitDelay);
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de-DE">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content="BVplan" />
<title>BVplan | dergrimm.net</title>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<h1>{{ status_code }}</h1>
{% match message %}
{% when Some with (x) %}
<hr />
<p>{{ x }}</p>
{% when None %}
{% endmatch %}
</body>
</html>