Compare commits

...

10 Commits

22 changed files with 2418 additions and 914 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
/*/target /*/target
/target /target
terminwahl_front/dist terminwahl_front/dist
db.sqlite* db.sqlite*

2752
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
[workspace] [workspace]
members = ["terminwahl_back", "terminwahl_front"] members = ["terminwahl_back", "terminwahl_front"]
workspace.resolver = "2"
[workspace.dependencies] [workspace.dependencies]
serde = {version="1.0", features = ["derive"]} serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
chrono = {version="*", features = ["serde"]} chrono = { version = "*", features = ["serde"] }

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Project Name
## Description
This project is a webpage that simplifies management of parentsday at our school.
## Installation
To install this project, follow these steps:
1. TODO
## Setup
to insert teachers use:
```sql
insert into teachers (ansprache, last_name, subject_id) values ('Frau', 'Bücher', 2);
insert into teachers (ansprache, last_name, subject_id) values ('Frau', 'Klemm', 2);
insert into teachers (ansprache, last_name, subject_id) values ('Frau', 'Vietzen/Pfab', 2);
insert into teachers (ansprache, last_name, subject_id) values ('Frau', 'Bärtle', 2);
insert into teachers (ansprache, last_name, subject_id) values ('Frau', 'Wörner', 2);
```
to make a teacher available in the current year:
```sql
insert into teacher_dates values (1,3);
insert into teacher_dates values (2,3);
insert into teacher_dates values (3,3);
insert into teacher_dates values (4,3);
insert into teacher_dates values (5,3);
insert into teacher_dates values (7,3);
insert into teacher_dates values (12,3);
insert into teacher_dates values (8,3);
insert into teacher_dates values (9,3);
insert into teacher_dates values (10,3);
insert into teacher_dates values (11,3);
insert into teacher_dates values (13,3);
insert into teacher_dates values (14,3);
insert into teacher_dates values (15,3);
insert into teacher_dates values (16,3);
```
to generate slots use:
```sql
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 14:00:00', '2023-02-28 14:15:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 14:15:00', '2023-02-28 14:30:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 14:30:00', '2023-02-28 14:45:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 14:45:00', '2023-02-28 15:00:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 15:15:00', '2023-02-28 15:30:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 15:30:00', '2023-02-28 15:45:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 15:45:00', '2023-02-28 16:00:00', 3);
insert into appointment_slots (start_time, end_time, date_id) values ('2023-02-28 16:00:00', '2023-02-28 16:15:00', 3);
```
## Usage
To use this project, follow these steps:
1. ...
2. ...
3. ...
## Contributing
If you would like to contribute to this project, please follow these guidelines:
1. Fork the repository.
2. Create a new branch.
3. ...
4. Submit a pull request.
## License
This project is licensed under the [MIT License](LICENSE).
## Contact
For any questions or inquiries, please contact us at [franz.dietrich@uhlandshoehe.de](mailto:franz.dietrich@uhlandshoehe.de).

View File

@ -1,9 +1,21 @@
#!/bin/bash #!/bin/bash
if [ ! -f terminwahl_front/static/bulma.sass ]; then
pushd terminwahl_front/static/ || exit
wget https://github.com/jgthms/bulma/releases/download/0.9.4/bulma-0.9.4.zip
unzip bulma-0.9.4.zip
ln -s bulma/sass/ sass
ln -s bulma/bulma.sass bulma.sass
wget https://use.fontawesome.com/releases/v6.3.0/fontawesome-free-6.3.0-web.zip
unzip fontawesome-free-6.3.0-web.zip
ln -s fontawesome-free-6.3.0-web/scss/ scss
ln -s fontawesome-free-6.3.0-web/webfonts webfonts
popd || exit
fi
systemctl stop Terminwahl systemctl stop Terminwahl
cp -v target/release/terminwahl_back /usr/local/bin/terminwahl_back && chown terminwahl.terminwahl /usr/local/bin/terminwahl_back cp -v target/release/terminwahl_back /usr/local/bin/terminwahl_back && chown terminwahl:terminwahl /usr/local/bin/terminwahl_back
echo You may want to copy the database echo You may want to copy the database
echo cp -v terminwahl_back/db.sqlite /var/local/terminwahl/db.sqlite && chown terminwahl.terminwahl /var/local/terminwahl/db.sqlite echo cp -v terminwahl_back/db.sqlite /var/local/terminwahl/db.sqlite && chown terminwahl:terminwahl /var/local/terminwahl/db.sqlite
rsync -va --delete terminwahl_back/templates/ /var/local/terminwahl/templates/ rsync -va --delete terminwahl_back/templates/ /var/local/terminwahl/templates/
rsync -va --delete terminwahl_front/dist/ /var/local/terminwahl/dist/ rsync -va --delete terminwahl_front/dist/ /var/local/terminwahl/dist/
systemctl start Terminwahl systemctl start Terminwahl

View File

@ -1,31 +1,41 @@
[package] [package]
name = "terminwahl_back" name = "terminwahl_back"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
default-run = "terminwahl_back" default-run = "terminwahl_back"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
futures = "*" futures = "*"
actix-web = "4.3" actix-web = "4.5"
actix-rt = "2.8" actix-rt = "2.8"
actix-files = "0.6.2" actix-files = "0.6.2"
actix-session = { version = "0.7", features = ["cookie-session"] } actix-session = { version = "0.9", features = ["cookie-session"] }
# sqlx is currently on version 0.3.5 in this project due to breaking changes introduced in versions # sqlx is currently on version 0.3.5 in this project due to breaking changes introduced in versions
# beyond 0.4.0, which changed the return type of 'exectute' to a 'Done'. Also the row parsing related # beyond 0.4.0, which changed the return type of 'exectute' to a 'Done'. Also the row parsing related
# traits have been altered. The overall architecture of this CRUD can still be reproduced with a # traits have been altered. The overall architecture of this CRUD can still be reproduced with a
# newer version of sqlx, and the version will be updated in the future. # newer version of sqlx, and the version will be updated in the future.
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-actix-rustls", "chrono"] } sqlx = { version = "0.7", features = [
"sqlite",
"runtime-tokio-rustls",
"chrono",
] }
uuid = { version = "1.2", features = ["serde", "v4"] } uuid = { version = "1.2", features = ["serde", "v4"] }
dotenv = "*" dotenv = "*"
env_logger = "0.10" env_logger = "0.11"
log = "*" log = "*"
lettre = {version="0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "hostname", "builder", "pool"]} lettre = { version = "0.11", default-features = false, features = [
"smtp-transport",
"tokio1-rustls-tls",
"hostname",
"builder",
"pool",
] }
rand = "*" rand = "*"
handlebars = {version="4.3", features=["dir_source"]} handlebars = { version = "5.1", features = ["dir_source"] }
glob = "*" glob = "*"
terminwahl_typen={path="../terminwahl_typen/"} terminwahl_typen = { path = "../terminwahl_typen/" }
serde = {workspace = true} serde = { workspace = true }
serde_json={workspace=true} serde_json = { workspace = true }
chrono={workspace=true} chrono = { workspace = true }

View File

@ -0,0 +1,49 @@
-- Add migration script here
CREATE TABLE date (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
subtitle TEXT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL
);
INSERT INTO date (id, name, subtitle, start_time, end_time)
VALUES (
1,
'OS 2023',
'2023',
'2023-02-28 12:00:00',
'2023-02-28 18:00:00'
);
-- Create a temporary table
CREATE TEMPORARY TABLE temp_appointment_slots AS
SELECT *
FROM appointment_slots;
DROP TABLE appointment_slots;
-- Recreate the appointments table with the new column and foreign key constraint
CREATE TABLE appointment_slots (
id INTEGER PRIMARY KEY,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
date_id INTEGER DEFAULT 1 Not NULL,
-- Add the new column
FOREIGN KEY (date_id) REFERENCES date(id) -- Add the foreign key constraint
);
-- Insert data back into the new appointments table from the temporary table
INSERT INTO appointment_slots
SELECT *,
1
FROM temp_appointment_slots;
-- Drop the temporary table
DROP TABLE temp_appointment_slots;
CREATE TABLE teacher_dates (
teacher_id INTEGER NOT NULL,
date_id INTEGER NOT NULL,
PRIMARY KEY (teacher_id, date_id),
FOREIGN KEY (teacher_id) REFERENCES teachers(id),
FOREIGN KEY (date_id) REFERENCES date(id)
);
INSERT INTO teacher_dates (teacher_id, date_id)
SELECT teachers.id AS teacher_id,
date.id AS date_id
FROM teachers,
date;

View File

@ -1,26 +1,32 @@
use actix_files::NamedFile; use actix_files::NamedFile;
use actix_web::{dev, middleware::ErrorHandlerResponse, Result}; use actix_web::{dev, middleware::ErrorHandlerResponse, Responder as _, Result};
pub fn bad_request<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { pub fn bad_request<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> {
let new_resp = NamedFile::open("static/errors/400.html")? let new_resp = NamedFile::open("static/errors/400.html")?
.set_status_code(res.status()) .customize()
.into_response(res.request()) .with_status(actix_web::http::StatusCode::OK)
.respond_to(res.request())
.map_into_boxed_body()
.map_into_right_body(); .map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) Ok(ErrorHandlerResponse::Response(res.into_response(new_resp)))
} }
pub fn not_found<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { pub fn not_found<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> {
let new_resp = NamedFile::open("static/errors/404.html")? let new_resp = NamedFile::open("static/errors/404.html")?
.set_status_code(res.status()) .customize()
.into_response(res.request()) .with_status(actix_web::http::StatusCode::OK)
.respond_to(res.request())
.map_into_boxed_body()
.map_into_right_body(); .map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) Ok(ErrorHandlerResponse::Response(res.into_response(new_resp)))
} }
pub fn internal_server_error<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> { pub fn internal_server_error<B>(res: dev::ServiceResponse<B>) -> Result<ErrorHandlerResponse<B>> {
let new_resp = NamedFile::open("static/errors/500.html")? let new_resp = NamedFile::open("static/errors/500.html")?
.set_status_code(res.status()) .customize()
.into_response(res.request()) .with_status(actix_web::http::StatusCode::OK)
.respond_to(res.request())
.map_into_boxed_body()
.map_into_right_body(); .map_into_right_body();
Ok(ErrorHandlerResponse::Response(res.into_response(new_resp))) Ok(ErrorHandlerResponse::Response(res.into_response(new_resp)))
} }

View File

@ -0,0 +1,55 @@
use std::io::Read as _;
use actix_files::NamedFile;
use actix_web::{
body::MessageBody,
dev::{self, ServiceResponse},
middleware::ErrorHandlerResponse,
Responder, Result,
};
pub fn bad_request<B: MessageBody>(
mut res: dev::ServiceResponse<B>,
) -> Result<ErrorHandlerResponse<B>> {
let mut new_resp = NamedFile::open("static/errors/400.html")?;
let mut body = String::new();
new_resp.read_to_string(&mut body);
res.response_mut().set_body(body);
/* let (req, res) = res.into_parts();
res.set_body(body);
let response = ServiceResponse::new(req, res)
.map_into_boxed_body()
.map_into_right_body(); */
Ok(ErrorHandlerResponse::Response(
res.map_into_boxed_body().map_into_right_body(),
))
}
pub fn not_found<B>(res: dev::ServiceResponse<B>) -> Result<impl Responder> {
let new_resp = NamedFile::open("static/errors/404.html")?
.customize()
.with_status(res.status())
.respond_to(res.request());
Ok(new_resp)
}
pub fn internal_server_error<B: MessageBody>(
res: dev::ServiceResponse<B>,
) -> Result<ErrorHandlerResponse<B>> {
let mut new_resp = NamedFile::open("static/errors/500.html")?;
let mut body = String::new();
new_resp.read_to_string(&mut body);
let (req, res) = res.into_parts();
res.set_body(body);
let response = ServiceResponse::new(req, res)
.map_into_boxed_body()
.map_into_right_body();
Ok(ErrorHandlerResponse::Response(response))
}

View File

@ -1,9 +1,14 @@
use actix_web::{error, web, Error, HttpResponse}; use actix_web::{error, web, Error, HttpResponse};
use terminwahl_typen::IdType;
use crate::db::{self, Pool}; use crate::db::{self, Pool};
pub async fn get_teachers_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> { pub async fn get_teachers_json(
let tasks = db::read::get_teachers(&pool) pool: web::Data<Pool>,
path: web::Path<IdType>,
) -> Result<HttpResponse, Error> {
let date_id = path.into_inner();
let tasks = db::read::get_teachers(&pool, date_id)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -17,8 +22,12 @@ pub async fn get_subjects_json(pool: web::Data<Pool>) -> Result<HttpResponse, Er
Ok(HttpResponse::Ok().json(tasks)) Ok(HttpResponse::Ok().json(tasks))
} }
pub async fn get_slots_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> { pub async fn get_slots_json(
let tasks = db::read::get_slots(&pool) pool: web::Data<Pool>,
path: web::Path<IdType>,
) -> Result<HttpResponse, Error> {
let date_id = path.into_inner();
let tasks = db::read::get_slots(&pool, date_id)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -31,3 +40,11 @@ pub async fn get_unavailable_json(pool: web::Data<Pool>) -> Result<HttpResponse,
Ok(HttpResponse::Ok().json(tasks)) Ok(HttpResponse::Ok().json(tasks))
} }
pub async fn get_dates_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> {
let dates = db::read::get_dates(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(dates))
}

View File

@ -7,7 +7,7 @@ use lettre::{
message::header::ContentType, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::header::ContentType, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
}; };
use log::debug; use log::debug;
use rand::{distributions::Alphanumeric, thread_rng, Rng}; use rand::{distr::Alphanumeric, rng, Rng};
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
use terminwahl_typen::{AppointmentSlot, Nutzer, PlannedAppointment, RequestState, Teacher}; use terminwahl_typen::{AppointmentSlot, Nutzer, PlannedAppointment, RequestState, Teacher};
@ -48,7 +48,7 @@ pub async fn save_appointments_json(
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
debug!("Saving appointments"); debug!("Saving appointments");
let validation_key: String = thread_rng() let validation_key: String = rng()
.sample_iter(&Alphanumeric) .sample_iter(&Alphanumeric)
.take(30) .take(30)
.map(char::from) .map(char::from)

View File

@ -5,17 +5,23 @@ use serde::{Deserialize, Serialize};
use sqlx::query_as; use sqlx::query_as;
use terminwahl_typen::{ use terminwahl_typen::{
AppointmentSlot, AppointmentSlots, IdType, Nutzer, SlotId, Subject, Subjects, Teacher, Teachers, AppointmentSlot, AppointmentSlots, Date, Dates, IdType, Nutzer, SlotId, Subject, Subjects,
Teacher, Teachers,
}; };
use super::Pool; use super::Pool;
pub async fn get_teachers(db: &Pool) -> Result<Teachers, sqlx::Error> { pub async fn get_teachers(db: &Pool, date_id: IdType) -> Result<Teachers, sqlx::Error> {
query_as!( query_as!(
Teacher, Teacher,
r#" r#"
SELECT * SELECT
FROM `teachers`"#, id,
ansprache,
last_name,
subject_id
FROM `teachers` JOIN teacher_dates ON teachers.id = teacher_dates.teacher_id where date_id = ?"#,
date_id
) )
.fetch_all(db) .fetch_all(db)
.await .await
@ -44,12 +50,13 @@ pub async fn get_subjects(db: &Pool) -> Result<Subjects, sqlx::Error> {
.await .await
} }
pub async fn get_slots(db: &Pool) -> Result<AppointmentSlots, sqlx::Error> { pub async fn get_slots(db: &Pool, date_id: IdType) -> Result<AppointmentSlots, sqlx::Error> {
match query_as!( match query_as!(
AppointmentSlot, AppointmentSlot,
r#" r#"
SELECT * SELECT id, start_time, end_time, date_id
FROM `appointment_slots`"#, FROM `appointment_slots` where date_id = ?"#,
date_id
) )
.fetch_all(db) .fetch_all(db)
.await .await
@ -87,7 +94,31 @@ pub async fn get_unavailable(db: &Pool) -> Result<HashSet<SlotId>, sqlx::Error>
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
pub async fn get_dates(db: &Pool) -> Result<Dates, sqlx::Error> {
match query_as!(
Date,
r#"
SELECT *
FROM `date` WHERE end_time > datetime('now', '-14 days');"#,
)
.fetch_all(db)
.await
{
Ok(elems) => Ok(elems.into_iter().collect()),
Err(e) => Err(e),
}
}
pub async fn get_date(db: &Pool, date_id: IdType) -> Result<Date, sqlx::Error> {
query_as!(
Date,
r#"
SELECT *
FROM `date` WHERE id = ?"#,
date_id
)
.fetch_one(db)
.await
}
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TeacherWithAppointments { pub struct TeacherWithAppointments {
teacher: Teacher, teacher: Teacher,
@ -109,8 +140,11 @@ pub struct AssignedAppointment {
nutzer: Nutzer, nutzer: Nutzer,
} }
pub async fn get_all_teachers(db: &Pool) -> Result<Vec<TeacherWithAppointments>, sqlx::Error> { pub async fn get_all_teachers(
let teachers = get_teachers(db).await?; db: &Pool,
date_id: IdType,
) -> Result<Vec<TeacherWithAppointments>, sqlx::Error> {
let teachers = get_teachers(db, date_id).await?;
let mut response = Vec::new(); let mut response = Vec::new();
for teacher in teachers.into_iter() { for teacher in teachers.into_iter() {

View File

@ -7,7 +7,7 @@ use actix_web::{
web, App, HttpServer, web, App, HttpServer,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use handlebars::Handlebars; use handlebars::{DirectorySourceOptions, Handlebars};
use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor}; use lettre::{transport::smtp::authentication::Credentials, AsyncSmtpTransport, Tokio1Executor};
use log::debug; use log::debug;
use std::env; use std::env;
@ -41,8 +41,12 @@ async fn main() -> std::io::Result<()> {
let mut handlebars = Handlebars::new(); let mut handlebars = Handlebars::new();
handlebars.register_helper("time_of", Box::new(TimeOfDate)); handlebars.register_helper("time_of", Box::new(TimeOfDate));
let handlebars_source = DirectorySourceOptions {
tpl_extension: ".hbs".to_string(),
..Default::default()
};
handlebars handlebars
.register_templates_directory(".hbs", handlebars_templates) .register_templates_directory(handlebars_templates, handlebars_source)
.unwrap(); .unwrap();
log::info!("starting HTTP server at http://localhost:8080"); log::info!("starting HTTP server at http://localhost:8080");
@ -69,13 +73,18 @@ async fn main() -> std::io::Result<()> {
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(session_store) .wrap(session_store)
.wrap(error_handlers) .wrap(error_handlers)
.service(web::resource("/get/dates").route(web::get().to(api::read::get_dates_json)))
.service( .service(
web::resource("/get/teachers").route(web::get().to(api::read::get_teachers_json)), web::resource("/get/teachers/{date_key}")
.route(web::get().to(api::read::get_teachers_json)),
) )
.service( .service(
web::resource("/get/subjects").route(web::get().to(api::read::get_subjects_json)), web::resource("/get/subjects").route(web::get().to(api::read::get_subjects_json)),
) )
.service(web::resource("/get/slots").route(web::get().to(api::read::get_slots_json))) .service(
web::resource("/get/slots/{date_key}")
.route(web::get().to(api::read::get_slots_json)),
)
.service( .service(
web::resource("/get/unavailable") web::resource("/get/unavailable")
.route(web::get().to(api::read::get_unavailable_json)), .route(web::get().to(api::read::get_unavailable_json)),
@ -93,7 +102,7 @@ async fn main() -> std::io::Result<()> {
.route(web::get().to(views::delete_appointment)), .route(web::get().to(views::delete_appointment)),
) )
.service( .service(
web::resource("/export/all/{password}") web::resource("/export/all/{password}/{id}")
.route(web::get().to(views::export_appointments)), .route(web::get().to(views::export_appointments)),
) )
.service(Files::new("/", wasm_statics.clone()).index_file("index.html")) .service(Files::new("/", wasm_statics.clone()).index_file("index.html"))

View File

@ -60,12 +60,12 @@ pub async fn export_appointments(
_mailer: web::Data<AsyncSmtpTransport<Tokio1Executor>>, _mailer: web::Data<AsyncSmtpTransport<Tokio1Executor>>,
handlebars: web::Data<Handlebars<'_>>, handlebars: web::Data<Handlebars<'_>>,
css: web::Data<CssPath>, css: web::Data<CssPath>,
path: web::Path<String>, path: web::Path<(String, IdType)>,
) -> Result<HttpResponse, error::Error> { ) -> Result<HttpResponse, error::Error> {
let password = path.into_inner(); let (password, date_id) = path.into_inner();
dbg!(&password); dbg!(&password);
if password == "AllExport1517" { if password == "AllExport1517" {
match get_all_teachers(&pool).await { match get_all_teachers(&pool, date_id).await {
Ok(teachers) => { Ok(teachers) => {
dbg!(&teachers); dbg!(&teachers);
let data = json!({ let data = json!({

View File

@ -16,7 +16,7 @@
Elternsprechtag Elternsprechtag
</p> </p>
<p class="subtitle"> <p class="subtitle">
Am 28.02.23 Am 06.03.24
</p> </p>
</div> </div>
</section> </section>

View File

@ -1,17 +1,17 @@
[package] [package]
name = "terminwahl_front" name = "terminwahl_front"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
yew = { version = "0.20.0", features = ["csr"] } yew = { version = "0.20.0", features = ["csr"] }
gloo="*" gloo = "*"
js-sys="*" js-sys = "*"
web-sys="*" web-sys = "*"
terminwahl_typen = {path="../terminwahl_typen/"} terminwahl_typen = { path = "../terminwahl_typen/" }
serde = {workspace = true} serde = { workspace = true }
serde_json={workspace=true} serde_json = { workspace = true }
chrono={workspace=true} chrono = { workspace = true }

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Yew App</title> <title>Elternsprechtag</title>
<link data-trunk rel="scss" href="static/my_bulma_colors.scss" /> <link data-trunk rel="scss" href="static/my_bulma_colors.scss" />
<link data-trunk rel="copy-file" href="static/logoheader.png" /> <link data-trunk rel="copy-file" href="static/logoheader.png" />
<link data-trunk rel="copy-dir" href="static/webfonts" /> <link data-trunk rel="copy-dir" href="static/webfonts" />
@ -12,4 +12,6 @@
<meta name="description" content="Termine buchen für den Lehrersprechtag der Waldorfschule Uhlandshöhe" /> <meta name="description" content="Termine buchen für den Lehrersprechtag der Waldorfschule Uhlandshöhe" />
</head> </head>
<body></body>
</html> </html>

View File

@ -2,10 +2,10 @@ mod requests;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use gloo::console::log; use gloo::console::log;
use requests::{fetch_slots, fetch_teachers, fetch_unavailable, send_appointments}; use requests::{fetch_dates, fetch_slots, fetch_teachers, fetch_unavailable, send_appointments};
use terminwahl_typen::{ use terminwahl_typen::{
AppointmentSlot, AppointmentSlots, IdType, Nutzer, PlannedAppointment, RequestState, SlotId, AppointmentSlot, AppointmentSlots, Date, Dates, IdType, Nutzer, PlannedAppointment,
Teacher, Teachers, RequestState, SlotId, Teacher, Teachers,
}; };
use web_sys::HtmlInputElement; use web_sys::HtmlInputElement;
use yew::prelude::*; use yew::prelude::*;
@ -16,9 +16,12 @@ pub enum Msg {
UpdateSchüler(String), UpdateSchüler(String),
UpdateEmail(String), UpdateEmail(String),
DataEntered(Nutzer), DataEntered(Nutzer),
GetTeachers, GetTeachers(IdType),
ReceivedTeachers(Teachers), ReceivedTeachers(Teachers),
GetSlots, GetDates,
ReceivedDates(Dates),
SelectDate(IdType),
GetSlots(IdType),
ReceivedSlots(AppointmentSlots), ReceivedSlots(AppointmentSlots),
Selected(PlannedAppointment), Selected(PlannedAppointment),
TooMany, TooMany,
@ -30,6 +33,8 @@ pub enum Msg {
pub struct App { pub struct App {
nutzer: Option<Nutzer>, nutzer: Option<Nutzer>,
tmp_nutzer: Nutzer, tmp_nutzer: Nutzer,
dates: Option<Dates>,
selected_date: Option<Date>,
teachers: Option<Teachers>, teachers: Option<Teachers>,
slots: Option<AppointmentSlots>, slots: Option<AppointmentSlots>,
appointments: HashMap<SlotId, PlannedAppointment>, appointments: HashMap<SlotId, PlannedAppointment>,
@ -54,6 +59,8 @@ impl Component for App {
let app = Self { let app = Self {
appointments: HashMap::new(), appointments: HashMap::new(),
slots: None, slots: None,
dates: None,
selected_date: None,
unavailable: None, unavailable: None,
teachers: None, teachers: None,
nutzer: None, nutzer: None,
@ -64,8 +71,8 @@ impl Component for App {
}, },
successfully_saved: None, successfully_saved: None,
}; };
ctx.link().send_message(Msg::GetTeachers);
ctx.link().send_message(Msg::GetSlots); ctx.link().send_message(Msg::GetDates);
app app
} }
@ -85,16 +92,38 @@ impl Component for App {
true true
} }
Msg::TooMany => todo!(), Msg::TooMany => todo!(),
Msg::GetTeachers => { Msg::GetTeachers(id) => {
ctx.link().send_future(fetch_teachers()); ctx.link().send_future(fetch_teachers(id));
false false
} }
Msg::ReceivedTeachers(teachers) => { Msg::ReceivedTeachers(teachers) => {
self.teachers = Some(teachers); self.teachers = Some(teachers);
true true
} }
Msg::GetSlots => { Msg::GetDates => {
ctx.link().send_future(fetch_slots()); ctx.link().send_future(fetch_dates());
false
}
Msg::ReceivedDates(dates) => {
if dates.len() == 1 {
ctx.link()
.send_message(Msg::SelectDate(dates.first().unwrap().id))
}
self.dates = Some(dates);
true
}
Msg::SelectDate(date_id) => {
ctx.link().send_message(Msg::GetTeachers(date_id));
ctx.link().send_message(Msg::GetSlots(date_id));
let date = self
.dates
.as_ref()
.map(|dts| dts.iter().find(|d| d.id == date_id).unwrap());
self.selected_date = Some(date.expect("A date should be found").clone());
true
}
Msg::GetSlots(id) => {
ctx.link().send_future(fetch_slots(id));
ctx.link().send_future(fetch_unavailable()); ctx.link().send_future(fetch_unavailable());
false false
} }
@ -150,24 +179,45 @@ impl Component for App {
html! {<> html! {<>
<section class="hero is-warning"> <section class="hero is-warning">
<div class="hero-body"> <div class="hero-body">
<p class="title has-text-link">
{"Elternsprechtag"} {if let Some(d) = self.selected_date.as_ref(){ html!{
</p> <><p class="title has-text-link">
<p class="subtitle"> {&d.name}
{"Am 28.02.23"} </p><p class="subtitle">
</p> {&d.subtitle}
</p><p class="subtitle">
{"Am "}{d.start_time.format("%d.%m.%Y")}
</p></>}}else{html!(<p class="title has-text-link">{"Elternsprechtag"}</p>)}}
</div> </div>
</section> </section>
<div class="container"> <div class="container">
<div class="section"> <div class="section">
{ {
if let Some(_saved) = self.successfully_saved.as_ref(){self.view_dank_dialog(ctx)} else if self.nutzer.is_none(){ if let Some(dates) = self.dates.as_ref(){
if let Some(date) = self.selected_date.as_ref(){
if date.end_time < (chrono::Local::now() + chrono::Duration::days(4)).naive_local(){
html!(
<div class="columns is-centered">
<div class="column is-half">
<div class="notification is-light">
<h1>{"Die Anmeldung ist bereits geschlossen."}</h1>
</div>
</div>
</div>
)
} else if let Some(_saved) = self.successfully_saved.as_ref(){
self.view_dank_dialog(ctx)
} else if self.nutzer.is_none(){
self.view_eingabe_daten(ctx) self.view_eingabe_daten(ctx)
} }
else else
{ {
self.view_auswahl_termine(ctx) self.view_auswahl_termine(ctx)
} }
} else {
self.view_auswahl_date(dates, ctx)
}
}else{html!(<h1>{"Loading"}</h1>)}
} }
</div> </div>
</div> </div>
@ -178,6 +228,35 @@ impl Component for App {
} }
impl App { impl App {
fn view_auswahl_date(&self, dates: &Dates, ctx: &Context<Self>) -> Html {
let onchange = ctx.link().callback(|e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
Msg::SelectDate(input.value().parse().unwrap())
});
html! {
<div class="columns is-centered">
<div class="column is-half">
<div class="notification is-light">
<figure class="image container is-128x128" id="headerlogo">
<img src="/logoheader.png" />
</figure>
<div class="box mt-3 is-light">
<p>{"Anmeldung zum Elternsprechtag!"}</p><p>{"Bitte wählen Sie den Sprechtag zu welchem Sie sich anmelden möchten:"}</p>
</div>
<div class="select is-rounded is-fullwidth">
<select onchange={onchange}>
<option selected=true value="--">{"Bitte wählen Sie einen Termin"}</option>
{dates.iter().map(|dt|html!{
<option value={dt.id.to_string()}>{&dt.name}{" "}{&dt.start_time.format("%d.%m.%Y")}</option>
}).collect::<Html>()}
</select>
</div>
</div>
</div>
</div>
}
}
fn view_eingabe_daten(&self, ctx: &Context<Self>) -> Html { fn view_eingabe_daten(&self, ctx: &Context<Self>) -> Html {
html! { <div class="columns is-centered"> html! { <div class="columns is-centered">
<div class="column is-half"> <div class="column is-half">
@ -186,7 +265,7 @@ impl App {
<img src="/logoheader.png" /> <img src="/logoheader.png" />
</figure> </figure>
<div class="box mt-3 is-light"> <div class="box mt-3 is-light">
<p>{"Anmeldung zum Elternsprechtag!"}</p><p>{"Bitte geben Sie unbedingt eine gültige E-Mail-Adresse an, <p>{"Anmeldung zum "} {&self.dates.as_ref().expect("termin").first().expect("termin").name}{" am "}{&self.dates.as_ref().expect("termin").first().expect("termin").start_time.format("%d.%m.%Y")}{"!"}</p><p>{"Bitte geben Sie unbedingt eine gültige E-Mail-Adresse an,
da die Termine erst nach Bestätigung über den per E-Mail zugesandten Link gebucht werden."}</p> da die Termine erst nach Bestätigung über den per E-Mail zugesandten Link gebucht werden."}</p>
</div> </div>
<div class="field"> <div class="field">

View File

@ -1,11 +1,23 @@
use gloo::net::http::{Method, Request}; use gloo::net::http::Request;
use terminwahl_typen::{Nutzer, PlannedAppointment, RequestState}; use terminwahl_typen::{IdType, Nutzer, PlannedAppointment, RequestState};
use crate::Msg; use crate::Msg;
pub async fn fetch_teachers() -> Result<Msg, Msg> { pub async fn fetch_dates() -> Result<Msg, Msg> {
// Send the request to the specified URL. // Send the request to the specified URL.
let response = Request::new("/get/teachers").send().await; let response = Request::get("/get/dates").send().await;
// Return the ZuordnungMessage with the given network object and the response.
let response = response
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
.json()
.await
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))?;
Ok(Msg::ReceivedDates(response))
}
pub async fn fetch_teachers(id: IdType) -> Result<Msg, Msg> {
// Send the request to the specified URL.
let response = Request::get(&format!("/get/teachers/{}", id)).send().await;
// Return the ZuordnungMessage with the given network object and the response. // Return the ZuordnungMessage with the given network object and the response.
let response = response let response = response
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))? .map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
@ -15,9 +27,9 @@ pub async fn fetch_teachers() -> Result<Msg, Msg> {
Ok(Msg::ReceivedTeachers(response)) Ok(Msg::ReceivedTeachers(response))
} }
pub async fn fetch_slots() -> Result<Msg, Msg> { pub async fn fetch_slots(id: IdType) -> Result<Msg, Msg> {
// Send the request to the specified URL. // Send the request to the specified URL.
let response = Request::new("/get/slots").send().await; let response = Request::get(&format!("/get/slots/{}", id)).send().await;
// Return the ZuordnungMessage with the given network object and the response. // Return the ZuordnungMessage with the given network object and the response.
let response = response let response = response
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))? .map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
@ -29,7 +41,7 @@ pub async fn fetch_slots() -> Result<Msg, Msg> {
pub async fn fetch_unavailable() -> Result<Msg, Msg> { pub async fn fetch_unavailable() -> Result<Msg, Msg> {
// Send the request to the specified URL. // Send the request to the specified URL.
let response = Request::new("/get/unavailable").send().await; let response = Request::get("/get/unavailable").send().await;
// Return the ZuordnungMessage with the given network object and the response. // Return the ZuordnungMessage with the given network object and the response.
let response = response let response = response
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))? .map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
@ -43,8 +55,7 @@ pub async fn send_appointments(
appointments: Vec<PlannedAppointment>, appointments: Vec<PlannedAppointment>,
nutzer: Nutzer, nutzer: Nutzer,
) -> Result<Msg, Msg> { ) -> Result<Msg, Msg> {
let response = Request::new("/send/appointments") let response = Request::post("/send/appointments")
.method(Method::POST)
.json(&(&appointments, &nutzer)) .json(&(&appointments, &nutzer))
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))? .map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
.send() .send()

View File

@ -1,4 +1,7 @@
bulma.sass bulma.sass
sass/ sass
webfonts/ webfonts
scss/ scss
*.zip
bulma
fontawesome*

View File

@ -1,12 +1,12 @@
[package] [package]
name = "terminwahl_typen" name = "terminwahl_typen"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
serde = {workspace = true} serde = { workspace = true }
serde_json={workspace=true} serde_json = { workspace = true }
chrono={workspace=true} chrono = { workspace = true }

View File

@ -41,6 +41,7 @@ pub struct AppointmentSlot {
pub id: IdType, pub id: IdType,
pub start_time: NaiveDateTime, pub start_time: NaiveDateTime,
pub end_time: NaiveDateTime, pub end_time: NaiveDateTime,
pub date_id: IdType,
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
@ -84,3 +85,14 @@ impl Nutzer {
!self.name.is_empty() && !self.email.is_empty() && !self.schueler.is_empty() !self.name.is_empty() && !self.email.is_empty() && !self.schueler.is_empty()
} }
} }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Date {
pub id: IdType,
pub name: String,
pub subtitle: String,
pub start_time: NaiveDateTime,
pub end_time: NaiveDateTime,
}
pub type Dates = Vec<Date>;