feat: validation of new availability

This commit is contained in:
Max Hohlfeld 2025-07-03 21:16:43 +02:00
parent b42540ac2f
commit d1e067407b
6 changed files with 73 additions and 31 deletions

View File

@ -2,9 +2,11 @@ use chrono::{Days, NaiveDateTime};
use sqlx::PgPool;
use super::Availability;
use crate::{validation::{
start_date_time_lies_before_end_date_time, AsyncValidate, AsyncValidateError
}, END_OF_DAY, START_OF_DAY};
use crate::{
END_OF_DAY, START_OF_DAY,
models::Assignment,
validation::{AsyncValidate, AsyncValidateError, start_date_time_lies_before_end_date_time},
};
pub struct AvailabilityChangeset {
pub time: (NaiveDateTime, NaiveDateTime),
@ -14,7 +16,7 @@ pub struct AvailabilityChangeset {
pub struct AvailabilityContext<'a> {
pub pool: &'a PgPool,
pub user_id: i32,
pub availability_to_get_edited: Option<i32>,
pub availability: Option<i32>,
}
impl<'a> AsyncValidate<'a> for AvailabilityChangeset {
@ -28,48 +30,69 @@ impl<'a> AsyncValidate<'a> for AvailabilityChangeset {
Availability::read_by_user_and_date(context.pool, context.user_id, &self.time.0.date())
.await?;
if let Some(existing) = context.availability_to_get_edited {
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
if let Some(availability) = context.availability {
existing_availabilities = existing_availabilities
.into_iter()
.filter(|a| a.id != existing)
.filter(|a| a.id != availability)
.collect();
time_is_not_already_assigned(&self.time, availability, context.pool).await?;
}
time_is_not_already_made_available(&self.time, &existing_availabilities)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
if !existing_availabilities.is_empty() {
time_is_not_already_made_available(&self.time, &existing_availabilities)?;
}
Ok(())
}
}
fn time_is_not_already_made_available(
value: &(NaiveDateTime, NaiveDateTime),
(start, end): &(NaiveDateTime, NaiveDateTime),
existing_availabilities: &Vec<Availability>,
) -> Result<(), AsyncValidateError> {
if existing_availabilities.is_empty() {
return Ok(());
}
let free_slots = find_free_date_time_slots(existing_availabilities);
if free_slots.is_empty() {
return Err(AsyncValidateError::new(
"cant create a availability as every time slot is already filled",
"Verfügbarkeit kann nicht erstellt werden, da bereits alle Zeiträume verfügbar gemacht wurden.",
));
}
let free_block_found_for_start = free_slots.iter().any(|s| s.0 <= value.0 && s.1 >= value.0);
let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1);
let free_block_found_for_start = free_slots.iter().any(|s| s.0 <= *start && s.1 >= *start);
let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= *end && s.1 >= *end);
let is_already_present_as_is = existing_availabilities
.iter()
.any(|a| a.start == *start && a.end == a.end);
if !free_block_found_for_start || !free_block_found_for_end {
if !free_block_found_for_start || !free_block_found_for_end || is_already_present_as_is {
return Err(AsyncValidateError::new(
"cant create availability as there exists already a availability with the desired time",
"Verfügbarkeit kann nicht erstellt werden, da eine vorhandene Verfügbarkeit überschnitten würde.",
));
}
Ok(())
}
async fn time_is_not_already_assigned(
(start, end): &(NaiveDateTime, NaiveDateTime),
availability: i32,
pool: &PgPool,
) -> Result<(), AsyncValidateError> {
let existing_assignments = Assignment::read_all_by_availability(pool, availability).await?;
for a in existing_assignments {
if a.start < *start || a.end > *end {
return Err(AsyncValidateError::new(
"Verfügbarkeitszeit kann nicht verkleinert werden, da bereits eine Planung für diese Zeit existiert.",
));
}
}
Ok(())
}
pub fn find_free_date_time_slots(
availabilities: &[Availability],
) -> Vec<(NaiveDateTime, NaiveDateTime)> {

View File

@ -5,8 +5,8 @@ mod validation_trait;
use chrono::NaiveDateTime;
pub use email::email_is_valid;
pub use error::AsyncValidateError;
pub use validation_trait::AsyncValidate;
use sqlx::PgPool;
pub use validation_trait::AsyncValidate;
pub struct DbContext<'a> {
pub pool: &'a PgPool,
@ -24,7 +24,7 @@ pub fn start_date_time_lies_before_end_date_time(
) -> Result<(), AsyncValidateError> {
if start >= end {
return Err(AsyncValidateError::new(
"endtime can't lie before starttime",
"Ende kann nicht vor dem Beginn liegen.",
));
}

View File

@ -1,4 +1,5 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use maud::html;
use sqlx::PgPool;
use crate::{
@ -22,7 +23,7 @@ pub async fn post(
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: None,
availability: None,
};
let mut changeset = AvailabilityChangeset {
@ -31,7 +32,13 @@ pub async fn post(
};
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
let error_message = html! {
svg class="icon is-small" {
use href="/static/feather-sprite.svg#alert-triangle" {}
}
" " (e)
};
return Ok(HttpResponse::UnprocessableEntity().body(error_message.into_string()));
};
if let Some(a) =

View File

@ -1,4 +1,5 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use maud::html;
use sqlx::PgPool;
use crate::{
@ -31,7 +32,7 @@ pub async fn post(
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: Some(availability.id),
availability: Some(availability.id),
};
let mut changeset = AvailabilityChangeset {
@ -40,7 +41,13 @@ pub async fn post(
};
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
let error_message = html! {
svg class="icon is-small" {
use href="/static/feather-sprite.svg#alert-triangle" {}
}
" " (e)
};
return Ok(HttpResponse::UnprocessableEntity().body(error_message.into_string()));
};
if let Some(a) = Availability::find_adjacent_by_time_for_user(

View File

@ -4,7 +4,8 @@
<section class="section">
<div class="container">
{% let is_edit = id.is_some() %}
<form method="post" action="/availability/{% if is_edit %}edit/{{ id.unwrap() }}{% else %}new{% endif %}">
<form hx-post="/availability/{% if is_edit %}edit/{{ id.unwrap() }}{% else %}new{% endif %}" hx-target="body"
hx-target-422="#error">
<h1 class="title">{% if is_edit %}Bearbeite{% else %}Neue{% endif %} Vefügbarkeit für {{
date|fmt_date(WeekdayDayMonthYear) }}</h1>
@ -18,7 +19,7 @@
<div class="field-body">
<div class="field">
<input class="input" type="time" name="starttime" required {{ start|insert_time_value|safe }}
_="on change put the value of me into #st">
_="on change put the value of me into #st then put '' into #error">
{% if slot_suggestions.len() > 0 %}
<p class="help">noch mögliche Zeiträume:</p>
<div class="tags help">
@ -32,7 +33,7 @@
</div>
<div class="field">
<input class="input" type="time" name="endtime" required {{ end|insert_time_value|safe }}
_='on change put the value of me into #et
_='on change put the value of me into #et then put "" into #error
then if (value of the previous <input/>) is greater than (value of me)
then set the value of #enddate to "{{ datetomorrow }}"
then put "{{ datetomorrow|fmt_date(WeekdayDayMonth) }}" into #ed
@ -53,13 +54,15 @@
<label class="radio">
<input type="radio" name="isovernight" {{ is_overnight|invert|ref|cond_show("checked")|safe }}
_='on click set the value of #enddate to "{{ date }}"
then put "{{ date|fmt_date(WeekdayDayMonth) }}" into #ed'>
then put "{{ date|fmt_date(WeekdayDayMonth) }}" into #ed
then put "" into #error'>
am selben Tag
</label>
<label class="radio ml-3">
<input type="radio" id="radionextday" name="isovernight" {{ is_overnight|cond_show("checked")|safe }}
_='on click set the value of #enddate to "{{ datetomorrow }}"
then put "{{ datetomorrow|fmt_date(WeekdayDayMonth) }}" into #ed'>
then put "{{ datetomorrow|fmt_date(WeekdayDayMonth) }}" into #ed
then put "" into #error'>
am Tag darauf
</label>
</div>
@ -90,6 +93,7 @@
<span id="et">{{ end_time|fmt_time(HourMinute) }}</span>
Uhr
</p>
<p id="error" class="help is-danger"></p>
</div>
</div>
</div>
@ -99,7 +103,7 @@
<div class="field-body">
<div class="field is-grouped">
<div class="control">
<button class="button is-success">
<button class="button is-success" _="on click put '' into #error">
<svg class="icon">
<use href="/static/feather-sprite.svg#check-circle" />
</svg>

View File

@ -3,7 +3,8 @@
{% block content %}
<section class="section">
<div class="container">
<form hx-post="/users/{% if let Some(id) = id %}edit/{{ id }}{% else %}new{% endif %}" hx-target-422="find p">
<form hx-post="/users/{% if let Some(id) = id %}edit/{{ id }}{% else %}new{% endif %}" hx-target="body"
hx-target-422="find p">
<h1 class="title">
{% if let Some(name) = name %}Nutzer '{{ name }}' bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}
</h1>