Compare commits

..

No commits in common. "45cf6dda103a34ec7ae9da238bb835a2a24608ad" and "f25e508bbdd5eeddc3df3d9bb9259a30d028ec98" have entirely different histories.

205 changed files with 991 additions and 1460 deletions

View File

@ -16,4 +16,3 @@ SMTP_PORT="1025"
# SMTP_LOGIN=""
# SMTP_PASSWORD=""
SMTP_TLSTYPE="none"
RUST_LOG="info,brass_web=trace,brass_db=trace"

70
Cargo.lock generated
View File

@ -775,7 +775,6 @@ dependencies = [
"anyhow",
"async-std",
"brass-config",
"chrono",
"clap",
"sqlx",
]
@ -788,19 +787,6 @@ dependencies = [
"dotenvy",
]
[[package]]
name = "brass-db"
version = "0.1.0"
dependencies = [
"chrono",
"fake",
"rand 0.9.1",
"regex",
"serde",
"sqlx",
"tracing",
]
[[package]]
name = "brass-macros"
version = "0.1.0"
@ -823,13 +809,13 @@ dependencies = [
"argon2",
"askama",
"brass-config",
"brass-db",
"brass-macros",
"built",
"change-detection",
"chrono",
"fake",
"futures-util",
"garde",
"insta",
"lettre",
"maud",
@ -904,6 +890,15 @@ dependencies = [
"bytes",
]
[[package]]
name = "castaway"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.22"
@ -1012,6 +1007,20 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@ -1634,6 +1643,31 @@ dependencies = [
"slab",
]
[[package]]
name = "garde"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a989bd2fd12136080f7825ff410d9239ce84a2a639487fc9d924ee42e2fb84f"
dependencies = [
"compact_str",
"garde_derive",
"once_cell",
"regex",
"smallvec",
]
[[package]]
name = "garde_derive"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7f0545bbbba0a37d4d445890fa5759814e0716f02417b39f6fab292193df68"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.101",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@ -3349,6 +3383,12 @@ dependencies = [
"path-slash",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stringprep"
version = "0.1.5"

View File

@ -1,5 +1,5 @@
[workspace]
members = [ "cli", "config", "db", "macros", "web", ]
members = [ "cli", "config", "macros", "web", ]
resolver = "2"
default-members = ["web"]

View File

@ -15,4 +15,3 @@ brass-config = { path = "../config" }
async-std = { version = "1.13.0", features = ["attributes"] }
sqlx = { version = "0.8.2", features = ["runtime-async-std", "postgres"] }
anyhow = "1.0.94"
chrono = "0.4.41"

View File

@ -1,9 +1,6 @@
use anyhow::Context;
use chrono::Local;
use sqlx::migrate::Migrate;
use sqlx::{migrate::Migrator, Executor};
use std::fs::File;
use std::io::Write;
use std::{
collections::HashMap,
path::{Path, PathBuf},
@ -31,14 +28,9 @@ enum Command {
Reset,
#[command(about = "Run all pending migrations on database")]
Migrate,
#[command(about = "Create a new migration")]
NewMigration { title: String },
#[command(about = "Prepare sqlx query metadata for offline compile-time verification")]
Prepare,
}
#[async_std::main]
#[allow(unused)]
async fn main() {
let cli = Cli::parse();
let config = load_config(&cli.environment).expect("Could not load config!");
@ -50,6 +42,7 @@ async fn main() {
create_db(&db_config)
.await
.expect("Failed creating database.");
migrate_db(&db_config)
.await
.expect("Failed migrating database.");
@ -58,24 +51,20 @@ async fn main() {
drop_db(&db_config)
.await
.expect("Failed dropping database.");
create_db(&db_config)
.await
.expect("Failed creating database.");
migrate_db(&db_config)
.await
.expect("Failed migrating database.");
}
},
Command::Migrate => {
migrate_db(&db_config)
.await
.expect("Failed migrating database.");
}
Command::NewMigration { title } => {
create_new_migration(&title)
.await
.expect("Failed creating new migration.");
}
Command::Prepare => prepare().await.expect("Failed preparing query metadata."),
}
}
@ -122,7 +111,13 @@ async fn migrate_db(db_config: &PgConnectOptions) -> anyhow::Result<()> {
.await
.context("Connection to database failed!")?;
let migrations_path = db_package_root()?.join("migrations");
let migrations_path = PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("This command needs to be invoked using cargo"),
)
.join("..")
.join("migrations")
.canonicalize()
.unwrap();
let migrator = Migrator::new(Path::new(&migrations_path))
.await
@ -153,56 +148,3 @@ async fn migrate_db(db_config: &PgConnectOptions) -> anyhow::Result<()> {
Ok(())
}
async fn create_new_migration(title: &str) -> anyhow::Result<()> {
let now = Local::now();
let timestamp = now.format("%Y%m%d%H%M%S");
let file_name = format!("{timestamp}_{title}.sql");
let path = db_package_root()?.join("migrations").join(&file_name);
let mut file = File::create(&path).context(format!(r#"Could not create file "{:?}""#, path))?;
file.write_all("".as_bytes())
.context(format!(r#"Could not write file "{:?}""#, path))?;
println!("Created migration {file_name}.");
Ok(())
}
async fn prepare() -> anyhow::Result<()> {
let cargo = std::env::var("CARGO")
.map_err(|_| anyhow::anyhow!("Please invoke me using Cargo, e.g.: `cargo db <ARGS>`"))
.expect("Existence of CARGO env var is asserted by calling `ensure_sqlx_cli_installed`");
let mut sqlx_prepare_command = {
let mut cmd = std::process::Command::new(&cargo);
cmd.args(["sqlx", "prepare", "--", "--all-targets", "--all-features"]);
let cmd_cwd = db_package_root().context("Error finding the root of the db package!")?;
cmd.current_dir(cmd_cwd);
cmd
};
let o = sqlx_prepare_command
.output()
.context("Could not run {cargo} sqlx prepare!")?;
if !o.status.success() {
let error = anyhow::anyhow!(String::from_utf8_lossy(&o.stdout).to_string()).context("Error generating query metadata. Are you sure the database is running and all migrations are applied?");
return Err(error);
}
println!("Query data written to db/.sqlx directory; please check this into version control.");
Ok(())
}
fn db_package_root() -> Result<PathBuf, anyhow::Error> {
Ok(PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("This command needs to be invoked using cargo"),
)
.join("..")
.join("db")
.canonicalize()?)
}

View File

@ -1,19 +0,0 @@
[package]
name = "brass-db"
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0"
authors = ["Max Hohlfeld <maxhohlfeld@posteo.de>"]
publish = false
[dependencies]
sqlx = { version = "^0.8", features = ["runtime-async-std-rustls", "postgres", "chrono"] }
chrono = { version = "0.4.33", features = ["serde", "now"] }
serde = { version = "1", features = ["derive"] }
rand = { version = "0.9", features = ["os_rng"] }
regex = "1.11.1"
tracing = "0.1.41"
fake = { version = "4", features = ["chrono", "derive"], optional = true}
[features]
test-helpers = ["dep:fake"]

View File

@ -1,31 +0,0 @@
pub mod models;
mod support;
pub mod validation;
use std::error::Error;
use std::fmt::Display;
use chrono::NaiveTime;
pub use support::{NoneToken, Token};
const START_OF_DAY: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
const END_OF_DAY: NaiveTime = NaiveTime::from_hms_opt(23, 59, 59).unwrap();
#[derive(Debug)]
pub struct UnsupportedEnumValue {
pub value: u8,
pub enum_name: &'static str,
}
impl Display for UnsupportedEnumValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unsupported enum value '{}' given for enum '{}'",
self.value, self.enum_name
)
}
}
impl Error for UnsupportedEnumValue {}

View File

@ -1,186 +0,0 @@
use chrono::NaiveDateTime;
use sqlx::PgPool;
use tracing::debug;
use crate::validation::{
AsyncValidate, AsyncValidateError, start_date_time_lies_before_end_date_time,
};
use super::{Assignment, Availability, Event, Function, Role, User};
pub struct AssignmentChangeset {
pub function: Function,
pub time: (NaiveDateTime, NaiveDateTime),
}
pub struct AssignmentContext<'a> {
pub pool: &'a PgPool,
pub user: &'a User,
pub event_id: i32,
pub availability_id: i32,
}
impl<'a> AsyncValidate<'a> for AssignmentChangeset {
type Context = AssignmentContext<'a>;
async fn validate_with_context(
&self,
context: &'a Self::Context,
) -> Result<(), crate::validation::AsyncValidateError> {
let Some(availability) =
Availability::read_by_id_including_user(context.pool, context.availability_id).await?
else {
return Err(AsyncValidateError::new(
"Angegebener Verfügbarkeit des Nutzers existiert nicht.",
));
};
let Some(event) =
Event::read_by_id_including_location(context.pool, context.event_id).await?
else {
return Err(AsyncValidateError::new(
"Angegebene Veranstaltung existiert nicht.",
));
};
user_is_admin_or_area_manager_of_event_area(context.user, &event)?;
availability_user_inside_event_area(&availability, &event)?;
available_time_fits(&self.time, &availability)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
availability_not_already_assigned(&self.time, &availability, &event, context.pool).await?;
user_of_availability_has_function(&self.function, &availability)?;
event_has_free_slot_for_function(&self.function, &availability, &event, context.pool)
.await?;
Ok(())
}
}
fn availability_user_inside_event_area(
availability: &Availability,
event: &Event,
) -> Result<(), AsyncValidateError> {
let user = availability.user.as_ref().unwrap();
let location = event.location.as_ref().unwrap();
if user.area_id != location.area_id {
return Err(AsyncValidateError::new(
"Nutzer der Verfügbarkeit ist nicht im gleichen Bereich wie der Ort der Veranstaltung.",
));
}
Ok(())
}
fn available_time_fits(
value: &(NaiveDateTime, NaiveDateTime),
availability: &Availability,
) -> Result<(), AsyncValidateError> {
if value.0 < availability.start || value.1 > availability.end {
return Err(AsyncValidateError::new(
"Die verfügbar gemachte Zeit passt nicht zu der zugewiesenen Zeit für die Veranstaltung.",
));
}
Ok(())
}
fn user_of_availability_has_function(
value: &Function,
availability: &Availability,
) -> Result<(), AsyncValidateError> {
let user_function = &availability.user.as_ref().unwrap().function;
if !user_function.contains(value) {
return Err(AsyncValidateError::new(
"Nutzer der Verfügbarkeit besitzt nicht die benötigte Funktion um für diese Position zugewiesen zu werden.",
));
}
Ok(())
}
async fn event_has_free_slot_for_function(
value: &Function,
availability: &Availability,
event: &Event,
pool: &PgPool,
) -> Result<(), AsyncValidateError> {
debug!(?event, "event parameter");
let assignments_for_event: Vec<Assignment> = Assignment::read_all_by_event(pool, event.id)
.await?
.into_iter()
.filter(|a| a.availability_id != availability.id)
.collect();
debug!(?assignments_for_event, "existing assignments for event");
let assignments_with_function = assignments_for_event
.iter()
.filter(|a| a.function == *value)
.count();
debug!(
assignments_with_function,
"amount of existing assignments for function"
);
if match *value {
Function::Posten => assignments_with_function >= event.amount_of_posten as usize,
Function::Fuehrungsassistent => {
event.voluntary_fuehrungsassistent && assignments_with_function >= 1
}
Function::Wachhabender => event.voluntary_wachhabender && assignments_with_function >= 1,
} {
return Err(AsyncValidateError::new(
"Veranstaltung hat bereits genug Zuweisungen für diese Funktion.",
));
}
Ok(())
}
async fn availability_not_already_assigned(
time: &(NaiveDateTime, NaiveDateTime),
availability: &Availability,
event: &Event,
pool: &PgPool,
) -> Result<(), AsyncValidateError> {
let list: Vec<Assignment> = Assignment::read_all_by_availability(pool, availability.id)
.await?
.into_iter()
.filter(|a| a.event_id != event.id)
.collect();
let has_start_time_during_assignment = |a: &Assignment| a.start >= time.0 && a.start <= time.1;
let has_end_time_during_assignment = |a: &Assignment| a.end >= time.0 && a.end <= time.1;
if list
.iter()
.any(|a| has_start_time_during_assignment(a) || has_end_time_during_assignment(a))
{
return Err(AsyncValidateError::new(
"Die Verfügbarkeit des Nutzers wurde bereits zu einer anderen Veranstaltung zugewiesen.",
));
}
Ok(())
}
// TODO: maybe merge with event changeset
fn user_is_admin_or_area_manager_of_event_area(
user: &User,
event: &Event,
) -> Result<(), AsyncValidateError> {
let user_is_admin = user.role == Role::Admin;
let user_is_area_manager_event_area =
user.role == Role::AreaManager && user.area_id == event.location.as_ref().unwrap().area_id;
if !user_is_admin && !user_is_area_manager_event_area {
return Err(AsyncValidateError::new(
"Du verfügst nicht über die Berechtigung, Zuweisungen zu Veranstaltungen vorzunehmen.",
));
}
Ok(())
}

View File

@ -1,248 +0,0 @@
use chrono::Days;
use chrono::NaiveDate;
use chrono::NaiveDateTime;
#[cfg(feature = "test-helpers")]
use fake::{Fake, Faker};
use sqlx::PgPool;
use crate::END_OF_DAY;
use crate::START_OF_DAY;
use crate::models::Assignment;
use crate::models::Availability;
use crate::models::Event;
use crate::models::Function;
use crate::models::Location;
use crate::models::Role;
use crate::models::User;
use crate::validation::AsyncValidate;
use crate::validation::AsyncValidateError;
use crate::validation::start_date_time_lies_before_end_date_time;
pub struct EventChangeset {
pub time: (NaiveDateTime, NaiveDateTime),
pub name: String,
pub location_id: i32,
pub voluntary_wachhabender: bool,
pub voluntary_fuehrungsassistent: bool,
pub amount_of_posten: i16,
pub clothing: i32,
pub note: Option<String>,
}
pub struct EventContext<'a> {
pub pool: &'a PgPool,
pub event: Option<i32>,
pub user: &'a User,
}
impl<'a> AsyncValidate<'a> for EventChangeset {
type Context = EventContext<'a>;
async fn validate_with_context(
&self,
context: &'a Self::Context,
) -> Result<(), AsyncValidateError> {
let Some(location) = Location::read_by_id(context.pool, self.location_id).await? else {
return Err(AsyncValidateError::new(
"Der angegebene Veranstaltungsort existiert nicht.",
));
};
user_is_admin_or_area_manager_of_event_location(context.user, &location)?;
start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?;
let mut minimum_amount_of_posten = 0_i16;
if let Some(id) = context.event {
let event = Event::read_by_id_including_location(context.pool, id)
.await?
.unwrap();
let assignments_for_event =
Assignment::read_all_by_event(context.pool, event.id).await?;
minimum_amount_of_posten = assignments_for_event
.iter()
.filter(|a| a.function == Function::Posten)
.count() as i16;
time_can_be_extended_if_edit(&self.time, &event, &assignments_for_event, context.pool)
.await?;
date_unchanged_if_edit(&self.time, &event.start.date())?;
can_unset_wachhabender(&self.voluntary_wachhabender, &assignments_for_event)?;
can_unset_fuehrungsassistent(
&self.voluntary_fuehrungsassistent,
&assignments_for_event,
)?;
if location.area_id != event.location.unwrap().area_id {
return Err(AsyncValidateError::new(
"Veranstaltungsort kann nicht zu einem Ort außerhalb des initialen Bereichs geändert werden.",
));
}
}
if !(minimum_amount_of_posten..=100).contains(&self.amount_of_posten) {
return Err(AsyncValidateError::new(
"Die Anzahl der Posten darf nicht kleiner als die Anzahl der bereits geplanten Posten und maximal 100 sein.",
));
}
Ok(())
}
}
fn user_is_admin_or_area_manager_of_event_location(
user: &User,
location: &Location,
) -> Result<(), AsyncValidateError> {
if user.role != Role::Admin
&& !(user.role == Role::AreaManager && user.area_id == location.area_id)
{
return Err(AsyncValidateError::new(
"Du verfügst nicht über die Berechtigung, diese Veranstaltung zu erstellen bzw. zu bearbeiten.",
));
}
Ok(())
}
fn date_unchanged_if_edit(
time: &(NaiveDateTime, NaiveDateTime),
date_in_db: &NaiveDate,
) -> Result<(), AsyncValidateError> {
if time.0.date() != *date_in_db {
return Err(AsyncValidateError::new("event date can't be changed"));
}
Ok(())
}
async fn time_can_be_extended_if_edit(
time: &(NaiveDateTime, NaiveDateTime),
event: &Event,
assignments_for_event: &Vec<Assignment>,
pool: &PgPool,
) -> Result<(), AsyncValidateError> {
let start = event.start.date();
let end = event.start.date().checked_add_days(Days::new(1)).unwrap();
let mut common_time = (start.and_time(START_OF_DAY), end.and_time(END_OF_DAY));
for assignment in assignments_for_event {
let availability = Availability::read_by_id(pool, assignment.availability_id)
.await?
.unwrap();
let all_assignments =
Assignment::read_all_by_availability(pool, assignment.availability_id).await?;
if all_assignments.len() == 1 {
if availability.start > common_time.0 {
common_time.0 = availability.start;
}
if availability.end < common_time.1 {
common_time.1 = availability.end;
}
} else {
let mut slots = vec![(availability.start, availability.end)];
for a in all_assignments
.iter()
.filter(|x| x.event_id != assignment.event_id)
{
let (fit, rest) = slots
.into_iter()
.partition(|s| s.0 >= a.start && s.1 <= a.end);
slots = rest;
let fit = fit.first().unwrap();
if fit.0 != a.start {
slots.push((fit.0, a.start));
}
if fit.1 != a.end {
slots.push((a.end, fit.1));
}
}
let slot = slots
.into_iter()
.find(|s| s.0 >= assignment.start && s.1 <= assignment.end)
.unwrap();
if slot.0 > common_time.0 {
common_time.0 = slot.0;
}
if slot.1 < common_time.1 {
common_time.1 = slot.1;
}
}
}
let old_start_time = common_time.0;
let new_start_time = time.0;
let old_end_time = common_time.1;
let new_end_time = time.1;
if new_start_time < old_start_time {
return Err(AsyncValidateError::new(
"starttime lies outside of available time for assigned people",
));
}
if new_end_time > old_end_time {
return Err(AsyncValidateError::new(
"endtime lies ouside of available time for assigned people",
));
}
Ok(())
}
fn can_unset_fuehrungsassistent(
fuehrungsassistent_required: &bool,
assignments_for_event: &Vec<Assignment>,
) -> Result<(), AsyncValidateError> {
if !*fuehrungsassistent_required
&& assignments_for_event
.iter()
.any(|a| a.function == Function::Fuehrungsassistent)
{
return Err(AsyncValidateError::new(
"fuehrungsassistent can't be set to not by ff, because a person is already assigned",
));
}
Ok(())
}
fn can_unset_wachhabender(
voluntary_wachhabender: &bool,
assignments_for_event: &Vec<Assignment>,
) -> Result<(), AsyncValidateError> {
if !*voluntary_wachhabender
&& assignments_for_event
.iter()
.any(|a| a.function == Function::Wachhabender)
{
return Err(AsyncValidateError::new(
"wachhabender can't be set to not by ff, because a person is already assigned",
));
}
Ok(())
}
#[cfg(feature = "test-helpers")]
impl EventChangeset {
pub fn create_for_test(start: NaiveDateTime, end: NaiveDateTime) -> EventChangeset {
let changeset = EventChangeset {
time: (start, end),
name: Faker.fake(),
location_id: 1,
voluntary_wachhabender: true,
voluntary_fuehrungsassistent: true,
amount_of_posten: 5,
clothing: 1,
note: None,
};
changeset
}
}

View File

@ -1,46 +0,0 @@
#[cfg(feature = "test-helpers")]
use fake::{Dummy, faker::internet::en::SafeEmail, faker::name::en::Name};
use sqlx::PgPool;
use super::{Area, Function, Role};
use crate::validation::{AsyncValidate, AsyncValidateError, DbContext, email_is_valid};
#[derive(Debug)]
#[cfg_attr(feature = "test-helpers", derive(Dummy))]
pub struct UserChangeset {
#[cfg_attr(feature = "test-helpers", dummy(faker = "Name()"))]
pub name: String,
#[cfg_attr(feature = "test-helpers", dummy(faker = "SafeEmail()"))]
pub email: String,
#[cfg_attr(feature = "test-helpers", dummy(expr = "Role::Staff"))]
pub role: Role,
#[cfg_attr(feature = "test-helpers", dummy(expr = "vec![Function::Posten]"))]
pub functions: Vec<Function>,
#[cfg_attr(feature = "test-helpers", 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,5 +0,0 @@
mod token_generation;
mod token_trait;
pub use token_generation::generate_token_and_expiration;
pub use token_trait::{Token, NoneToken};

View File

@ -1,18 +0,0 @@
use chrono::{NaiveDateTime, TimeDelta, Utc};
use rand::{Rng, distr::Alphanumeric, rng};
pub fn generate_token_and_expiration(
token_length_bytes: usize,
validity: TimeDelta,
) -> (String, NaiveDateTime) {
let value = std::iter::repeat(())
.map(|()| rng().sample(Alphanumeric))
.take(token_length_bytes)
.collect::<Vec<_>>();
let token = String::from_utf8(value).unwrap();
let expires = Utc::now().naive_utc() + validity;
(token, expires)
}

View File

@ -1,12 +0,0 @@
use sqlx::PgPool;
pub trait Token {
fn delete(&self, pool: &PgPool) -> impl Future<Output = Result<(), sqlx::Error>>;
}
pub struct NoneToken {}
impl Token for NoneToken {
async fn delete(&self, _pool: &PgPool) -> Result<(), sqlx::Error> {
unimplemented!()
}
}

View File

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

View File

@ -1 +0,0 @@
imports_granularity = "Crate"

View File

@ -28,15 +28,16 @@ zxcvbn = "3.1.0"
thiserror = "2"
brass-macros = { path = "../macros" }
brass-config = { path = "../config" }
brass-db = { path = "../db" }
actix-http = "3.9.0"
askama = "0.13.0"
garde = { version = "0.22.0", features = ["derive", "email"] }
maud = "0.27.0"
tracing-actix-web = "0.7.18"
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"
@ -46,5 +47,4 @@ change-detection = "1.2.0"
[dev-dependencies]
insta = { version = "1.41.1", features = ["yaml", "filters"] }
fake = { version = "4", features = ["chrono", "derive"]}
brass-db = { path = "../db", features = ["test-helpers"] }
regex = "1.11.1"

View File

@ -1,67 +0,0 @@
---
source: web/src/endpoints/assignment/post_new.rs
expression: body
snapshot_kind: text
---
<table class="table is-fullwidth">
<thead>
<tr>
<th>Name</th>
<th>Funktion</th>
<th>Zeitraum</th>
<th>Kommentar</th>
<th>Planung</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Max Mustermann</td>
<td>
<div class="tags"><span class="tag is-primary is-light">Posten</span></div>
</td>
<td>
10:00 bis 10.01.2025 20:00
</td>
<td>
</td>
<td>
<div class="dropdown">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span>als Posten geplant</span>
<svg class="icon">
<use href="/static/feather-sprite.svg#edit-2" />
</svg>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content" hx-target="closest table" hx-swap="outerHTML">
<a class="dropdown-item"
hx-post="/assignments/new?event=1&availability=1&function=1" disabled>
als Posten planen</a>
<hr class="dropdown-divider" />
<a class="dropdown-item"
hx-delete="/assignments/delete?event=1&availability=1"
class="button is-small">entplanen</a>
</div>
</div>
</div>
</td>
<td>
</td>
</tr>
</tbody>
</table>

View File

@ -1,8 +1,11 @@
use actix_web::{web, HttpResponse, Responder};
use brass_db::models::{Area, Role, User};
use sqlx::PgPool;
use crate::{endpoints::IdPath, utils::ApplicationError};
use crate::{
endpoints::IdPath,
models::{Area, Role, User},
utils::ApplicationError,
};
#[actix_web::delete("/area/delete/{id}")]
pub async fn delete(
@ -25,8 +28,10 @@ pub async fn delete(
#[cfg(test)]
mod tests {
use crate::utils::test_helper::{test_delete, DbTestContext, RequestConfig, StatusCode};
use brass_db::models::{Area, Function, Location, Role};
use crate::{
models::{Area, Function, Location, Role},
utils::test_helper::{test_delete, DbTestContext, RequestConfig, StatusCode},
};
use brass_macros::db_test;
#[db_test]

View File

@ -3,9 +3,9 @@ use sqlx::PgPool;
use crate::{
endpoints::{area::NewOrEditAreaTemplate, IdPath},
models::{Area, Role, User},
utils::{ApplicationError, TemplateResponse},
};
use brass_db::models::{Area, Role, User};
#[actix_web::get("/area/edit/{id}")]
async fn get(
@ -32,18 +32,23 @@ async fn get(
#[cfg(test)]
mod tests {
use actix_http::StatusCode;
use brass_db::models::Role;
use brass_macros::db_test;
use crate::utils::test_helper::{
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig,
use crate::{
models::{Function, Role},
utils::test_helper::{assert_snapshot, read_body, test_get, DbTestContext, RequestConfig},
};
#[db_test]
async fn produces_template_when_area_exists_and_user_is_admin(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/area/edit/1").with_role(Role::Admin);
let config = RequestConfig {
uri: "/area/edit/1".to_string(),
role: Role::Admin,
function: vec![Function::Posten],
user_area: 1,
};
let response = test_get(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::OK, response.status());
@ -56,7 +61,12 @@ mod tests {
async fn returns_unauthorized_when_user_is_not_admin(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/area/edit/1").with_role(Role::AreaManager);
let config = RequestConfig {
uri: "/area/edit/1".to_string(),
role: Role::AreaManager,
function: vec![Function::Posten],
user_area: 1,
};
let response = test_get(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
@ -66,7 +76,12 @@ mod tests {
async fn returns_not_found_when_area_does_not_exist(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/area/edit/2").with_role(Role::Admin);
let config = RequestConfig {
uri: "/area/edit/2".to_string(),
role: Role::Admin,
function: vec![Function::Posten],
user_area: 1,
};
let response = test_get(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());

View File

@ -1,9 +1,9 @@
use crate::{
endpoints::area::NewOrEditAreaTemplate,
models::{Role, User},
utils::{ApplicationError, TemplateResponse},
};
use actix_web::{web, Responder};
use brass_db::models::{Role, User};
#[actix_web::get("/area/new")]
async fn get(user: web::ReqData<User>) -> Result<impl Responder, ApplicationError> {
@ -21,13 +21,13 @@ async fn get(user: web::ReqData<User>) -> Result<impl Responder, ApplicationErro
#[cfg(test)]
mod tests {
use brass_macros::db_test;
use crate::{
models::{Function, Role},
utils::test_helper::{
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig, StatusCode,
},
};
use brass_db::models::{Function, Role};
use brass_macros::db_test;
#[db_test]
async fn produces_template_when_user_is_admin(context: &DbTestContext) {

View File

@ -7,7 +7,7 @@ pub mod delete;
use askama::Template;
use serde::Deserialize;
use brass_db::models::{Area, Role, User};
use crate::models::{Area, Role, User};
#[derive(Template)]
#[cfg_attr(not(test), template(path = "area/new_or_edit.html"))]

View File

@ -1,8 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use sqlx::PgPool;
use crate::{endpoints::IdPath, utils::ApplicationError};
use brass_db::models::{Area, Role, User};
use crate::{
endpoints::IdPath,
models::{Area, Role, User},
utils::ApplicationError,
};
use super::AreaForm;
@ -32,11 +35,11 @@ pub async fn post(
#[cfg(test)]
mod tests {
use actix_http::StatusCode;
use brass_db::models::{Area, Function, Role};
use brass_macros::db_test;
use crate::{
endpoints::area::AreaForm,
models::{Area, Function, Role},
utils::test_helper::{test_post, DbTestContext, RequestConfig},
};
@ -55,7 +58,7 @@ mod tests {
name: "Neuer Name".to_string(),
};
let response = test_post(&context.db_pool, app, &config, Some(request)).await;
let response = test_post(&context.db_pool, app, &config, request).await;
assert_eq!(StatusCode::FOUND, response.status());
@ -82,7 +85,7 @@ mod tests {
name: "Neuer Name".to_string(),
};
let response = test_post(&context.db_pool, app, &config, Some(request)).await;
let response = test_post(&context.db_pool, app, &config, request).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
@ -102,7 +105,7 @@ mod tests {
name: "Neuer Name".to_string(),
};
let response = test_post(&context.db_pool, app, &config, Some(request)).await;
let response = test_post(&context.db_pool, app, &config, request).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
}

Some files were not shown because too many files have changed in this diff Show More