feat: show crossareal in event planning

This commit is contained in:
Max Hohlfeld 2025-07-27 11:06:26 +02:00
parent 18875a9e0c
commit 760897b301
12 changed files with 395 additions and 83 deletions

View File

@ -14,12 +14,15 @@ SELECT
user_.areaId,
user_.locked,
user_.lastLogin,
user_.receiveNotifications
user_.receiveNotifications,
area.name AS areaName
FROM availability
JOIN
user_ ON availability.userId = user_.id
JOIN
area ON user_.areaId = area.id
WHERE
availability.starttimestamp::date = $1
AND user_.areaId = $2
AND (user_.areaId = $2 OR availability.crossAreal = true)
AND availability.startTimestamp <= $3
AND availability.endTimestamp >= $4;

View File

@ -18,7 +18,7 @@ impl Availability {
pub async fn create(
pool: &PgPool,
user_id: i32,
changeset: AvailabilityChangeset,
changeset: &AvailabilityChangeset,
) -> Result<()> {
query_file!(
"sql/availability/create.sql",
@ -75,7 +75,7 @@ impl Availability {
Ok(availabilities)
}
/// loads availabilities for the area and the same day as the start date and which fully lie inside the daterange
/// loads availabilities for the area or ones that are cross-area and the same day as the start date and which fully lie inside the daterange
pub async fn read_all_by_daterange_and_area_including_user_for_event_planning(
pool: &PgPool,
date_range: (NaiveDateTime, NaiveDateTime),
@ -105,7 +105,10 @@ impl Availability {
role: r.role,
function: r.function.clone(),
area_id: r.areaid,
area: None,
area: Some(Area {
id: r.areaid,
name: r.areaname.clone(),
}),
locked: r.locked,
last_login: r.lastlogin,
receive_notifications: r.receivenotifications,

View File

@ -15,8 +15,6 @@ snapshot_kind: text
</tr>
</thead>
<tbody>
<tr>
<td>Max Mustermann</td>
<td>

View File

@ -0,0 +1,210 @@
---
source: web/src/endpoints/events/get_plan.rs
expression: body
snapshot_kind: text
---
<section class="section">
<div class="container">
<h1 class="title">Eventplanung</h1>
<div class="box">
<h5 class="title is-5">Allgemeines</h5>
<div class="fixed-grid has-1-cols-mobile">
<div class="grid content">
<div class="cell is-col-span-2">
<p><b>Name:</b> Große Veranstaltung</p>
</div>
<div class="cell">
<p><b>Datum:</b> Mittwoch, 01.01.2025</p>
</div>
<div class="cell">
<p><b>Uhrzeit:</b> 12:00 Uhr - 01.01.2025 22:00 Uhr</p>
</div>
<div class="cell is-col-span-2">
<p><b>Veranstaltungsort:</b> Location</p>
</div>
<div class="cell">
<p><b>Wachhabender:</b> FF</p>
</div>
<div class="cell">
<p><b>Führungsassistent benötigt:</b> ja</p>
</div>
<div class="cell is-col-span-2">
<p><b>Anzahl der Posten:</b> 5</p>
</div>
<div class="cell is-col-span-2">
<p><b>Anzugsordnung:</b> Tuchuniform</p>
</div>
<div class="cell is-col-span-2">
<p><b>Anmerkungen:</b> </p>
</div>
</div>
</div>
</div>
<div class="box">
<h5 class="title is-5">Einteilung Personal</h5>
<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>
12:00 bis 01.01.2025 22:00
</td>
<td>
Kommentar
</td>
<td>
<div class="dropdown">
<div class="dropdown-trigger"
_="on click[.dropdown does not contain target] from elsewhere
or keyup[key is 'Escape'] from elsewhere
or click[parentElement of me does not match .is-active]
remove .is-active from .dropdown end
on click toggle .is-active on the parentElement of me">
<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>
<tr>
<td>Rudi Tester (Fremdbereich Süd)</td>
<td>
<div class="tags"><span class="tag is-primary is-light">Posten</span></div>
</td>
<td>
12:00 bis 01.01.2025 22:00
</td>
<td>
Kommentar
</td>
<td>
<div class="dropdown">
<div class="dropdown-trigger"
_="on click[.dropdown does not contain target] from elsewhere
or keyup[key is 'Escape'] from elsewhere
or click[parentElement of me does not match .is-active]
remove .is-active from .dropdown end
on click toggle .is-active on the parentElement of me">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span>Planen</span>
<svg class="icon">
<use href="/static/feather-sprite.svg#chevron-down" />
</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=2&function=1" >
als Posten planen</a>
</div>
</div>
</div>
</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>
<div class="box">
<h5 class="title is-5">Einteilung Fahrzeuge</h5>
<div id="vehicle-plan">
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<div class="tags has-addons">
<span class="tag is-link"> 11.49.1 - HLF FF Ost</span>
<button class="tag is-delete" hx-delete="/vehicleassignments/delete?event=1&vehicle=1"
hx-target="#vehicle-plan" />
</div>
</div>
</div>
<div class="field">
<label class="label">Fahrzeug hinzufügen</label>
<div class="control">
<div class="select">
<select name="vehicle" hx-post="/vehicleassignments/new?event=1" hx-include="this"
hx-target="#vehicle-plan">
<option selected></option>
<option value="2">11.19.1 - MTW FF Ost</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="control">
<a class="button is-link is-light" hx-boost="true" href="/calendar?date=2025-01-01">
<svg class="icon">
<use href="/static/feather-sprite.svg#arrow-left" />
</svg>
<span>Zurück</span>
</a>
</div>
</div>
</section>

View File

@ -98,7 +98,7 @@ mod tests {
),
comment: None,
};
Availability::create(pool, 1, new_availability).await?;
Availability::create(pool, 1, &new_availability).await?;
let new_assignment = AssignmentChangeset {
function: Function::Posten,

View File

@ -117,7 +117,7 @@ mod tests {
Availability::create(
pool,
1,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (start, end),
comment: None,
},
@ -335,7 +335,7 @@ mod tests {
Availability::create(
&context.db_pool,
1,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (start, end),
comment: None,
},
@ -346,7 +346,7 @@ mod tests {
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (start, end),
comment: None,
},

View File

@ -34,21 +34,23 @@ mod tests {
use brass_macros::db_test;
use chrono::NaiveDateTime;
use fake::{Fake, Faker};
use sqlx::PgPool;
use crate::utils::test_helper::{
create_test_login_user, test_delete, DbTestContext, NaiveDateTimeExt, RequestConfig,
};
#[db_test]
async fn deletes_when_availability_is_from_user_itself(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/availability/delete/1");
create_test_login_user(&context.db_pool, &config).await;
async fn arrange(pool: &PgPool, availability_id: i32, area_id: i32) -> Result<(), sqlx::Error> {
Area::create(pool, "Süd").await?;
let mut changeset: UserChangeset = Faker.fake();
changeset.area_id = area_id;
User::create(pool, &changeset).await?;
Availability::create(
&context.db_pool,
1,
AvailabilityChangeset {
pool,
availability_id,
&AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
@ -56,8 +58,18 @@ mod tests {
comment: None,
},
)
.await
.unwrap();
.await?;
Ok(())
}
#[db_test]
async fn deletes_when_availability_is_from_user_itself(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/availability/delete/1");
create_test_login_user(&context.db_pool, &config).await;
arrange(&context.db_pool, 1, 1).await.unwrap();
let response = test_delete(&app, &config).await;
assert_eq!(StatusCode::OK, response.status());
@ -73,22 +85,7 @@ mod tests {
let app = context.app().await;
let config = RequestConfig::new("/availability/delete/1").with_role(Role::AreaManager);
create_test_login_user(&context.db_pool, &config).await;
User::create(&context.db_pool, &Faker.fake()).await.unwrap();
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
),
comment: None,
},
)
.await
.unwrap();
arrange(&context.db_pool, 2, 1).await.unwrap();
let response = test_delete(&app, &config).await;
assert_eq!(StatusCode::OK, response.status());
@ -104,26 +101,7 @@ mod tests {
let app = context.app().await;
let config = RequestConfig::new("/availability/delete/1").with_role(Role::AreaManager);
create_test_login_user(&context.db_pool, &config).await;
Area::create(&context.db_pool, "Süd").await.unwrap();
let mut changeset: UserChangeset = Faker.fake();
changeset.area_id = 2;
User::create(&context.db_pool, &changeset).await.unwrap();
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
),
comment: None,
},
)
.await
.unwrap();
arrange(&context.db_pool, 2, 2).await.unwrap();
let response = test_delete(&app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
@ -139,22 +117,7 @@ mod tests {
let app = context.app().await;
let config = RequestConfig::new("/availability/delete/1").with_role(Role::Admin);
create_test_login_user(&context.db_pool, &config).await;
User::create(&context.db_pool, &Faker.fake()).await.unwrap();
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
),
comment: None,
},
)
.await
.unwrap();
arrange(&context.db_pool, 2, 1).await.unwrap();
let response = test_delete(&app, &config).await;
assert_eq!(StatusCode::OK, response.status());

View File

@ -66,7 +66,7 @@ pub async fn post(
Availability::update(pool.get_ref(), a.id, changeset).await?;
} else {
Availability::create(pool.get_ref(), user_for_availability, changeset).await?;
Availability::create(pool.get_ref(), user_for_availability, &changeset).await?;
}
let url = utils::get_return_url_for_date(&form.startdate);

View File

@ -106,7 +106,7 @@ mod tests {
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
@ -169,7 +169,7 @@ mod tests {
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
@ -216,7 +216,7 @@ mod tests {
Availability::create(
&context.db_pool,
1,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),
@ -267,7 +267,7 @@ mod tests {
Availability::create(
&context.db_pool,
2,
AvailabilityChangeset {
&AvailabilityChangeset {
time: (
NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 10, 0, 0).unwrap(),
NaiveDateTime::from_ymd_and_hms(2025, 02, 01, 10, 0, 0).unwrap(),

View File

@ -18,7 +18,12 @@ use crate::{
use brass_db::models::{Availability, AvailabilityAssignmentState, Event, Role, User, Vehicle};
#[derive(Template)]
#[template(path = "events/plan.html")]
#[cfg_attr(not(test), template(path = "events/plan.html"))]
#[cfg_attr(
test,
template(path = "events/plan.html", block = "content"),
allow(dead_code)
)]
pub struct PlanEventTemplate {
user: User,
event: Event,
@ -72,3 +77,132 @@ pub async fn get(
Ok(template.to_response()?)
}
#[cfg(test)]
mod tests {
use actix_http::StatusCode;
use brass_macros::db_test;
use chrono::NaiveDateTime;
use fake::{Fake, Faker};
use sqlx::PgPool;
use crate::utils::test_helper::{
assert_snapshot, create_test_login_user, test_get, DbTestContext, NaiveDateTimeExt,
RequestConfig, ServiceResponseExt,
};
use brass_db::models::{
Area, Assignment, AssignmentChangeset, Availability, AvailabilityChangeset, Event,
EventChangeset, Function, Location, Role, User, UserChangeset, Vehicle, VehicleAssignment,
};
#[db_test]
async fn generates_template_for_admin(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/events/1/plan").with_role(Role::Admin);
arrange(&context.db_pool).await.unwrap();
create_test_login_user(&context.db_pool, &config).await;
let (status, body) = test_get(app, &config).await.into_status_and_body().await;
assert_eq!(StatusCode::OK, status);
assert_snapshot!(body);
}
#[db_test]
async fn returns_ok_for_area_manager_of_same_area(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/events/1/plan").with_role(Role::AreaManager);
arrange(&context.db_pool).await.unwrap();
create_test_login_user(&context.db_pool, &config).await;
let response = test_get(app, &config).await;
assert_eq!(StatusCode::OK, response.status());
}
#[db_test]
async fn returns_unauthorized_for_area_manager_of_different_area(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/events/1/plan")
.with_role(Role::AreaManager)
.with_user_area(2);
arrange(&context.db_pool).await.unwrap();
create_test_login_user(&context.db_pool, &config).await;
let response = test_get(app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn returns_unauthorized_for_user(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/events/1/plan");
arrange(&context.db_pool).await.unwrap();
create_test_login_user(&context.db_pool, &config).await;
let response = test_get(app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status());
}
#[db_test]
async fn returns_not_found_when_event_doesnt_exist(context: &DbTestContext) {
let app = context.app().await;
let config = RequestConfig::new("/events/1/plan").with_role(Role::AreaManager);
create_test_login_user(&context.db_pool, &config).await;
let response = test_get(app, &config).await;
assert_eq!(StatusCode::NOT_FOUND, response.status());
}
async fn arrange(pool: &PgPool) -> anyhow::Result<()> {
Location::create(pool, "Location", 1).await?;
Area::create(pool, "Süd").await.unwrap();
let mut user_changeset: UserChangeset = Faker.fake();
user_changeset.name = "Max Mustermann".to_string();
User::create(pool, &user_changeset).await?;
let mut other_area_user: UserChangeset = Faker.fake();
other_area_user.area_id = 2;
other_area_user.name = "Rudi Tester".to_string();
User::create(pool, &other_area_user).await?;
let start = NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 12, 0, 0).unwrap();
let end = NaiveDateTime::from_ymd_and_hms(2025, 01, 01, 22, 0, 0).unwrap();
let mut new_event = EventChangeset::create_for_test(start, end);
new_event.name = "Große Veranstaltung".to_string();
Event::create(pool, new_event).await?;
let new_availability = AvailabilityChangeset {
time: (start, end),
comment: Some("Kommentar".to_string()),
};
Availability::create(pool, 1, &new_availability).await?;
Availability::create(pool, 2, &new_availability).await?;
Availability::update_cross_areal(pool, 2, true)
.await
.unwrap();
let new_assignment = AssignmentChangeset {
function: Function::Posten,
time: (start, end),
};
Assignment::create(pool, 1, 1, new_assignment).await?;
Vehicle::create(pool, "11.49.1", "HLF FF Ost")
.await
.unwrap();
Vehicle::create(pool, "11.19.1", "MTW FF Ost")
.await
.unwrap();
VehicleAssignment::create(pool, 1, 1, start, end)
.await
.unwrap();
Ok(())
}
}

View File

@ -32,7 +32,7 @@
</div>
<div class="cell">
<p><b>Führungsassistent benötigt:</b> {% if event.fuehrungsassistent_required %}ja{% else %}nein{% endif %}
<p><b>Führungsassistent benötigt:</b> {% if event.fuehrungsassistent_required %}ja{% else %}nein{% endif -%}
</p>
</div>

View File

@ -19,10 +19,11 @@
</tr>
</thead>
<tbody>
{% for (availability, status) in availabilities %}
{% let u = availability.user.as_ref().unwrap() %}
{% for (availability, status) in availabilities -%}
{%- let u = availability.user.as_ref().unwrap() -%}
<tr>
<td>{{ u.name }}</td>
<td>{{ u.name }}{% if u.area_id != event.location.as_ref().unwrap().area_id %} (Fremdbereich {{ u.area.as_ref().unwrap().name
}}){% endif %}</td>
<td>
{{ u.function|show_tree|safe }}
</td>