Buchung funktioniert email anfänglich

This commit is contained in:
Franz Dietrich 2023-01-29 22:56:33 +01:00
parent 0cd75a9ff5
commit c708a4b79b
Signed by: dietrich
GPG Key ID: F0CE5A20AB5C4B27
21 changed files with 728 additions and 38 deletions

2
.env
View File

@ -1 +1,3 @@
DATABASE_URL="sqlite://db.sqlite" DATABASE_URL="sqlite://db.sqlite"
SMTP_USER="SMTP_USERNAME"
SMTP_PASSWORD="SMTP_PASSWORD"

3
.gitignore vendored
View File

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

94
Cargo.lock generated
View File

@ -692,6 +692,22 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "email-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbfb21b9878cf7a348dcb8559109aabc0ec40d69924bd706fa5149846c4fef75"
dependencies = [
"base64 0.21.0",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.31" version = "0.8.31"
@ -741,6 +757,15 @@ version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fastrand"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499"
dependencies = [
"instant",
]
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.0.25" version = "1.0.25"
@ -1152,6 +1177,17 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi",
]
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.8" version = "0.2.8"
@ -1319,6 +1355,34 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]]
name = "lettre"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd84a055407850bcf4791baa77cb4818d37cbb79ad4e60b9b659727b920d2c65"
dependencies = [
"async-trait",
"base64 0.21.0",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"nom",
"once_cell",
"quoted_printable",
"rustls",
"rustls-pemfile",
"socket2",
"tokio",
"tokio-rustls",
"webpki-roots",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.139" version = "0.2.139"
@ -1388,6 +1452,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.5.0" version = "2.5.0"
@ -1684,6 +1754,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f14e071918cbeefc5edc986a7aa92c425dae244e003a35e1cdddb5ca39b5cb"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.5"
@ -2109,10 +2185,12 @@ dependencies = [
"chrono", "chrono",
"dotenv", "dotenv",
"env_logger", "env_logger",
"lettre",
"log", "log",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"terminwahl_typen",
"uuid", "uuid",
] ]
@ -2120,9 +2198,25 @@ dependencies = [
name = "terminwahl_front" name = "terminwahl_front"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"gloo",
"js-sys",
"serde",
"serde_json",
"terminwahl_typen",
"web-sys",
"yew", "yew",
] ]
[[package]]
name = "terminwahl_typen"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.38" version = "1.0.38"

View File

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

View File

@ -15,10 +15,13 @@ actix-session = { version = "0.7", features = ["cookie-session"] }
# 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.6.2", features = ["sqlite", "runtime-actix-rustls", "chrono"] }
serde = {version="1.0", features = ["derive"]}
serde_json = "1.0"
uuid = { version = "1.2", features = ["serde", "v4"] } uuid = { version = "1.2", features = ["serde", "v4"] }
dotenv = "*" dotenv = "*"
env_logger = "0.10" env_logger = "0.10"
log = "*" log = "*"
chrono = {version="*", features = ["serde"]} lettre = {version="0.10", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "hostname", "builder", "pool"]}
terminwahl_typen={path="../terminwahl_typen/"}
serde = {workspace = true}
serde_json={workspace=true}
chrono={workspace=true}

View File

@ -1,2 +1,3 @@
pub mod errors; pub mod errors;
pub mod read; pub mod read;
pub mod write;

View File

@ -1,5 +1,3 @@
use std::collections::HashMap;
use actix_web::{error, web, Error, HttpResponse}; use actix_web::{error, web, Error, HttpResponse};
use crate::db::{self, Pool}; use crate::db::{self, Pool};
@ -19,8 +17,15 @@ 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_free_slots_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> { pub async fn get_slots_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> {
let tasks = db::read::get_free_slots(&pool) let tasks = db::read::get_slots(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(tasks))
}
pub async fn get_unavailable_json(pool: web::Data<Pool>) -> Result<HttpResponse, Error> {
let tasks = db::read::get_unavailable(&pool)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

View File

@ -0,0 +1,37 @@
use actix_web::{error, web, HttpResponse};
use lettre::{
transport::smtp::{authentication::Credentials, SmtpTransportBuilder},
Message, SmtpTransport, Transport,
};
use terminwahl_typen::{PlannedAppointment, RequestState};
use crate::db::{self, Pool};
pub async fn save_appointments_json(
pool: web::Data<Pool>,
mailer: web::Data<SmtpTransport>,
appointments: web::Json<Vec<PlannedAppointment>>,
) -> Result<HttpResponse, error::Error> {
db::write::save_appointments(&pool, &appointments)
.await
.map_err(error::ErrorInternalServerError)?;
let email = Message::builder()
.from(
"Franz Dietrich <franz.dietrich@uhlandshoehe.de>"
.parse()
.unwrap(),
)
.to("Franz Dietrich <dietrich@teilgedanken.de>".parse().unwrap())
.subject("Happy new year")
.body(String::from("Be happy!"))
.unwrap();
// Send the email
match mailer.send(&email) {
Ok(_) => println!("Email sent successfully!"),
Err(e) => panic!("Could not send email: {:?}", e),
}
Ok(HttpResponse::Ok().json(RequestState::Success))
}

View File

@ -1,6 +1,10 @@
use std::collections::HashSet;
use sqlx::query_as; use sqlx::query_as;
use crate::types::{AppointmentSlot, AppointmentSlots, Subject, Subjects, Teacher, Teachers}; use terminwahl_typen::{
AppointmentSlot, AppointmentSlots, SlotId, Subject, Subjects, Teacher, Teachers,
};
use super::Pool; use super::Pool;
@ -11,7 +15,7 @@ pub async fn get_teachers(db: &Pool) -> Result<Teachers, sqlx::Error> {
SELECT * SELECT *
FROM `teachers`"#, FROM `teachers`"#,
) )
.fetch_all(&*db) .fetch_all(db)
.await .await
} }
@ -22,21 +26,35 @@ pub async fn get_subjects(db: &Pool) -> Result<Subjects, sqlx::Error> {
SELECT * SELECT *
FROM `subjects`"#, FROM `subjects`"#,
) )
.fetch_all(&*db) .fetch_all(db)
.await .await
} }
pub async fn get_free_slots(db: &Pool) -> Result<AppointmentSlots, sqlx::Error> { pub async fn get_slots(db: &Pool) -> Result<AppointmentSlots, sqlx::Error> {
match query_as!( match query_as!(
AppointmentSlot, AppointmentSlot,
r#" r#"
SELECT * SELECT *
FROM `appointment_slots`"#, FROM `appointment_slots`"#,
) )
.fetch_all(&*db) .fetch_all(db)
.await .await
{ {
Ok(elems) => Ok(elems.into_iter().map(|e| (e.id, e)).collect()), Ok(elems) => Ok(elems.into_iter().map(|e| (e.id, e)).collect()),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
pub async fn get_unavailable(db: &Pool) -> Result<HashSet<SlotId>, sqlx::Error> {
match query_as!(
SlotId,
r#"
SELECT teacher_id, slot_id
FROM `appointments`"#,
)
.fetch_all(db)
.await
{
Ok(elems) => Ok(elems.into_iter().collect()),
Err(e) => Err(e),
}
}

View File

@ -0,0 +1,17 @@
use terminwahl_typen::PlannedAppointment;
use super::Pool;
pub async fn save_appointments(
pool: &Pool,
appointments: &[PlannedAppointment],
) -> Result<(), sqlx::Error> {
for appointment in appointments {
sqlx::query("INSERT INTO appointments (teacher_id, slot_id) VALUES ($1, $2)")
.bind(appointment.teacher_id)
.bind(appointment.slot_id)
.execute(pool)
.await?;
}
Ok(())
}

View File

@ -1,3 +1,2 @@
pub mod api; pub mod api;
pub mod db; pub mod db;
pub mod types;

View File

@ -7,10 +7,8 @@ use actix_web::{
web, App, HttpServer, web, App, HttpServer,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use std::{ use lettre::{transport::smtp::authentication::Credentials, SmtpTransport};
env, use std::env;
sync::{Arc, Mutex},
};
use terminwahl_back::{api, db}; use terminwahl_back::{api, db};
#[actix_web::main] #[actix_web::main]
@ -22,6 +20,15 @@ async fn main() -> std::io::Result<()> {
let pool = db::init_pool(&database_url) let pool = db::init_pool(&database_url)
.await .await
.expect("Failed to create pool"); .expect("Failed to create pool");
let smtp_user = env::var("SMTP_USER").expect("Failed to get smtp user");
let smtp_password = env::var("SMTP_PASSWORD").expect("Failed to get smtp password");
let credentials = Credentials::new(smtp_user, smtp_password);
let smtp_pool = SmtpTransport::relay("smtp.1und1.de")
.expect("Failed to connect to smtp")
// Add credentials for authentication
.credentials(credentials)
// Connection pool settings
.build();
log::info!("starting HTTP server at http://localhost:8080"); log::info!("starting HTTP server at http://localhost:8080");
@ -40,6 +47,7 @@ async fn main() -> std::io::Result<()> {
App::new() App::new()
.app_data(web::Data::new(pool.clone())) .app_data(web::Data::new(pool.clone()))
.app_data(web::Data::new(smtp_pool.clone()))
.wrap(Logger::default()) .wrap(Logger::default())
.wrap(session_store) .wrap(session_store)
.wrap(error_handlers) .wrap(error_handlers)
@ -49,9 +57,14 @@ async fn main() -> std::io::Result<()> {
.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( .service(
web::resource("/get/free_slots") web::resource("/get/unavailable")
.route(web::get().to(api::read::get_free_slots_json)), .route(web::get().to(api::read::get_unavailable_json)),
)
.service(
web::resource("/send/appointments")
.route(web::post().to(api::write::save_appointments_json)),
) )
.service(Files::new("/", "./terminwahl_front/dist/").index_file("index.html")) .service(Files::new("/", "./terminwahl_front/dist/").index_file("index.html"))
}) })

View File

@ -8,3 +8,10 @@ edition = "2021"
[dependencies] [dependencies]
yew = { version = "0.20.0", features = ["csr"] } yew = { version = "0.20.0", features = ["csr"] }
gloo="*"
js-sys="*"
web-sys="*"
terminwahl_typen = {path="../terminwahl_typen/"}
serde = {workspace = true}
serde_json={workspace=true}
chrono={workspace=true}

View File

@ -1,9 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Yew App</title> <title>Yew App</title>
<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-dir" href="static/webfonts" />
<meta name="keywords" content="Termine,Waldorfschule,Lehrersprechtag,Lehrerinnensprechtag" />
<meta name="author" content="Franz Dietrich" />
<meta name="description" content="Termine buchen für den Lehrersprechtag der Waldorfschule Uhlandshöhe" />
</head> </head>
</html> </html>

View File

@ -1,21 +1,375 @@
mod requests;
use std::collections::{HashMap, HashSet};
use gloo::console::log;
use requests::{fetch_slots, fetch_teachers, fetch_unavailable, send_appointments};
use serde::{Deserialize, Serialize};
use terminwahl_typen::{
AppointmentSlot, AppointmentSlots, IdType, PlannedAppointment, RequestState, SlotId, Teacher,
Teachers,
};
use web_sys::HtmlInputElement;
use yew::prelude::*; use yew::prelude::*;
#[function_component] // Define the possible messages which can be sent to the component
fn App() -> Html { pub enum Msg {
let counter = use_state(|| 5); UpdateName(String),
let onclick = { UpdateSchüler(String),
let counter = counter.clone(); UpdateEmail(String),
move |_| { DataEntered(Nutzer),
let value = *counter + 1; GetTeachers,
counter.set(value); ReceivedTeachers(Teachers),
} GetSlots,
}; ReceivedSlots(AppointmentSlots),
Selected(PlannedAppointment),
TooMany,
SendToServer,
AppointmentsSent(RequestState),
ReceivedUnavailable(HashSet<SlotId>),
}
html! { pub struct App {
<div> nutzer: Option<Nutzer>,
<button {onclick}>{ "+1" }</button> tmp_nutzer: Nutzer,
<p>{ *counter }</p> teachers: Option<Teachers>,
slots: Option<AppointmentSlots>,
appointments: HashMap<SlotId, PlannedAppointment>,
unavailable: Option<HashSet<SlotId>>,
successfully_saved: Option<RequestState>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Nutzer {
name: String,
schüler: String,
email: String,
}
impl From<Result<Msg, Msg>> for Msg {
fn from(value: Result<Msg, Msg>) -> Self {
match value {
Ok(m) => m,
Err(m) => m,
}
}
}
impl Nutzer {
fn validate(&self) -> bool {
!self.name.is_empty() && !self.email.is_empty() && !self.schüler.is_empty()
}
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
let app = Self {
appointments: HashMap::new(),
slots: None,
unavailable: None,
teachers: None,
nutzer: None,
tmp_nutzer: Nutzer {
name: "".into(),
schüler: "".into(),
email: "".into(),
},
successfully_saved: None,
};
ctx.link().send_message(Msg::GetTeachers);
ctx.link().send_message(Msg::GetSlots);
app
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::Selected(planned_appointment) => {
let slot_id =
SlotId::new(planned_appointment.teacher_id, planned_appointment.slot_id);
if self.appointments.contains_key(&slot_id) {
self.appointments.remove(&slot_id);
} else {
self.appointments.insert(
SlotId::new(planned_appointment.teacher_id, planned_appointment.slot_id),
planned_appointment,
);
};
true
}
Msg::TooMany => todo!(),
Msg::GetTeachers => {
ctx.link().send_future(fetch_teachers());
false
}
Msg::ReceivedTeachers(teachers) => {
self.teachers = Some(teachers);
true
}
Msg::GetSlots => {
ctx.link().send_future(fetch_slots());
ctx.link().send_future(fetch_unavailable());
false
}
Msg::ReceivedSlots(slots) => {
self.slots = Some(slots);
true
}
Msg::DataEntered(n) => {
self.nutzer = Some(n);
true
}
Msg::UpdateName(s) => {
log!("update name to {}", &s);
self.tmp_nutzer.name = s;
true
}
Msg::UpdateSchüler(s) => {
self.tmp_nutzer.schüler = s;
true
}
Msg::UpdateEmail(s) => {
self.tmp_nutzer.email = s;
true
}
Msg::SendToServer => {
if (1..=3).contains(&self.appointments.len()) {
let values = self.appointments.clone().into_values();
let appointments: Vec<PlannedAppointment> = values.collect();
ctx.link().send_future(send_appointments(appointments));
true
} else {
true
}
}
Msg::AppointmentsSent(state) => {
self.successfully_saved = Some(state);
true
}
Msg::ReceivedUnavailable(r) => {
self.unavailable = Some(r);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {<>
<section class="hero is-warning">
<div class="hero-body">
<p class="title has-text-link">
{"Elternsprechtag"}
</p>
<p class="subtitle">
{"Am 28.02.23"}
</p>
</div> </div>
</section>
<div class="container">
<div class="section">
{
if self.nutzer.is_none(){
self.view_eingabe_daten(ctx)
}
else
{
self.view_auswahl_termine(ctx)
}
}
</div>
</div>
</>
}
}
}
impl App {
fn view_eingabe_daten(&self, ctx: &Context<Self>) -> Html {
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 geben Sie unbedingt eine valide Emailadresse an,
da die Termine erst nach Bestätigung über den per Email zugesandten Link gebucht sind."}</p>
</div>
<div class="field">
<label class="label">{"Voller Name"}</label>
<p class="control has-icons-left has-icons-right">
<input class="input" type="text" placeholder="Voller Name" value={self.tmp_nutzer.name.to_string()}
oninput={ctx.link().batch_callback(|event:InputEvent| {
event.target_dyn_into::<HtmlInputElement>().map(|input|Msg::UpdateName(input.value()))
})}/>
<span class="icon is-small is-left">
<i class="fas fa-signature"></i>
</span>
</p>
</div>
<div class="field">
<label class="label">{"Alle betreffenden SchülerInnen"}</label>
<p class="control has-icons-left has-icons-right">
<input class="input" type="email" placeholder="Schülerinnen" value={self.tmp_nutzer.schüler.to_string()}
oninput={ctx.link().batch_callback(|event:InputEvent| {
event.target_dyn_into::<HtmlInputElement>().map(|input|Msg::UpdateSchüler(input.value()))
})}/>
<span class="icon is-small is-left">
<i class="fas fa-graduation-cap"></i>
</span>
</p>
</div>
<div class="field">
<label class="label">{"Email"}</label>
<p class="control has-icons-left">
<input class="input" type="email" placeholder="email" value={self.tmp_nutzer.email.to_string()}
oninput={ctx.link().batch_callback(|event:InputEvent| {
event.target_dyn_into::<HtmlInputElement>().map(|input|Msg::UpdateEmail(input.value()))
})}/>
<span class="icon is-small is-left">
<i class="fas fa-at"></i>
</span>
</p>
</div>
<div class="box mt-3 is-light">
<h4 class="title is-4">{"Datenschutzerklärung:"}</h4>
<p>{"Mit dem klick auf Weiter bestätigen Sie, dass das Lehrerkollegium die hier
und im folgenden eingegebenen Daten zur Organisation des Elternsprechtags speichert, verarbeitet und verwendet.
Die Daten werden nur für diesen Zweck verwendet."}</p>
<div class="has-text-right mt-6 mr-6">
{
if self.tmp_nutzer.validate() {
html!{<a class="button is-link is-medium" onclick={
let tmp_nutzer = self.tmp_nutzer.clone();
ctx.link().callback(move |_|{Msg::DataEntered(tmp_nutzer.clone()) })
}>{"Weiter"}</a>}
} else {html!{<><p>{"Füllen Sie zunächst die Felder oben aus!"}</p><a class="button is-link is-medium" disabled=true>{"Weiter"}</a></>}}}
</div>
</div>
</div>
</div>
</div>}
}
fn view_auswahl_termine(&self, ctx: &Context<Self>) -> Html {
html! {<>
<div id="app" class="row columns is-multiline">
{self.anleitung()}
{if let Some(ref teachers) = self.teachers
{teachers.iter().map(|t|{self.view_teacher_card(ctx, t)}).collect()}else{html!{}}}
</div>
<div class="container">
<div class="section">
{ if self.appointments.is_empty(){
html!{<>
<p>{"Bitte wählen Sie mindestens einen Block aus, bevor sie buchen können."}</p>
<div class="section">
<button class="button" disabled=true>{"Buchungen reservieren"}</button>
</div></>
} }else if self.appointments.len() > 3{
html!{<>
<p>{"Bitte wählen Sie höchstens drei Blöcke, bevor sie buchen können."}</p>
<div class="section">
<button class="button" disabled=true>{"Buchungen reservieren"}</button>
</div></>
}}else{html!{<>
<button class="button is-primary" onclick={ctx.link().callback(|_|Msg::SendToServer)}>{"Buchungen reservieren"}</button></>
}
}}
</div>
</div>
</>
}
}
fn anleitung(&self) -> Html {
html!( <div class="column is-12">
<div class="card">
<header class="card-header">
<div class="card-header-title is-centered">
{"Hinweise"}
</div>
</header>
<div class="card-content">
<div class="content">
<ul>
<li>{"Termine sind erst gebucht, wenn Sie:"}
<ul><li>{"ganz unten auf Absenden geklickt haben"}</li>
<li>{"Sie die Buchung innerhalb von 3 Stunden über den link in der email bestätigen."}</li>
</ul>
</li>
<li>{"Maximal 3 Termine pro Elternhaus, da die Anzahl der Termine begrenzt ist."}</li>
<li>{"Buchen Sie die Termine so, dass sie zwischen zwei Terminen mindestens einen Slot Pause haben für Raumsuche usw."}</li>
<li>{"Sprechen Sie vor allem mit den LehrerInnen, wo ihre Kinder Probleme haben."}</li>
<li>{"Sollten Sie dringenden Gesprächsbedarf haben, aber alle Termine sind voll, melden Sie sich wie gehabt bei den entsprechenden LehrerInnen"}</li>
<li>{"Sollten Sie ihren Termin nicht wahrnehmen können, sagen Sie ihn möglichst früh ab, dass er erneut belegt werden kann."}</li>
</ul>
</div>
</div>
</div>
</div>)
}
fn view_teacher_card(&self, ctx: &Context<Self>, teacher: &Teacher) -> Html {
html! {
<div class="column is-3">
<div class="card">
<header class="card-header">
<div class="card-header-title is-centered">
{&teacher.ansprache}{" "}{&teacher.last_name}
</div>
</header>
<div class="card-content">
<div class="content">
{ if let Some(ref slots) = self.slots
{
let mut slots:Vec<&AppointmentSlot> = slots.values().collect();
slots.sort_by(|x,y|{x.start_time.cmp(&y.start_time)});
let slots = slots.iter().map(|t|{self.view_teacher_slot(ctx, t, teacher.id)});
let first = slots.clone().take(4);
let rest = slots.skip(4);
first.chain([html!{<div class="has-text-centered mb-3">{"Pause"}</div>}]).chain(rest).collect()}else{html!{}} }
</div>
</div>
</div>
</div>
}
}
fn view_teacher_slot(
&self,
ctx: &Context<Self>,
slot: &AppointmentSlot,
teacher_id: IdType,
) -> Html {
let slot_id = slot.id;
let onclick = ctx.link().callback(move |_| {
Msg::Selected(PlannedAppointment {
teacher_id,
slot_id,
})
});
let text = format!(
"{} {}",
slot.start_time.time().format("%H:%M"),
slot.end_time.time().format("%H:%M")
);
let slot_id = SlotId::new(teacher_id, slot.id);
let (color, disabled) = if self.appointments.contains_key(&slot_id) {
("is-primary", false)
} else if let Some(unavailable) = &self.unavailable {
if unavailable.contains(&slot_id) {
("is-danger", true)
} else {
("", false)
}
} else {
("", false)
};
html! {<button class={format!("button is-fullwidth mb-2 {color}")} disabled={disabled} {onclick}>{text}</button>}
} }
} }

View File

@ -0,0 +1,55 @@
use gloo::net::http::{Method, Request};
use terminwahl_typen::{PlannedAppointment, RequestState};
use crate::Msg;
pub async fn fetch_teachers() -> Result<Msg, Msg> {
// Send the request to the specified URL.
let response = Request::new("/get/teachers").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::ReceivedTeachers(response))
}
pub async fn fetch_slots() -> Result<Msg, Msg> {
// Send the request to the specified URL.
let response = Request::new("/get/slots").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::ReceivedSlots(response))
}
pub async fn fetch_unavailable() -> Result<Msg, Msg> {
// Send the request to the specified URL.
let response = Request::new("/get/unavailable").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::ReceivedUnavailable(response))
}
pub async fn send_appointments(appointments: Vec<PlannedAppointment>) -> Result<Msg, Msg> {
let response = Request::new("/send/appointments")
.method(Method::POST)
.json(&appointments)
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
.send()
.await;
let response = response
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))?
.json()
.await
.map_err(|_| Msg::AppointmentsSent(RequestState::Error))?;
Ok(Msg::AppointmentsSent(response))
}

4
terminwahl_front/static/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bulma.sass
sass/
webfonts/
scss/

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,23 @@
@charset "utf-8";
$blue: #083aa6;
$yellow: #f2b227;
$cyan: #1c83c5;
@import "./bulma.sass";
#headerlogo {
background-color: $blue;
border-radius: 79px;
padding: 15px;
}
$fa-font-path: "./webfonts";
// importing core styling file
@import "./scss/fontawesome.scss";
// our project needs Solid + Brands
@import "./scss/solid.scss";
@import "./scss/brands.scss";

View File

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

View File

@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::{DateTime, Local, NaiveDateTime}; use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub type IdType = i64; pub type IdType = i64;
@ -28,6 +28,11 @@ pub struct Appointment {
pub teacher_id: IdType, pub teacher_id: IdType,
pub slot_id: IdType, pub slot_id: IdType,
} }
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PlannedAppointment {
pub teacher_id: IdType,
pub slot_id: IdType,
}
pub type AppointmentSlots = HashMap<IdType, AppointmentSlot>; pub type AppointmentSlots = HashMap<IdType, AppointmentSlot>;
@ -37,3 +42,32 @@ pub struct AppointmentSlot {
pub start_time: NaiveDateTime, pub start_time: NaiveDateTime,
pub end_time: NaiveDateTime, pub end_time: NaiveDateTime,
} }
#[derive(Debug, Deserialize, Serialize)]
pub struct OccupiedSlot {
pub id: IdType,
pub teacher_id: IdType,
pub slot_id: IdType,
}
#[derive(Debug, Deserialize, Serialize, Hash, Clone, Copy, Eq, PartialEq)]
pub struct SlotId {
pub teacher_id: IdType,
pub slot_id: IdType,
}
impl SlotId {
pub fn new(teacher: IdType, slot: IdType) -> Self {
Self {
teacher_id: teacher,
slot_id: slot,
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub enum RequestState {
Success,
Message(String),
Error,
}