Compare commits

..

4 Commits

16 changed files with 339 additions and 93 deletions

View File

@ -0,0 +1,49 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n availability.id,\n availability.userId,\n availability.startTimestamp,\n availability.endTimestamp,\n availability.comment\n FROM availability\n WHERE availability.userId = $1\n AND (availability.endtimestamp = $2\n OR availability.starttimestamp = $3)\n AND (availability.id <> $4 OR $4 IS NULL);\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "userid",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "starttimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "endtimestamp",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "comment",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int4",
"Timestamptz",
"Timestamptz",
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95"
}

View File

@ -37,6 +37,7 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-panic = "0.1.2"
rust_xlsxwriter = "0.87.0"
regex = "1.11.1"
[build-dependencies]
built = "0.7.4"

View File

@ -3,7 +3,7 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use serde::Deserialize;
use crate::filters;
use crate::models::{Availability, AvailabilityChangeset, Role, User};
use crate::models::{Role, User};
use crate::utils::DateTimeFormat::{DayMonth, DayMonthYear, DayMonthYearHourMinute, HourMinute};
pub mod delete;
@ -35,16 +35,3 @@ pub struct AvailabilityForm {
pub endtime: NaiveTime,
pub comment: Option<String>,
}
fn find_adjacend_availability<'a>(
changeset: &AvailabilityChangeset,
availability_id_to_be_updated: Option<i32>,
existing_availabilities: &'a [Availability],
) -> Option<&'a Availability> {
let existing_availability = existing_availabilities
.iter()
.filter(|a| availability_id_to_be_updated.is_none_or(|id| a.id != id))
.find(|a| a.start == changeset.time.1 || a.end == changeset.time.0);
return existing_availability;
}

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::availability::{find_adjacend_availability, AvailabilityForm},
endpoints::availability::AvailabilityForm,
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError},
utils::{self, validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/availability/new")]
@ -14,32 +13,33 @@ pub async fn post(
pool: web::Data<PgPool>,
form: web::Form<AvailabilityForm>,
) -> Result<impl Responder, ApplicationError> {
let existing_availabilities =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &form.startdate).await?;
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: None,
};
let mut changeset = AvailabilityChangeset {
time: (start, end),
comment: form.comment.clone(),
};
if let Err(e) = changeset.validate_with(&context) {
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
if let Some(a) = find_adjacend_availability(&changeset, None, &existing_availabilities) {
let (changeset_start, changeset_end) = changeset.time;
if a.end == changeset_start {
if let Some(a) =
Availability::find_adjacent_by_time_for_user(pool.get_ref(), &start, &end, user.id, None)
.await?
{
if a.end == start {
changeset.time.0 = a.start;
}
if a.start == changeset_end {
if a.start == end {
changeset.time.1 = a.end;
}

View File

@ -1,14 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{
availability::{find_adjacend_availability, AvailabilityForm},
IdPath,
},
endpoints::{availability::AvailabilityForm, IdPath},
models::{Availability, AvailabilityChangeset, AvailabilityContext, User},
utils::{self, ApplicationError},
utils::{self, validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/availability/edit/{id}")]
@ -26,39 +22,38 @@ pub async fn post(
return Err(ApplicationError::Unauthorized);
}
let existing_availabilities: Vec<Availability> =
Availability::read_by_user_and_date(pool.get_ref(), user.id, &availability.start.date())
.await?
.into_iter()
.filter(|a| a.id != availability.id)
.collect();
let context = AvailabilityContext {
existing_availabilities: existing_availabilities.clone(),
};
let start = form.startdate.and_time(form.starttime);
let end = form.enddate.and_time(form.endtime);
let context = AvailabilityContext {
pool: pool.get_ref(),
user_id: user.id,
availability_to_get_edited: Some(availability.id),
};
let mut changeset = AvailabilityChangeset {
time: (start, end),
comment: form.comment.clone(),
};
if let Err(e) = changeset.validate_with(&context) {
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::BadRequest().body(e.to_string()));
};
if let Some(a) =
find_adjacend_availability(&changeset, Some(availability.id), &existing_availabilities)
if let Some(a) = Availability::find_adjacent_by_time_for_user(
pool.get_ref(),
&start,
&end,
user.id,
Some(availability.id),
)
.await?
{
let (changeset_start, changeset_end) = changeset.time;
if a.end == changeset_start {
if a.end == start {
changeset.time.0 = a.start;
}
if a.start == changeset_end {
if a.start == end {
changeset.time.1 = a.end;
}

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{user::NewOrEditUserForm, IdPath},
models::{Area, Function, Role, User, UserChangeset},
utils::ApplicationError,
models::{Function, Role, User, UserChangeset},
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
};
#[actix_web::post("/users/edit/{id}")]
@ -34,9 +33,6 @@ pub async fn post_edit(
}
let area_id = form.area.unwrap_or(user_in_db.area_id);
if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() {
return Err(ApplicationError::Unauthorized);
}
let mut functions = Vec::with_capacity(3);
@ -67,7 +63,8 @@ pub async fn post_edit(
}
}
if let Err(e) = changeset.validate() {
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
@ -214,6 +211,6 @@ mod tests {
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status());
}
}

View File

@ -1,12 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::user::NewOrEditUserForm,
mail::Mailer,
models::{Function, Registration, Role, User, UserChangeset},
utils::ApplicationError,
utils::{validation::{AsyncValidate, DbContext}, ApplicationError},
};
#[actix_web::post("/users/new")]
@ -58,7 +57,8 @@ pub async fn post_new(
.body("email: an user already exists with the same email"));
}
if let Err(e) = changeset.validate() {
let context = DbContext::new(pool.get_ref());
if let Err(e) = changeset.validate_with_context(&context).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};

View File

@ -341,6 +341,49 @@ impl Availability {
Ok(availabilities)
}
pub async fn find_adjacent_by_time_for_user(
pool: &PgPool,
start: &NaiveDateTime,
end: &NaiveDateTime,
user: i32,
availability_to_ignore: Option<i32>,
) -> Result<Option<Availability>> {
let records = query!(
r##"
SELECT
availability.id,
availability.userId,
availability.startTimestamp,
availability.endTimestamp,
availability.comment
FROM availability
WHERE availability.userId = $1
AND (availability.endtimestamp = $2
OR availability.starttimestamp = $3)
AND (availability.id <> $4 OR $4 IS NULL);
"##,
user,
start.and_utc(),
end.and_utc(),
availability_to_ignore
)
.fetch_all(pool) // possible to find up to two availabilities (upper and lower), for now we only pick one and extend it
.await?;
let adjacent_avaialability = records.first().and_then(|r| {
Some(Availability {
id: r.id,
user_id: r.userid,
user: None,
start: r.starttimestamp.naive_utc(),
end: r.endtimestamp.naive_utc(),
comment: r.comment.clone(),
})
});
Ok(adjacent_avaialability)
}
pub async fn update(pool: &PgPool, id: i32, changeset: AvailabilityChangeset) -> Result<()> {
query!(
"UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4",

View File

@ -1,38 +1,63 @@
use chrono::{Days, NaiveDateTime};
use garde::Validate;
use sqlx::PgPool;
use crate::{END_OF_DAY, START_OF_DAY};
use crate::{
utils::validation::{
start_date_time_lies_before_end_date_time, AsyncValidate, AsyncValidateError,
},
END_OF_DAY, START_OF_DAY,
};
use super::{start_date_time_lies_before_end_date_time, Availability};
use super::Availability;
#[derive(Validate)]
#[garde(allow_unvalidated)]
#[garde(context(AvailabilityContext))]
pub struct AvailabilityChangeset {
#[garde(
custom(time_is_not_already_made_available),
custom(start_date_time_lies_before_end_date_time)
)]
pub time: (NaiveDateTime, NaiveDateTime),
pub comment: Option<String>,
}
pub struct AvailabilityContext {
pub existing_availabilities: Vec<Availability>,
pub struct AvailabilityContext<'a> {
pub pool: &'a PgPool,
pub user_id: i32,
pub availability_to_get_edited: Option<i32>,
}
impl<'a> AsyncValidate<'a> for AvailabilityChangeset {
type Context = AvailabilityContext<'a>;
async fn validate_with_context(
&self,
context: &'a Self::Context,
) -> Result<(), AsyncValidateError> {
let mut existing_availabilities =
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 {
existing_availabilities = existing_availabilities
.into_iter()
.filter(|a| a.id != existing)
.collect();
}
time_is_not_already_made_available(&self.time, &existing_availabilities)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
Ok(())
}
}
fn time_is_not_already_made_available(
value: &(NaiveDateTime, NaiveDateTime),
context: &AvailabilityContext,
) -> garde::Result {
if context.existing_availabilities.is_empty() {
existing_availabilities: &Vec<Availability>,
) -> Result<(), AsyncValidateError> {
if existing_availabilities.is_empty() {
return Ok(());
}
let free_slots = find_free_date_time_slots(&context.existing_availabilities);
let free_slots = find_free_date_time_slots(existing_availabilities);
if free_slots.is_empty() {
return Err(garde::Error::new(
return Err(AsyncValidateError::new(
"cant create a availability as every time slot is already filled",
));
}
@ -41,7 +66,7 @@ fn time_is_not_already_made_available(
let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1);
if !free_block_found_for_start || !free_block_found_for_end {
return Err(garde::Error::new(
return Err(AsyncValidateError::new(
"cant create availability as there exists already a availability with the desired time",
));
}

View File

@ -1,24 +1,44 @@
#[cfg(test)]
use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy};
use garde::Validate;
use sqlx::PgPool;
use super::{Function, Role};
use crate::utils::validation::{email_is_valid, AsyncValidate, AsyncValidateError, DbContext};
#[derive(Debug, Validate)]
use super::{Area, Function, Role};
#[derive(Debug)]
#[cfg_attr(test, derive(Dummy))]
#[garde(allow_unvalidated)]
pub struct UserChangeset {
#[cfg_attr(test, dummy(faker = "Name()"))]
pub name: String,
#[garde(email)]
#[cfg_attr(test, dummy(faker = "SafeEmail()"))]
pub email: String,
#[cfg_attr(test, dummy(expr = "Role::Staff"))]
pub role: Role,
#[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))]
pub functions: Vec<Function>,
/// check before: must exist and user can create other user for this area
#[cfg_attr(test, dummy(expr = "1"))]
pub area_id: i32,
}
impl <'a>AsyncValidate<'a> for UserChangeset {
type Context = DbContext<'a>;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError> {
email_is_valid(&self.email)?;
area_exists(context.pool, self.area_id).await?;
Ok(())
}
}
async fn area_exists(pool: &PgPool, id: i32) -> Result<(), AsyncValidateError> {
if Area::read_by_id(pool, id).await?.is_none() {
return Err(AsyncValidateError::new(
"Angegebener Bereich für Nutzer existiert nicht!",
));
}
Ok(())
}

View File

@ -1,3 +1,4 @@
mod app_customization;
mod application_error;
pub mod auth;
mod date_time_format;
@ -5,16 +6,16 @@ pub mod event_planning_template;
pub mod manage_commands;
pub mod password_change;
mod template_response_trait;
mod app_customization;
pub mod token_generation;
pub mod validation;
#[cfg(test)]
pub mod test_helper;
pub use app_customization::Customization;
pub use application_error::ApplicationError;
pub use date_time_format::DateTimeFormat;
pub use template_response_trait::TemplateResponse;
pub use app_customization::Customization;
use chrono::{NaiveDate, Utc};

View File

@ -0,0 +1,58 @@
// great inspiration taken from https://github.com/jprochazk/garde/blob/main/garde/src/rules/email.rs
use regex::Regex;
use super::AsyncValidateError;
pub fn email_is_valid(email: &str) -> Result<(), AsyncValidateError> {
if email.is_empty() {
return Err(AsyncValidateError::new("E-Mail ist leer!"));
}
let (user, domain) = email
.split_once('@')
.ok_or(AsyncValidateError::new("E-Mail enthält kein '@'!"))?;
if user.len() > 64 {
return Err(AsyncValidateError::new("Nutzerteil der E-Mail zu lang!"));
}
let user_re = Regex::new(r"(?i-u)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap();
if !user_re.is_match(user) {
return Err(AsyncValidateError::new(
"Nutzerteil der E-Mail enthält unerlaubte Zeichen.",
));
}
if domain.len() > 255 {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail ist zu lang.",
));
}
let domain_re = Regex::new(
r"(?i-u)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
)
.unwrap();
if !domain_re.is_match(domain) {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail enthält unerlaubte Zeichen!",
));
}
Ok(())
}
#[test]
pub fn email_validation_works_correctly() {
assert!(email_is_valid("abc@example.com").is_ok());
assert!(email_is_valid("admin.new@example-domain.de").is_ok());
assert!(email_is_valid("admin!new@sub.web.www.example-domain.de").is_ok());
assert!(email_is_valid("admin.domain.de").is_err());
assert!(email_is_valid("admin@web@domain.de").is_err());
assert!(email_is_valid("@domain.de").is_err());
assert!(email_is_valid("user@").is_err());
assert!(email_is_valid("").is_err());
}

View File

@ -0,0 +1,29 @@
use tracing::error;
#[derive(Debug)]
pub struct AsyncValidateError {
message: String,
}
impl std::fmt::Display for AsyncValidateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AsyncValidateError {}
impl AsyncValidateError {
pub fn new(message: &str) -> Self {
AsyncValidateError {
message: message.to_string(),
}
}
}
impl From<sqlx::Error> for AsyncValidateError {
fn from(value: sqlx::Error) -> Self {
error!(error = %value, "database error while validation input");
AsyncValidateError::new("Datenbankfehler beim Validieren!")
}
}

View File

@ -0,0 +1,32 @@
mod email;
mod error;
mod r#trait;
use chrono::NaiveDateTime;
pub use email::email_is_valid;
pub use error::AsyncValidateError;
pub use r#trait::AsyncValidate;
use sqlx::PgPool;
pub struct DbContext<'a> {
pub pool: &'a PgPool,
}
impl<'a> DbContext<'a> {
pub fn new(pool: &'a PgPool) -> Self {
Self { pool }
}
}
pub fn start_date_time_lies_before_end_date_time(
start: &NaiveDateTime,
end: &NaiveDateTime,
) -> Result<(), AsyncValidateError> {
if start >= end {
return Err(AsyncValidateError::new(
"endtime can't lie before starttime",
));
}
Ok(())
}

View File

@ -0,0 +1,8 @@
use super::AsyncValidateError;
pub trait AsyncValidate<'a> {
type Context: 'a;
async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError>;
}

View File

@ -9,7 +9,7 @@
date|fmt_date(DayMonthYear) }}</h1>
<input type="hidden" name="startdate" value="{{ date }}">
<input type="hidden" name="enddate" value="{{ date }}" id="enddate">
<input type="hidden" name="enddate" value="{{ enddate.as_ref().unwrap_or(date) }}" id="enddate">
<div class="field is-horizontal">
<div class="field-label">
@ -35,6 +35,7 @@
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(DayMonth) }}" into #ed
then set checked of #radionextday to true
end' />
</div>
</div>
@ -55,7 +56,7 @@
am selben Tag
</label>
<label class="radio ml-3">
<input type="radio" name="isovernight" {{ is_overnight|cond_show("checked")|safe }}
<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(DayMonth) }}" into #ed'>
am Tag darauf
@ -80,7 +81,7 @@
<use href="/static/feather-sprite.svg#info" />
</svg>
{% let start_time = start.unwrap_or(NaiveTime::from_hms_opt(10, 0, 0).unwrap()) %}
{% let end_time = start.unwrap_or(NaiveTime::from_hms_opt(20, 0, 0).unwrap()) %}
{% let end_time = end.unwrap_or(NaiveTime::from_hms_opt(20, 0, 0).unwrap()) %}
verfügbar von {{ date|fmt_date(DayMonth) }} <span id="st">{{ start_time|fmt_time(HourMinute) }}</span> Uhr
bis <span id="ed">{{ enddate.as_ref().unwrap_or(date)|fmt_date(DayMonth) }}</span>
<span id="et">{{ end_time|fmt_time(HourMinute) }}</span> Uhr