implement is_locked for trip_details

This commit is contained in:
philipp 2023-08-09 11:54:18 +02:00
parent 21c5609b31
commit bd4ee5954c
9 changed files with 150 additions and 47 deletions

View File

@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS "trip_details" (
"allow_guests" boolean NOT NULL default false, "allow_guests" boolean NOT NULL default false,
"notes" TEXT, "notes" TEXT,
"always_show" boolean NOT NULL default false, "always_show" boolean NOT NULL default false,
"is_locked" boolean NOT NULL default false,
"trip_type_id" INTEGER REFERENCES trip_type(id) ON DELETE CASCADE "trip_type_id" INTEGER REFERENCES trip_type(id) ON DELETE CASCADE
); );

View File

@ -23,6 +23,7 @@ pub struct PlannedEvent {
pub allow_guests: bool, pub allow_guests: bool,
trip_type_id: Option<i64>, trip_type_id: Option<i64>,
always_show: bool, always_show: bool,
is_locked: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -49,7 +50,7 @@ impl PlannedEvent {
Self, Self,
" "
SELECT SELECT
planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked
FROM planned_event FROM planned_event
INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id
WHERE planned_event.id like ? WHERE planned_event.id like ?
@ -77,7 +78,7 @@ WHERE planned_event.id like ?
let day = format!("{day}"); let day = format!("{day}");
let events = sqlx::query_as!( let events = sqlx::query_as!(
PlannedEvent, PlannedEvent,
"SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked
FROM planned_event FROM planned_event
INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id
WHERE day=?", WHERE day=?",
@ -108,7 +109,7 @@ WHERE day=?",
pub async fn all(db: &SqlitePool) -> Vec<PlannedEvent> { pub async fn all(db: &SqlitePool) -> Vec<PlannedEvent> {
sqlx::query_as!( sqlx::query_as!(
PlannedEvent, PlannedEvent,
"SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked
FROM planned_event FROM planned_event
INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id", INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id",
) )
@ -195,6 +196,7 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM planned_even
max_people: i32, max_people: i32,
notes: Option<&str>, notes: Option<&str>,
always_show: bool, always_show: bool,
is_locked: bool,
) { ) {
sqlx::query!( sqlx::query!(
"UPDATE planned_event SET planned_amount_cox = ? WHERE id = ?", "UPDATE planned_event SET planned_amount_cox = ? WHERE id = ?",
@ -206,10 +208,11 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM planned_even
.unwrap(); //Okay, as planned_event can only be created with proper DB backing .unwrap(); //Okay, as planned_event can only be created with proper DB backing
sqlx::query!( sqlx::query!(
"UPDATE trip_details SET max_people = ?, notes = ?, always_show=? WHERE id = ?", "UPDATE trip_details SET max_people = ?, notes = ?, always_show = ?, is_locked = ? WHERE id = ?",
max_people, max_people,
notes, notes,
always_show, always_show,
is_locked,
self.trip_details_id self.trip_details_id
) )
.execute(db) .execute(db)
@ -242,6 +245,12 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM planned_even
write!(&mut buf, "{}", calendar).unwrap(); write!(&mut buf, "{}", calendar).unwrap();
String::from_utf8(buf).unwrap() String::from_utf8(buf).unwrap()
} }
pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails {
TripDetails::find_by_id(db, self.trip_details_id)
.await
.unwrap() //ok, not null in db
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -22,6 +22,7 @@ pub struct Trip {
pub allow_guests: bool, pub allow_guests: bool,
trip_type_id: Option<i64>, trip_type_id: Option<i64>,
always_show: bool, always_show: bool,
is_locked: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -48,7 +49,7 @@ impl Trip {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked
FROM trip FROM trip
INNER JOIN trip_details ON trip.trip_details_id = trip_details.id INNER JOIN trip_details ON trip.trip_details_id = trip_details.id
INNER JOIN user ON trip.cox_id = user.id INNER JOIN user ON trip.cox_id = user.id
@ -71,6 +72,14 @@ WHERE trip.id=?
return Err(CoxHelpError::AlreadyRegisteredAsRower); return Err(CoxHelpError::AlreadyRegisteredAsRower);
} }
if planned_event.trip_details(db).await.is_locked {
return Err(CoxHelpError::DetailsLocked);
}
if planned_event.is_rower_registered(db, cox).await {
return Err(CoxHelpError::AlreadyRegisteredAsRower);
}
match sqlx::query!( match sqlx::query!(
"INSERT INTO trip (cox_id, planned_event_id) VALUES(?, ?)", "INSERT INTO trip (cox_id, planned_event_id) VALUES(?, ?)",
cox.id, cox.id,
@ -89,7 +98,7 @@ WHERE trip.id=?
let trips = sqlx::query_as!( let trips = sqlx::query_as!(
Trip, Trip,
" "
SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked
FROM trip FROM trip
INNER JOIN trip_details ON trip.trip_details_id = trip_details.id INNER JOIN trip_details ON trip.trip_details_id = trip_details.id
INNER JOIN user ON trip.cox_id = user.id INNER JOIN user ON trip.cox_id = user.id
@ -141,6 +150,7 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i
notes: Option<&str>, notes: Option<&str>,
trip_type: Option<i64>, //TODO: Move to `TripType` trip_type: Option<i64>, //TODO: Move to `TripType`
always_show: bool, always_show: bool,
is_locked: bool,
) -> Result<(), TripUpdateError> { ) -> Result<(), TripUpdateError> {
if !trip.is_trip_from_user(cox.id) { if !trip.is_trip_from_user(cox.id) {
return Err(TripUpdateError::NotYourTrip); return Err(TripUpdateError::NotYourTrip);
@ -158,11 +168,12 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i
}; };
sqlx::query!( sqlx::query!(
"UPDATE trip_details SET max_people = ?, notes = ?, trip_type_id = ?, always_show = ? WHERE id = ?", "UPDATE trip_details SET max_people = ?, notes = ?, trip_type_id = ?, always_show = ?, is_locked = ? WHERE id = ?",
max_people, max_people,
notes, notes,
trip_type, trip_type,
always_show, always_show,
is_locked,
trip_details_id trip_details_id
) )
.execute(db) .execute(db)
@ -172,12 +183,23 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i
Ok(()) Ok(())
} }
pub async fn trip_details(&self, db: &SqlitePool) -> Option<TripDetails> {
if let Some(trip_details_id) = self.trip_type_id {
return TripDetails::find_by_id(db, trip_details_id).await;
}
None
}
pub async fn delete_by_planned_event( pub async fn delete_by_planned_event(
db: &SqlitePool, db: &SqlitePool,
cox: &CoxUser, cox: &CoxUser,
planned_event: &PlannedEvent, planned_event: &PlannedEvent,
) -> bool { ) -> Result<(), TripHelpDeleteError> {
sqlx::query!( if planned_event.trip_details(db).await.is_locked {
return Err(TripHelpDeleteError::DetailsLocked);
}
let affected_rows = sqlx::query!(
"DELETE FROM trip WHERE cox_id = ? AND planned_event_id = ?", "DELETE FROM trip WHERE cox_id = ? AND planned_event_id = ?",
cox.id, cox.id,
planned_event.id planned_event.id
@ -185,8 +207,13 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i
.execute(db) .execute(db)
.await .await
.unwrap() .unwrap()
.rows_affected() .rows_affected();
> 0
if affected_rows == 0 {
return Err(TripHelpDeleteError::CoxNotHelping);
}
Ok(())
} }
pub(crate) async fn delete( pub(crate) async fn delete(
@ -233,6 +260,13 @@ FROM user_trip WHERE trip_details_id = (SELECT trip_details_id FROM trip WHERE i
pub enum CoxHelpError { pub enum CoxHelpError {
AlreadyRegisteredAsRower, AlreadyRegisteredAsRower,
AlreadyRegisteredAsCox, AlreadyRegisteredAsCox,
DetailsLocked,
}
#[derive(Debug, PartialEq)]
pub enum TripHelpDeleteError {
DetailsLocked,
CoxNotHelping,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@ -333,9 +367,11 @@ mod test {
let trip = Trip::find_by_id(&pool, 1).await.unwrap(); let trip = Trip::find_by_id(&pool, 1).await.unwrap();
assert!(Trip::update_own(&pool, &cox, &trip, 10, None, None, false) assert!(
.await Trip::update_own(&pool, &cox, &trip, 10, None, None, false, false)
.is_ok()); .await
.is_ok()
);
let trip = Trip::find_by_id(&pool, 1).await.unwrap(); let trip = Trip::find_by_id(&pool, 1).await.unwrap();
assert_eq!(trip.max_people, 10); assert_eq!(trip.max_people, 10);
@ -354,7 +390,7 @@ mod test {
let trip = Trip::find_by_id(&pool, 1).await.unwrap(); let trip = Trip::find_by_id(&pool, 1).await.unwrap();
assert!( assert!(
Trip::update_own(&pool, &cox, &trip, 10, None, Some(1), false) Trip::update_own(&pool, &cox, &trip, 10, None, Some(1), false, false)
.await .await
.is_ok() .is_ok()
); );
@ -376,9 +412,11 @@ mod test {
let trip = Trip::find_by_id(&pool, 1).await.unwrap(); let trip = Trip::find_by_id(&pool, 1).await.unwrap();
assert!(Trip::update_own(&pool, &cox, &trip, 10, None, None, false) assert!(
.await Trip::update_own(&pool, &cox, &trip, 10, None, None, false, false)
.is_err()); .await
.is_err()
);
assert_eq!(trip.max_people, 1); assert_eq!(trip.max_people, 1);
} }
@ -398,7 +436,9 @@ mod test {
//TODO: check why following assert fails //TODO: check why following assert fails
//assert!(Trip::find_by_id(&pool, 2).await.is_some()); //assert!(Trip::find_by_id(&pool, 2).await.is_some());
Trip::delete_by_planned_event(&pool, &cox, &planned_event).await; Trip::delete_by_planned_event(&pool, &cox, &planned_event)
.await
.unwrap();
assert!(Trip::find_by_id(&pool, 2).await.is_none()); assert!(Trip::find_by_id(&pool, 2).await.is_none());
} }

View File

@ -13,6 +13,7 @@ pub struct TripDetails {
pub allow_guests: bool, pub allow_guests: bool,
pub trip_type_id: Option<i64>, pub trip_type_id: Option<i64>,
pub always_show: bool, pub always_show: bool,
pub is_locked: bool,
} }
#[derive(FromForm, Serialize)] #[derive(FromForm, Serialize)]
@ -33,7 +34,7 @@ impl TripDetails {
sqlx::query_as!( sqlx::query_as!(
TripDetails, TripDetails,
" "
SELECT id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show SELECT id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked
FROM trip_details FROM trip_details
WHERE id like ? WHERE id like ?
", ",

View File

@ -14,6 +14,10 @@ impl UserTrip {
return Err(UserTripError::EventAlreadyFull); return Err(UserTripError::EventAlreadyFull);
} }
if trip_details.is_locked {
return Err(UserTripError::DetailsLocked);
}
if user.is_guest && !trip_details.allow_guests { if user.is_guest && !trip_details.allow_guests {
return Err(UserTripError::GuestNotAllowedForThisEvent); return Err(UserTripError::GuestNotAllowedForThisEvent);
} }
@ -68,8 +72,15 @@ impl UserTrip {
} }
} }
pub async fn delete(db: &SqlitePool, user: &User, trip_details: &TripDetails) { pub async fn delete(
//TODO: Check if > 2 hrs to event db: &SqlitePool,
user: &User,
trip_details: &TripDetails,
) -> Result<(), UserTripDeleteError> {
if trip_details.is_locked {
return Err(UserTripDeleteError::DetailsLocked);
}
let _ = sqlx::query!( let _ = sqlx::query!(
"DELETE FROM user_trip WHERE user_id = ? AND trip_details_id = ?", "DELETE FROM user_trip WHERE user_id = ? AND trip_details_id = ?",
user.id, user.id,
@ -77,7 +88,9 @@ impl UserTrip {
) )
.execute(db) .execute(db)
.await .await
.is_ok(); .unwrap();
Ok(())
} }
} }
@ -86,10 +99,16 @@ pub enum UserTripError {
AlreadyRegistered, AlreadyRegistered,
AlreadyRegisteredAsCox, AlreadyRegisteredAsCox,
EventAlreadyFull, EventAlreadyFull,
DetailsLocked,
CantRegisterAtOwnEvent, CantRegisterAtOwnEvent,
GuestNotAllowedForThisEvent, GuestNotAllowedForThisEvent,
} }
#[derive(Debug, PartialEq)]
pub enum UserTripDeleteError {
DetailsLocked,
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{ use crate::{

View File

@ -47,6 +47,7 @@ struct UpdatePlannedEventForm<'r> {
max_people: i32, max_people: i32,
notes: Option<&'r str>, notes: Option<&'r str>,
always_show: bool, always_show: bool,
is_locked: bool,
} }
#[put("/planned-event", data = "<data>")] #[put("/planned-event", data = "<data>")]
@ -64,6 +65,7 @@ async fn update(
data.max_people, data.max_people,
data.notes, data.notes,
data.always_show, data.always_show,
data.is_locked,
) )
.await; .await;
Flash::success(Redirect::to("/"), "Successfully edited the event") Flash::success(Redirect::to("/"), "Successfully edited the event")

View File

@ -9,7 +9,7 @@ use sqlx::SqlitePool;
use crate::model::{ use crate::model::{
log::Log, log::Log,
planned_event::PlannedEvent, planned_event::PlannedEvent,
trip::{CoxHelpError, Trip, TripDeleteError, TripUpdateError}, trip::{CoxHelpError, Trip, TripDeleteError, TripHelpDeleteError, TripUpdateError},
tripdetails::{TripDetails, TripDetailsToAdd}, tripdetails::{TripDetails, TripDetailsToAdd},
user::CoxUser, user::CoxUser,
}; };
@ -43,6 +43,7 @@ struct EditTripForm<'r> {
notes: Option<&'r str>, notes: Option<&'r str>,
trip_type: Option<i64>, trip_type: Option<i64>,
always_show: bool, always_show: bool,
is_locked: bool,
} }
#[post("/trip/<trip_id>", data = "<data>")] #[post("/trip/<trip_id>", data = "<data>")]
@ -61,6 +62,7 @@ async fn update(
data.notes, data.notes,
data.trip_type, data.trip_type,
data.always_show, data.always_show,
data.is_locked,
) )
.await .await
{ {
@ -99,6 +101,9 @@ async fn join(db: &State<SqlitePool>, planned_event_id: i64, cox: CoxUser) -> Fl
Redirect::to("/"), Redirect::to("/"),
"Du hast dich bereits als Ruderer angemeldet!", "Du hast dich bereits als Ruderer angemeldet!",
), ),
Err(CoxHelpError::DetailsLocked) => {
Flash::error(Redirect::to("/"), "Boot ist bereits eingeteilt.")
}
} }
} else { } else {
Flash::error(Redirect::to("/"), "Event gibt's nicht") Flash::error(Redirect::to("/"), "Event gibt's nicht")
@ -129,19 +134,25 @@ async fn remove_trip(db: &State<SqlitePool>, trip_id: i64, cox: CoxUser) -> Flas
#[get("/remove/<planned_event_id>")] #[get("/remove/<planned_event_id>")]
async fn remove(db: &State<SqlitePool>, planned_event_id: i64, cox: CoxUser) -> Flash<Redirect> { async fn remove(db: &State<SqlitePool>, planned_event_id: i64, cox: CoxUser) -> Flash<Redirect> {
if let Some(planned_event) = PlannedEvent::find_by_id(db, planned_event_id).await { if let Some(planned_event) = PlannedEvent::find_by_id(db, planned_event_id).await {
if Trip::delete_by_planned_event(db, &cox, &planned_event).await { match Trip::delete_by_planned_event(db, &cox, &planned_event).await {
Log::create( Ok(_) => {
db, Log::create(
format!( db,
"Cox {} deleted registration for planned_event.id={}", format!(
cox.name, planned_event_id "Cox {} deleted registration for planned_event.id={}",
), cox.name, planned_event_id
) ),
.await; )
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!") return Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!");
} else { }
Flash::error(Redirect::to("/"), "Steuermann hilft nicht aus...") Err(TripHelpDeleteError::DetailsLocked) => {
return Flash::error(Redirect::to("/"), "Boot bereits eingeteilt");
}
Err(TripHelpDeleteError::CoxNotHelping) => {
return Flash::error(Redirect::to("/"), "Steuermann hilft nicht aus...")
}
} }
} else { } else {
Flash::error(Redirect::to("/"), "Planned_event does not exist.") Flash::error(Redirect::to("/"), "Planned_event does not exist.")

View File

@ -16,7 +16,7 @@ use crate::model::{
tripdetails::TripDetails, tripdetails::TripDetails,
triptype::TripType, triptype::TripType,
user::User, user::User,
usertrip::{UserTrip, UserTripError}, usertrip::{UserTrip, UserTripDeleteError, UserTripError},
}; };
mod admin; mod admin;
@ -81,6 +81,10 @@ async fn join(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Flash
Redirect::to("/"), Redirect::to("/"),
"Bei dieser Ausfahrt können leider keine Gäste mitfahren.", "Bei dieser Ausfahrt können leider keine Gäste mitfahren.",
), ),
Err(UserTripError::DetailsLocked) => Flash::error(
Redirect::to("/"),
"Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.",
),
} }
} }
@ -90,18 +94,32 @@ async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Fla
return Flash::error(Redirect::to("/"), "TripDetailsId does not exist"); return Flash::error(Redirect::to("/"), "TripDetailsId does not exist");
}; };
UserTrip::delete(db, &user, &trip_details).await; match UserTrip::delete(db, &user, &trip_details).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} unregistered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Log::create( Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
db, }
format!( Err(UserTripDeleteError::DetailsLocked) => {
"User {} unregistered for trip_details.id={}", Log::create(
user.name, trip_details_id db,
), format!(
) "User {} tried to unregister for locked trip_details.id={}",
.await; user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!") Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.")
}
}
} }
#[catch(401)] //unauthorized #[catch(401)] //unauthorized

View File

@ -134,6 +134,7 @@
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=planned_event.max_people, min='0') }} {{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=planned_event.max_people, min='0') }}
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=planned_event.planned_amount_cox, required=true, min='0') }} {{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=planned_event.planned_amount_cox, required=true, min='0') }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=planned_event.id,checked=planned_event.always_show) }} {{ macros::checkbox(label='Immer anzeigen', name='always_show', id=planned_event.id,checked=planned_event.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=planned_event.id,checked=planned_event.is_locked) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=planned_event.notes) }} {{ macros::input(label='Anmerkungen', name='notes', type='input', value=planned_event.notes) }}
<input value="Speichern" class="btn btn-primary" type="submit"/> <input value="Speichern" class="btn btn-primary" type="submit"/>
@ -223,6 +224,7 @@
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=trip.max_people, min='0') }} {{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=trip.max_people, min='0') }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }} {{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=trip.id,checked=trip.always_show) }} {{ macros::checkbox(label='Immer anzeigen', name='always_show', id=trip.id,checked=trip.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=trip.id,checked=trip.is_locked) }}
{{ macros::select(select_name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id) }} {{ macros::select(select_name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id) }}
<input value="Speichern" class="btn btn-primary" type="submit"/> <input value="Speichern" class="btn btn-primary" type="submit"/>