diff --git a/Rocket.toml b/Rocket.toml index cc93d2b..88a9b6b 100644 --- a/Rocket.toml +++ b/Rocket.toml @@ -2,7 +2,7 @@ secret_key = "/NtVGizglEoyoxBLzsRDWTy4oAG1qDw4J4O+CWJSv+fypD7W9sam8hUY4j90EZsbZk8wEradS5zBoWtWKi3k8w==" rss_key = "rss-key-for-ci" limits = { file = "10 MiB", data-form = "10 MiB"} -smtp_pw = "8kIjlLH79Ky6D3j" +smtp_pw = "my-smtp-password" usage_log_path = "./usage.txt" -openweathermap_key = "c8dab8f91b5b815d76e9879cbaecd8d5" +openweathermap_key = "openweather-key" wordpress_key = "pw-to-allow-sending-notifications" diff --git a/doc/nextcloud-notes.md b/doc/nextcloud-notes.md new file mode 100644 index 0000000..b820e4b --- /dev/null +++ b/doc/nextcloud-notes.md @@ -0,0 +1,94 @@ +# Nextcloud integration + +- Based on [this plugin](https://github.com/nextcloud/user_external) +- Install that plugin via web +- Connect to server, enter nextcloud-docker-image: `docker exec -it nextcloud-aio-nextcloud bash` +- Adapt `/var/www/html/custom_apps/user_external/lib/BasicAuth.php` to switch from BasicAuth to RowtAuth: +```php + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OCA\UserExternal; + +class BasicAuth extends Base { + private $authUrl; + + public function __construct($authUrl) { + parent::__construct($authUrl); + $this->authUrl = $authUrl; + } + + /** + * Check if the password is correct without logging in the user + * + * @param string $uid The username + * @param string $password The password + * + * @return true/false + */ + public function checkPassword($uid, $password) { + // Prepare POST data with credentials + $postData = http_build_query([ + 'name' => $uid, + 'password' => $password + ]); + + // Create context with POST method + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => 'Content-Type: application/x-www-form-urlencoded', + 'content' => $postData, + 'follow_location' => 0 + ] + ]); + + // Get the content of the response + $content = @file_get_contents($this->authUrl, false, $context); + + if ($content === false) { + \OC::$server->getLogger()->error( + 'ERROR: Failed to get content from Auth Url: '.$this->authUrl, + ['app' => 'user_external'] + ); + return false; + } + + // Check if the content is "SUCC" + if (trim($content) === "SUCC") { + $this->storeUser($uid); + return $uid; + } + + return false; + } +} +``` +- In `/var/www/html/config/config.php` add this: +``` + 'user_backends' => + array ( + 0 => + array ( + 'class' => '\\OCA\\UserExternal\\BasicAuth', + 'arguments' => + array ( + 0 => 'https://app.rudernlinz.at/nxauth', + ), + ), + ), +``` +- In `/var/www/html/config/config.php` add this `'skeletondirectory' => '',` to disable default folders for new users +- To automatically add users to a group (e.g. `vorstand`), use the `Auto Groups` plugin +- Shared folders are not shared with new members due to [this bug](https://github.com/nextcloud/server/issues/25062#issuecomment-766445043) + - Find DB config: `docker exec nextcloud-aio-database env | grep POSTGRES` + - Workaround: Connect to docker-db: `docker exec -it nextcloud-aio-database bash` + - Connect to db: `psql -U nextcloud -d nextcloud_database` + - (with `\l` you see all dbs) + - Connect to nextcloud db: `\c nextcloud_database` + - Do query from issue: `UPDATE oc_share SET accepted = 1 WHERE share_type = 1;` diff --git a/frontend/tests/cox.spec.ts b/frontend/tests/cox.spec.ts index 1b4717b..d238e96 100644 --- a/frontend/tests/cox.spec.ts +++ b/frontend/tests/cox.spec.ts @@ -120,18 +120,69 @@ test.describe("cox can edit trips", () => { }); test("call off trip", async () => { - await sharedPage.goto("/"); + // Someone registers... + await sharedPage.goto("/auth/logout"); + await sharedPage.goto("/auth"); + await sharedPage.getByPlaceholder("Name").click(); + await sharedPage.getByPlaceholder("Name").fill("rower"); + await sharedPage.getByPlaceholder("Name").press("Tab"); + await sharedPage.getByPlaceholder("Passwort").fill("rower"); + await sharedPage.getByPlaceholder("Passwort").press("Enter"); + + await sharedPage.goto("/planned"); + await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click(); + + + // Login as cox again + await sharedPage.goto("/auth/logout"); + await sharedPage.goto("/auth"); + await sharedPage.getByPlaceholder("Name").click(); + await sharedPage.getByPlaceholder("Name").fill("cox"); + await sharedPage.getByPlaceholder("Name").press("Tab"); + await sharedPage.getByPlaceholder("Passwort").fill("cox"); + await sharedPage.getByPlaceholder("Passwort").press("Enter"); + + await sharedPage.goto("/planned"); + + + // ... now I can cancel trip await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await expect(sharedPage.locator("#sidebar")).toContainText( - "Freie Plätze: 3", - ); - await sharedPage.getByRole("spinbutton").click(); - await sharedPage.getByRole("spinbutton").fill("0"); - await sharedPage.getByRole("button", { name: "Speichern" }).click(); + await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click(); await expect(sharedPage.locator("body")).toContainText( "Ausfahrt erfolgreich aktualisiert.", ); await expect(sharedPage.locator("body")).toContainText("(Absage cox)"); + + + // Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails + await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); + await sharedPage.getByRole("spinbutton").click(); + await sharedPage.getByRole("spinbutton").fill("3"); + await sharedPage.getByRole("button", { name: "Speichern" }).click(); + + + + // deregistering + await sharedPage.goto("/auth/logout"); + await sharedPage.goto("/auth"); + await sharedPage.getByPlaceholder("Name").click(); + await sharedPage.getByPlaceholder("Name").fill("rower"); + await sharedPage.getByPlaceholder("Name").press("Tab"); + await sharedPage.getByPlaceholder("Passwort").fill("rower"); + await sharedPage.getByPlaceholder("Passwort").press("Enter"); + + await sharedPage.goto("/planned"); + await sharedPage.getByRole('link', { name: 'Abmelden' }).click(); + + + // now cox can delete trip again in afterAll + await sharedPage.goto("/auth/logout"); + await sharedPage.goto("/auth"); + await sharedPage.getByPlaceholder("Name").click(); + await sharedPage.getByPlaceholder("Name").fill("cox"); + await sharedPage.getByPlaceholder("Name").press("Tab"); + await sharedPage.getByPlaceholder("Passwort").fill("cox"); + await sharedPage.getByPlaceholder("Passwort").press("Enter"); }); test.afterAll(async () => { diff --git a/src/model/event.rs b/src/model/event.rs index 71a2c3c..1ff1141 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -34,11 +34,13 @@ pub struct Event { } #[derive(Serialize, Debug)] -pub struct EventWithUserAndTriptype { +pub struct EventWithDetails { #[serde(flatten)] pub event: Event, trip_type: Option, + tripdetails: TripDetails, cox_needed: bool, + cancelled: bool, cox: Vec, rower: Vec, } @@ -116,6 +118,12 @@ pub struct EventUpdate<'a> { pub trip_type_id: Option, } +impl EventUpdate<'_> { + fn cancelled(&self) -> bool { + self.max_people == -1 + } +} + impl Event { pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { sqlx::query_as!( @@ -134,16 +142,13 @@ WHERE planned_event.id like ? .ok() } - pub async fn get_pinned_for_day( - db: &SqlitePool, - day: NaiveDate, - ) -> Vec { + pub async fn get_pinned_for_day(db: &SqlitePool, day: NaiveDate) -> Vec { let mut events = Self::get_for_day(db, day).await; events.retain(|e| e.event.always_show); events } - pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec { + pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec { let day = format!("{day}"); let events = sqlx::query_as!( Event, @@ -164,10 +169,15 @@ WHERE day=?", if let Some(trip_type_id) = event.trip_type_id { trip_type = TripType::find_by_id(db, trip_type_id).await; } - ret.push(EventWithUserAndTriptype { + let tripdetails = TripDetails::find_by_id(db, event.trip_details_id) + .await + .expect("db constraints"); + ret.push(EventWithDetails { cox_needed: event.planned_amount_cox > cox.len() as i64, cox, rower: Registration::all_rower(db, event.trip_details_id).await, + cancelled: tripdetails.cancelled(), + tripdetails, event, trip_type, }); @@ -313,7 +323,7 @@ WHERE trip_details.id=? .unwrap(); //Okay, as planned_event can only be created with proper DB backing let tripdetails = self.trip_details(db).await; - let was_already_cancelled = tripdetails.max_people == 0; + let was_already_cancelled = tripdetails.cancelled(); sqlx::query!( "UPDATE trip_details SET max_people = ?, notes = ?, always_show = ?, is_locked = ?, trip_type_id = ? WHERE id = ?", @@ -338,7 +348,7 @@ WHERE trip_details.id=? .await; } - if update.max_people == 0 && !was_already_cancelled { + if update.cancelled() && !was_already_cancelled { let coxes = Registration::all_cox(db, self.id).await; for user in coxes { if let Some(user) = User::find_by_name(db, &user.name).await { @@ -387,7 +397,7 @@ WHERE trip_details.id=? } } } - if update.max_people > 0 && was_already_cancelled { + if !update.cancelled() && was_already_cancelled { Notification::delete_by_action( db, &format!("remove_user_trip_with_trip_details_id:{}", tripdetails.id), @@ -425,7 +435,7 @@ WHERE trip_details.id=? } pub fn is_cancelled(&self) -> bool { - self.max_people == 0 + self.max_people == -1 } pub async fn get_ics_feed(db: &SqlitePool) -> String { @@ -442,10 +452,16 @@ WHERE trip_details.id=? pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event { let mut vevent = ics::Event::new(format!("event-{}@ruad.at", self.id), "19900101T180000"); + let time_str = self.planned_starting_time.replace(':', ""); + let formatted_time = if time_str.len() == 3 { + format!("0{}", time_str) + } else { + time_str.clone() // TODO: remove again + }; vevent.push(DtStart::new(format!( "{}T{}00", self.day.replace('-', ""), - self.planned_starting_time.replace(':', "") + formatted_time ))); let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M") diff --git a/src/model/mod.rs b/src/model/mod.rs index 0bf6094..0e5261b 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -6,8 +6,8 @@ use waterlevel::WaterlevelDay; use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD; use self::{ - event::{Event, EventWithUserAndTriptype}, - trip::{Trip, TripWithUserAndType}, + event::{Event, EventWithDetails}, + trip::{Trip, TripWithDetails}, waterlevel::Waterlevel, weather::Weather, }; @@ -28,8 +28,8 @@ pub mod weather; #[derive(Serialize, Debug)] pub struct Day { day: NaiveDate, - events: Vec, - trips: Vec, + events: Vec, + trips: Vec, is_pinned: bool, regular_sees_this_day: bool, max_waterlevel: Option, diff --git a/src/model/notification.rs b/src/model/notification.rs index f72eb49..3c860df 100644 --- a/src/model/notification.rs +++ b/src/model/notification.rs @@ -284,7 +284,7 @@ mod test { let cancel_update = EventUpdate { name: &event.name, planned_amount_cox: event.planned_amount_cox as i32, - max_people: 0, + max_people: -1, notes: event.notes.as_deref(), always_show: event.always_show, is_locked: event.is_locked, diff --git a/src/model/trip.rs b/src/model/trip.rs index e69dfac..8839b2f 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -30,11 +30,12 @@ pub struct Trip { } #[derive(Serialize, Debug)] -pub struct TripWithUserAndType { +pub struct TripWithDetails { #[serde(flatten)] pub trip: Trip, pub rower: Vec, trip_type: Option, + cancelled: bool, } pub struct TripUpdate<'a> { @@ -46,7 +47,13 @@ pub struct TripUpdate<'a> { pub is_locked: bool, } -impl TripWithUserAndType { +impl<'a> TripUpdate<'a> { + fn cancelled(&self) -> bool { + self.max_people == -1 + } +} + +impl TripWithDetails { pub async fn from(db: &SqlitePool, trip: Trip) -> Self { let mut trip_type = None; if let Some(trip_type_id) = trip.trip_type_id { @@ -54,8 +61,9 @@ impl TripWithUserAndType { } Self { rower: Registration::all_rower(db, trip.trip_details_id.unwrap()).await, - trip, trip_type, + cancelled: trip.is_cancelled(), + trip, } } } @@ -129,10 +137,17 @@ WHERE trip_details.id=? pub(crate) async fn get_vevent(self, user: &User) -> ics::Event { let mut vevent = ics::Event::new(format!("trip-{}@ruad.at", self.id), "19900101T180000"); + let time_str = self.planned_starting_time.replace(':', ""); + let formatted_time = if time_str.len() == 3 { + format!("0{}", time_str) + } else { + time_str + }; + vevent.push(DtStart::new(format!( "{}T{}00", self.day.replace('-', ""), - self.planned_starting_time.replace(':', "") + formatted_time ))); let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M") @@ -234,7 +249,7 @@ WHERE trip.id=? return Err(CoxHelpError::DetailsLocked); } - if event.max_people == 0 { + if event.is_cancelled() { return Err(CoxHelpError::CanceledEvent); } @@ -251,12 +266,12 @@ WHERE trip.id=? } } - pub async fn get_for_today(db: &SqlitePool) -> Vec { + pub async fn get_for_today(db: &SqlitePool) -> Vec { let today = Local::now().date_naive(); Self::get_for_day(db, today).await } - pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec { + pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec { let day = format!("{day}"); let trips = sqlx::query_as!( Trip, @@ -275,7 +290,7 @@ WHERE day=? let mut ret = Vec::new(); for trip in trips { - ret.push(TripWithUserAndType::from(db, trip).await); + ret.push(TripWithDetails::from(db, trip).await); } ret } @@ -298,9 +313,9 @@ WHERE day=? }; let tripdetails = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); - let was_already_cancelled = tripdetails.max_people == 0; + let was_already_cancelled = tripdetails.cancelled(); - let is_locked = if update.max_people == 0 { + let is_locked = if update.cancelled() { false } else { update.is_locked @@ -318,10 +333,8 @@ WHERE day=? .await .unwrap(); //Okay, as trip_details can only be created with proper DB backing - if update.max_people == 0 && !was_already_cancelled { - let rowers = TripWithUserAndType::from(db, update.trip.clone()) - .await - .rower; + if update.cancelled() && !was_already_cancelled { + let rowers = TripWithDetails::from(db, update.trip.clone()).await.rower; for user in rowers { if let Some(user) = User::find_by_name(db, &user.name).await { let notes = match update.notes { @@ -357,7 +370,7 @@ WHERE day=? .await; } - if update.max_people > 0 && was_already_cancelled { + if !update.cancelled() && was_already_cancelled { Notification::delete_by_action( db, &format!("remove_user_trip_with_trip_details_id:{}", trip_details_id), @@ -445,14 +458,14 @@ WHERE day=? pub(crate) async fn get_pinned_for_day( db: &sqlx::Pool, day: NaiveDate, - ) -> Vec { + ) -> Vec { let mut trips = Self::get_for_day(db, day).await; trips.retain(|e| e.trip.always_show); trips } fn is_cancelled(&self) -> bool { - self.max_people == 0 + self.max_people == -1 } } diff --git a/src/model/tripdetails.rs b/src/model/tripdetails.rs index ff813cb..e19a2b7 100644 --- a/src/model/tripdetails.rs +++ b/src/model/tripdetails.rs @@ -6,7 +6,7 @@ use sqlx::{FromRow, SqlitePool}; use super::{ notification::Notification, - trip::{Trip, TripWithUserAndType}, + trip::{Trip, TripWithDetails}, triptype::TripType, }; @@ -95,7 +95,7 @@ WHERE day = ? AND planned_starting_time = ? } pub fn cancelled(&self) -> bool { - self.max_people == 0 + self.max_people == -1 } /// This function is called when a person registers to a trip or when the cox changes the @@ -138,7 +138,7 @@ WHERE day = ? AND planned_starting_time = ? // This trip_details belongs to a planned_event, no need to do anything continue; }; - let pot_coxes = TripWithUserAndType::from(db, trip.clone()).await; + let pot_coxes = TripWithDetails::from(db, trip.clone()).await; let pot_coxes = pot_coxes.rower; for user in pot_coxes { let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); @@ -196,7 +196,7 @@ WHERE day = ? AND planned_starting_time = ? .fetch_one(db) .await .unwrap(); //TODO: fixme - let amount_currently_registered = i64::from(amount_currently_registered.count); + let amount_currently_registered = amount_currently_registered.count; amount_currently_registered >= self.max_people } diff --git a/src/model/usertrip.rs b/src/model/usertrip.rs index 4ea4341..9f14ba6 100644 --- a/src/model/usertrip.rs +++ b/src/model/usertrip.rs @@ -3,7 +3,7 @@ use sqlx::{FromRow, SqlitePool}; use super::{ notification::Notification, - trip::{Trip, TripWithUserAndType}, + trip::{Trip, TripWithDetails}, tripdetails::TripDetails, user::{SteeringUser, User}, }; @@ -158,7 +158,7 @@ impl UserTrip { .unwrap() .cancelled() { - let trip = TripWithUserAndType::from(db, trip.clone()).await; + let trip = TripWithDetails::from(db, trip.clone()).await; if trip.rower.len() == 1 { trip_to_delete = Some(trip.trip); } diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 09a71e7..343e051 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -71,7 +71,6 @@ async fn steering(db: &State, user: User, flash: Option Redirect { // Save the URL the user tried to access, to be able to go there once logged in let mut redirect_cookie = Cookie::new("redirect_url", format!("{}", req.uri())); - println!("{}", req.uri()); redirect_cookie.set_expires(OffsetDateTime::now_utc() + Duration::hours(1)); req.cookies().add_private(redirect_cookie); diff --git a/templates/index.html.tera b/templates/index.html.tera index 309dedb..0bb1f1e 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -51,7 +51,7 @@ {% if event.always_show and not day.regular_sees_this_day %} 🔮 {% endif -%} - {%- if event.max_people == 0 %} + {%- if event.cancelled %} ⚠ Absage {{ event.planned_starting_time }} Uhr @@ -129,7 +129,7 @@
{# --- START List Coxes --- #} {% if event.planned_amount_cox > 0 %} - {% if event.max_people == 0 %} + {% if event.cancelled %} {{ macros::box(participants=event.cox, empty_seats="", header='Absage', bg='[#f43f5e]') }} {% else %} {% if amount_cox_missing > 0 %} @@ -142,9 +142,9 @@ {# --- END List Coxes --- #} {# --- START List Rowers --- #} {% set amount_cur_rower = event.rower | length %} - {% if event.max_people == 0 %} + {% if event.cancelled %} {{ macros::box(header='Absage', bg='[#f43f5e]', participants=event.rower, trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }} - {% else %} + {% elif event.max_people > 0 %} {{ macros::box(participants=event.rower, empty_seats=event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }} {% endif %} {# --- END List Rowers --- #} @@ -167,7 +167,11 @@ {{ macros::input(label='Titel', name='name', type='input', value=event.name) }} - {{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=event.max_people, min='1') }} + {% if event.cancelled %} + + {% else %} + {{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=event.max_people, min='1') }} + {% endif %} {{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=event.planned_amount_cox, required=true, min='0') }} {{ macros::checkbox(label='Immer anzeigen', name='always_show', id=event.id,checked=event.always_show, help="Grundsätzlich sehen Rudernde Ausfahrten 10 Tage im vorhinein. Wenn du diese Option aktivierst, ist diese Ausfahrt sofort allen ersichtlich.") }} {{ macros::checkbox(label='Gesperrt', name='is_locked', id=event.id,checked=event.is_locked, help="Wenn diese Option aktiviert ist, kann sich keiner mehr an- und abmelden. Sinnvoll, wenn zB bereits die Bootseinteilung vorgenommen wurde") }} @@ -187,7 +191,7 @@
{% else %} - {% if event.max_people == 0 %} + {% if event.cancelled %} Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen. {% else %}
@@ -196,9 +200,8 @@ {{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }} - {{ macros::input(label='', name='max_people', type='hidden', value=0) }} + {{ macros::input(label='', name='max_people', type='hidden', value=-1) }} {{ macros::input(label='', name='name', type='hidden', value=event.name) }} - {{ macros::input(label='', name='max_people', type='hidden', value=event.max_people) }} {{ macros::input(label='', name='planned_amount_cox', type='hidden', value=event.planned_amount_cox) }} {{ macros::input(label='', name='always_show', type='hidden', value=event.always_show) }} {{ macros::input(label='', name='is_locked', type='hidden', value=event.is_locked) }} @@ -228,7 +231,7 @@ {% if trip.always_show and not day.regular_sees_this_day %} 🔮 {% endif -%} - {% if trip.max_people == 0 %} + {% if trip.cancelled %} ⚠ {{ trip.planned_starting_time }} Uhr @@ -250,7 +253,7 @@ {% endif %}
{% else %} - {% if trip.max_people == 0 %} + {% if trip.cancelled %} Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen. {% else %}

Ausfahrt absagen

- {{ macros::input(label='', name='max_people', type='hidden', value=0) }} + {{ macros::input(label='', name='max_people', type='hidden', value=-1) }} {{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }} {{ macros::input(label='', name='is_locked', type='hidden', value=trip.is_locked) }} {{ macros::input(label='', name='trip_type', type='hidden', value=trip.trip_type_id) }}