diff --git a/ht_booking/.gitignore b/ht_booking/.gitignore index 510f9fb..e2305bd 100644 --- a/ht_booking/.gitignore +++ b/ht_booking/.gitignore @@ -17,4 +17,7 @@ target/ *.pdb *.sqlite -*.sqlite-* \ No newline at end of file +*.sqlite-* + +# Local secrets (hardcoded admin credentials) +.env \ No newline at end of file diff --git a/ht_booking/Cargo.lock b/ht_booking/Cargo.lock index 90491da..156e3d3 100644 --- a/ht_booking/Cargo.lock +++ b/ht_booking/Cargo.lock @@ -1670,6 +1670,7 @@ dependencies = [ "axum", "axum-extra", "chrono", + "dotenvy", "fluent-templates", "include_dir", "insta", diff --git a/ht_booking/Cargo.toml b/ht_booking/Cargo.toml index 07756f3..3d71053 100644 --- a/ht_booking/Cargo.toml +++ b/ht_booking/Cargo.toml @@ -39,7 +39,8 @@ include_dir = { version = "0.7" } fluent-templates = { version = "0.13", features = ["tera"] } unic-langid = { version = "0.9" } # /view engine -axum-extra = { version = "0.10", features = ["form"] } +axum-extra = { version = "0.10", features = ["form", "cookie"] } +dotenvy = { version = "0.15" } [[bin]] name = "ht_booking-cli" diff --git a/ht_booking/assets/i18n/de-DE/main.ftl b/ht_booking/assets/i18n/de-DE/main.ftl deleted file mode 100644 index ced609f..0000000 --- a/ht_booking/assets/i18n/de-DE/main.ftl +++ /dev/null @@ -1,4 +0,0 @@ -hello-world = Hallo Welt! -greeting = Hallochen { $name }! - .placeholder = Hallo Freund! -about = Uber diff --git a/ht_booking/assets/i18n/en-US/main.ftl b/ht_booking/assets/i18n/en-US/main.ftl deleted file mode 100644 index 9d4d5e7..0000000 --- a/ht_booking/assets/i18n/en-US/main.ftl +++ /dev/null @@ -1,10 +0,0 @@ -hello-world = Hello World! -greeting = Hello { $name }! - .placeholder = Hello Friend! -about = About -simple = simple text -reference = simple text with a reference: { -something } -parameter = text with a { $param } -parameter2 = text one { $param } second { $multi-word-param } -email = text with an EMAIL("example@example.org") -fallback = this should fall back diff --git a/ht_booking/assets/i18n/en/main.ftl b/ht_booking/assets/i18n/en/main.ftl new file mode 100644 index 0000000..492bc26 --- /dev/null +++ b/ht_booking/assets/i18n/en/main.ftl @@ -0,0 +1,43 @@ +brand = Tennis Court Booking +nav-calendar = Calendar +nav-admin = Admin login +admin-title = Admin +calendar-title = Court Calendar +court-label = Court +prev-week = Previous week +this-week = This week +next-week = Next week +free = Free +booked = Booked +no-courts = No courts available yet. +day-mon = Mon +day-tue = Tue +day-wed = Wed +day-thu = Thu +day-fri = Fri +day-sat = Sat +day-sun = Sun +login-title = Admin Login +email = Email +password = Password +login-button = Sign in +login-error = Invalid email or password. +logout = Log out +manage-courts = Courts +back-to-calendar = Back to calendar +add-booking = New Booking +edit-booking = Edit Booking +date = Date +hour = Hour +booking-color = Colour +booking-name = Name +booking-contact = Contact +booking-note = Note +save = Save +cancel = Cancel +delete = Delete +confirm-delete = Delete this booking? +court-name = Name +court-surface = Surface +court-indoor = Indoor +add-court = Add Court diff --git a/ht_booking/assets/i18n/sk/main.ftl b/ht_booking/assets/i18n/sk/main.ftl new file mode 100644 index 0000000..f321cbb --- /dev/null +++ b/ht_booking/assets/i18n/sk/main.ftl @@ -0,0 +1,43 @@ +brand = Rezervácia tenisového kurtu +nav-calendar = Kalendár +nav-admin = Prihlásenie admina +admin-title = Admin +calendar-title = Kalendár kurtu +court-label = Kurt +prev-week = Predchádzajúci týždeň +this-week = Tento týždeň +next-week = Nasledujúci týždeň +free = Voľné +booked = Obsadené +no-courts = Zatiaľ nie sú k dispozícii žiadne kurty. +day-mon = Po +day-tue = Ut +day-wed = St +day-thu = Št +day-fri = Pi +day-sat = So +day-sun = Ne +login-title = Prihlásenie admina +email = E-mail +password = Heslo +login-button = Prihlásiť sa +login-error = Nesprávny e-mail alebo heslo. +logout = Odhlásiť sa +manage-courts = Kurty +back-to-calendar = Späť na kalendár +add-booking = Nová rezervácia +edit-booking = Upraviť rezerváciu +date = Dátum +hour = Hodina +booking-color = Farba +booking-name = Meno +booking-contact = Kontakt +booking-note = Poznámka +save = Uložiť +cancel = Zrušiť +delete = Zmazať +confirm-delete = Zmazať túto rezerváciu? +court-name = Názov +court-surface = Povrch +court-indoor = Krytý +add-court = Pridať kurt diff --git a/ht_booking/assets/views/admin/booking_form.html b/ht_booking/assets/views/admin/booking_form.html new file mode 100644 index 0000000..eff56be --- /dev/null +++ b/ht_booking/assets/views/admin/booking_form.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %} +{% if mode == "edit" %}{{ t(key="edit-booking", lang=lang) }}{% else %}{{ t(key="add-booking", lang=lang) }}{% endif %} +{% endblock title %} + +{% block content %} +
+
+

+ {% if mode == "edit" %}{{ t(key="edit-booking", lang=lang) }}{% else %}{{ t(key="add-booking", lang=lang) }}{% endif %} +

+ ← {{ t(key="back-to-calendar", lang=lang) }} +
+ +
+ +
{{ t(key="court-label", lang=lang) }}: {{ court_name }}
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {{ t(key="cancel", lang=lang) }} +
+
+ + {% if mode == "edit" %} +
+ +
+ {% endif %} +
+{% endblock content %} diff --git a/ht_booking/assets/views/admin/courts.html b/ht_booking/assets/views/admin/courts.html new file mode 100644 index 0000000..32031cf --- /dev/null +++ b/ht_booking/assets/views/admin/courts.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="manage-courts", lang=lang) }}{% endblock title %} + +{% block content %} +
+

{{ t(key="manage-courts", lang=lang) }}

+ ← {{ t(key="back-to-calendar", lang=lang) }} +
+ +
+ + + + + + + + + + {% for c in courts %} + + + + + + {% endfor %} + +
{{ t(key="court-name", lang=lang) }}{{ t(key="court-surface", lang=lang) }}{{ t(key="court-indoor", lang=lang) }}
{{ c.name }}{{ c.surface }}{{ c.indoor }}
+
+ +
+

{{ t(key="add-court", lang=lang) }}

+
+
+ + +
+
+ + +
+ + +
+
+{% endblock content %} diff --git a/ht_booking/assets/views/admin/login.html b/ht_booking/assets/views/admin/login.html new file mode 100644 index 0000000..7db5f88 --- /dev/null +++ b/ht_booking/assets/views/admin/login.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="login-title", lang=lang) }}{% endblock title %} + +{% block content %} +
+

{{ t(key="login-title", lang=lang) }}

+ {% if error %} +
{{ t(key="login-error", lang=lang) }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+
+{% endblock content %} diff --git a/ht_booking/assets/views/base.html b/ht_booking/assets/views/base.html index 1c28e1f..d5147dd 100644 --- a/ht_booking/assets/views/base.html +++ b/ht_booking/assets/views/base.html @@ -1,65 +1,50 @@ - - + - {% block title %}{% endblock title %} - - {% block head %} - - {% endblock head %} + {% block title %}{{ t(key="brand", lang=lang) }}{% endblock title %} + + {% block head %}{% endblock head %} - -
-
-
-
-

- {% block page_title %}{% endblock page_title %} -

- {% block content %} - {% endblock content %} -
-
-
+ +
+
+ {{ t(key="brand", lang=lang) }} +
- {% block js %} +
- {% endblock js %} +
+ {% block content %}{% endblock content %} +
+ {% block js %}{% endblock js %} - \ No newline at end of file + diff --git a/ht_booking/assets/views/calendar/week.html b/ht_booking/assets/views/calendar/week.html new file mode 100644 index 0000000..a02c026 --- /dev/null +++ b/ht_booking/assets/views/calendar/week.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}{{ t(key="calendar-title", lang=lang) }}{% endblock title %} + +{% block content %} +
+

+ {% if is_admin %}{{ t(key="admin-title", lang=lang) }}{% else %}{{ t(key="calendar-title", lang=lang) }}{% endif %} +

+ {% if has_courts %} +
+ + + +
+ {% endif %} +
+ +{% if has_courts %} +
+ +
{{ court_name }} · {{ week_label }}
+
+ +
+ + + + + {% for d in days %} + + {% endfor %} + + + + {% for row in rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + {% endfor %} + +
+
{{ t(key=d.key, lang=lang) }}
+
{{ d.num }}
+
{{ row.hour_label }} + {% if cell.booked %} + {% if is_admin %} + {{ cell.name }} + {% else %} +
{{ t(key="booked", lang=lang) }}
+ {% endif %} + {% else %} + {% if is_admin %} + + + {% else %} +
{{ t(key="free", lang=lang) }}
+ {% endif %} + {% endif %} +
+
+{% else %} +
{{ t(key="no-courts", lang=lang) }}
+{% endif %} +{% endblock content %} diff --git a/ht_booking/assets/views/court/create.html b/ht_booking/assets/views/court/create.html deleted file mode 100644 index c24d590..0000000 --- a/ht_booking/assets/views/court/create.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} - -{% block title %} -Create court -{% endblock title %} - -{% block page_title %} -Create new court -{% endblock page_title %} - -{% block content %} -
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-Back to courts -
-{% endblock content %} - -{% block js %} - -{% endblock js %} \ No newline at end of file diff --git a/ht_booking/assets/views/court/edit.html b/ht_booking/assets/views/court/edit.html deleted file mode 100644 index 5894cce..0000000 --- a/ht_booking/assets/views/court/edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} - -{% block title %} -Edit court: {{ item.id }} -{% endblock title %} - -{% block page_title %} -Edit court: {{ item.id }} -{% endblock page_title %} - -{% block content %} -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
- Back to court -
-{% endblock content %} - -{% block js %} - -{% endblock js %} \ No newline at end of file diff --git a/ht_booking/assets/views/court/list.html b/ht_booking/assets/views/court/list.html deleted file mode 100644 index 02c5640..0000000 --- a/ht_booking/assets/views/court/list.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} - -{% block title %} -List of court -{% endblock title %} - -{% block page_title %} -court -{% endblock page_title %} - -{% block content %} -
- - {% if items %} - -
-
- - - - - - - - - - - {% for item in items %} - - - - - - - {% endfor %} - -
- {{"name" | capitalize }} - - {{"surface" | capitalize }} - - {{"indoor" | capitalize }} - - Actions -
- {{item.name | escape }} - - {{item.surface | escape }} - - {{ item.indoor }} - - Edit -
-
- -
- -
-
- - {% else %} - -
-
-

Nothing Here Yet

- There are no records to display. Add a new record to get started! - - Create - -
-
- - {% endif %} - - -
-{% endblock content %} \ No newline at end of file diff --git a/ht_booking/assets/views/court/show.html b/ht_booking/assets/views/court/show.html deleted file mode 100644 index 62fb0b9..0000000 --- a/ht_booking/assets/views/court/show.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} - -{% block title %} -View court: {{ item.id }} -{% endblock title %} - -{% block content %} -

View court: {{ item.id }}

-
-
- -
-
- -
-
- -
-
-Back to courts -
-{% endblock content %} \ No newline at end of file diff --git a/ht_booking/assets/views/home/hello.html b/ht_booking/assets/views/home/hello.html deleted file mode 100644 index 6b97c39..0000000 --- a/ht_booking/assets/views/home/hello.html +++ /dev/null @@ -1,12 +0,0 @@ - - -
- find this tera template at assets/views/home/hello.html: -
-
- {{ t(key="hello-world", lang="en-US") }}, -
- {{ t(key="hello-world", lang="de-DE") }} - - - \ No newline at end of file diff --git a/ht_booking/assets/views/home/home.html b/ht_booking/assets/views/home/home.html deleted file mode 100644 index d0f9f16..0000000 --- a/ht_booking/assets/views/home/home.html +++ /dev/null @@ -1,181 +0,0 @@ -{% extends "base.html" %} - -{% block title %}ht_booking · Loco SaaS starter{% endblock title %} - -{% block page_title %}What this Loco app gives you out of the box{% endblock page_title %} - -{% block content %} -

- This page replaces the default Loco fallback screen. Every model, controller, - migration and route below was produced by cargo loco generate — - nothing here is hand-written boilerplate. -

- - -
-

1 · Authentication — JWT API

-

- The SaaS starter ships a complete JWT auth flow in src/controllers/auth.rs. - It is a JSON API (no HTML login pages) — so here is a live console that calls those exact endpoints. -

- -
- - - - - - - - - - - - - - - - - - - - -
MethodPathJSON bodyPurpose
POST/api/auth/registername, email, passwordcreate an account
POST/api/auth/loginemail, passwordreturns a JWT token
GET/api/auth/current— (Bearer token)info about the logged-in user
POST/api/auth/forgotemailstart password reset
POST/api/auth/resettoken, passwordset a new password
GET/api/auth/verify/{token}verify an email address
POST/api/auth/magic-linkemailemail a passwordless login link
GET/api/auth/magic-link/{token}exchange the link for a JWT
POST/api/auth/resend-verification-mailemailresend the verification mail
-
- -
-
-

Register

- - - - -

-    
- -
-

Login

- - - -

-    
- -
-

Current user

-

Sends the JWT from a successful login as a Bearer token.

- -

-    
-
- -

- Active JWT: -

-
- - -
-

2 · Database CRUD — Courts

-

- A model, SeaORM entity, migration, controller and Tera views, all generated with one command: -

-
cargo loco generate scaffold court name:string surface:string indoor:bool --html
- Open /courts → -
- - -
-

3 · Health & ops endpoints

-

Built-in, no code needed:

- -
- - -
-

4 · Server-side views & i18n

-

- Tera templates plus Fluent translations. The same key hello-world, - resolved by the t() function in this template: -

- -
- - -
-

5 · Also wired up (CLI-generated when you need them)

- -
-{% endblock content %} - -{% block js %} -{% raw %} - -{% endraw %} -{% endblock js %} diff --git a/ht_booking/migration/src/lib.rs b/ht_booking/migration/src/lib.rs index 010da00..a2c3d64 100644 --- a/ht_booking/migration/src/lib.rs +++ b/ht_booking/migration/src/lib.rs @@ -4,6 +4,7 @@ pub use sea_orm_migration::prelude::*; mod m20220101_000001_users; mod m20260515_162423_courts; +mod m20260515_170417_bookings; pub struct Migrator; #[async_trait::async_trait] @@ -12,6 +13,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20220101_000001_users::Migration), Box::new(m20260515_162423_courts::Migration), + Box::new(m20260515_170417_bookings::Migration), // inject-above (do not remove this comment) ] } diff --git a/ht_booking/migration/src/m20260515_170417_bookings.rs b/ht_booking/migration/src/m20260515_170417_bookings.rs new file mode 100644 index 0000000..e40f610 --- /dev/null +++ b/ht_booking/migration/src/m20260515_170417_bookings.rs @@ -0,0 +1,31 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + create_table(m, "bookings", + &[ + + ("id", ColType::PkAuto), + + ("date", ColType::Date), + ("hour", ColType::Integer), + ("color", ColType::String), + ("name", ColType::String), + ("contact", ColType::StringNull), + ("note", ColType::TextNull), + ], + &[ + ("court", ""), + ] + ).await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "bookings").await + } +} diff --git a/ht_booking/src/app.rs b/ht_booking/src/app.rs index 741e6ad..028b6eb 100644 --- a/ht_booking/src/app.rs +++ b/ht_booking/src/app.rs @@ -44,16 +44,16 @@ impl Hooks for App { } async fn initializers(_ctx: &AppContext) -> Result>> { - Ok(vec![Box::new( - initializers::view_engine::ViewEngineInitializer, - )]) + Ok(vec![ + Box::new(initializers::view_engine::ViewEngineInitializer), + Box::new(initializers::admin_seeder::AdminSeeder), + ]) } fn routes(_ctx: &AppContext) -> AppRoutes { AppRoutes::with_default_routes() // controller routes below - .add_route(controllers::home::routes()) - .add_route(controllers::court::routes()) - .add_route(controllers::auth::routes()) + .add_route(controllers::calendar::routes()) + .add_route(controllers::admin::routes()) } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; diff --git a/ht_booking/src/bin/main.rs b/ht_booking/src/bin/main.rs index 6bb43e3..3f61c02 100644 --- a/ht_booking/src/bin/main.rs +++ b/ht_booking/src/bin/main.rs @@ -4,5 +4,7 @@ use migration::Migrator; #[tokio::main] async fn main() -> loco_rs::Result<()> { + // Load ADMIN_* credentials (and any DATABASE_URL override) from `.env`. + dotenvy::dotenv().ok(); cli::main::().await } diff --git a/ht_booking/src/controllers/admin.rs b/ht_booking/src/controllers/admin.rs new file mode 100644 index 0000000..b37a0b5 --- /dev/null +++ b/ht_booking/src/controllers/admin.rs @@ -0,0 +1,392 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unused_async)] +//! Admin area: cookie-based JWT login and the booking editor. +//! +//! There is exactly one admin. Login is gated to the `ADMIN_EMAIL` env value +//! so any other user row in the DB cannot reach the admin pages. + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::Redirect; +use axum_extra::extract::cookie::{Cookie, CookieJar}; +use loco_rs::auth::jwt; +use loco_rs::prelude::*; +use sea_orm::QueryOrder; +use serde::Deserialize; + +use crate::controllers::calendar::{self, build_calendar, current_lang, FIRST_HOUR, LAST_HOUR}; +use crate::models::_entities::{bookings, courts}; +use crate::models::users; + +const AUTH_COOKIE: &str = "auth_token"; + +fn admin_email() -> String { + std::env::var("ADMIN_EMAIL").unwrap_or_default() +} + +fn jwt_settings(ctx: &AppContext) -> Option<(String, u64)> { + let jwt = ctx.config.auth.as_ref()?.jwt.as_ref()?; + Some((jwt.secret.clone(), jwt.expiration)) +} + +/// Request guard for admin-only routes. On any failure it redirects to the +/// login page instead of returning an error. +pub struct AdminAuth { + #[allow(dead_code)] + pub user: users::Model, +} + +impl FromRequestParts for AdminAuth { + type Rejection = Redirect; + + async fn from_request_parts( + parts: &mut Parts, + ctx: &AppContext, + ) -> std::result::Result { + let deny = || Redirect::to("/admin/login"); + let admin = admin_email(); + if admin.is_empty() { + return Err(deny()); + } + + let jar = CookieJar::from_headers(&parts.headers); + let token = jar + .get(AUTH_COOKIE) + .map(|c| c.value().to_string()) + .ok_or_else(deny)?; + let (secret, _) = jwt_settings(ctx).ok_or_else(deny)?; + let claims = jwt::JWT::new(&secret) + .validate(&token) + .map_err(|_| deny())?; + let user = users::Model::find_by_pid(&ctx.db, &claims.claims.pid) + .await + .map_err(|_| deny())?; + if user.email != admin { + return Err(deny()); + } + Ok(Self { user }) + } +} + +// ---------------------------------------------------------------- login ---- + +fn render_login(v: &TeraView, lang: &str, error: bool) -> Result { + format::render().view( + v, + "admin/login.html", + data!({ "lang": lang, "is_admin": false, "error": error }), + ) +} + +#[debug_handler] +pub async fn login_form(ViewEngine(v): ViewEngine, jar: CookieJar) -> Result { + render_login(&v, ¤t_lang(&jar), false) +} + +#[derive(Debug, Deserialize)] +pub struct LoginForm { + pub email: String, + pub password: String, +} + +#[debug_handler] +pub async fn login_submit( + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let lang = current_lang(&jar); + let admin = admin_email(); + + if admin.is_empty() || form.email != admin { + return render_login(&v, &lang, true); + } + let Ok(user) = users::Model::find_by_email(&ctx.db, &form.email).await else { + return render_login(&v, &lang, true); + }; + if !user.verify_password(&form.password) { + return render_login(&v, &lang, true); + } + + let (secret, expiration) = + jwt_settings(&ctx).ok_or_else(|| Error::string("JWT is not configured"))?; + let token = user.generate_jwt(&secret, expiration)?; + let jar = jar.add( + Cookie::build((AUTH_COOKIE, token)) + .path("/") + .http_only(true) + .build(), + ); + Ok((jar, Redirect::to("/admin")).into_response()) +} + +#[debug_handler] +pub async fn logout(jar: CookieJar) -> Result { + let jar = jar.remove(Cookie::build((AUTH_COOKIE, "")).path("/").build()); + Ok((jar, Redirect::to("/admin/login")).into_response()) +} + +// ------------------------------------------------------------ dashboard ---- + +#[debug_handler] +pub async fn dashboard( + _auth: AdminAuth, + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, + Query(q): Query, +) -> Result { + let lang = current_lang(&jar); + let page = build_calendar(&ctx, &lang, true, q.court, q.week).await?; + format::render().view(&v, "calendar/week.html", &page) +} + +// ---------------------------------------------------------------- courts --- + +#[debug_handler] +pub async fn courts_page( + _auth: AdminAuth, + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, +) -> Result { + let lang = current_lang(&jar); + let list = courts::Entity::find() + .order_by_asc(courts::Column::Id) + .all(&ctx.db) + .await?; + let items: Vec<_> = list + .iter() + .map(|c| { + data!({ + "id": c.id, + "name": c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)), + "surface": c.surface.clone().unwrap_or_default(), + "indoor": c.indoor.unwrap_or(false), + }) + }) + .collect(); + format::render().view( + &v, + "admin/courts.html", + data!({ "lang": lang, "is_admin": true, "courts": items }), + ) +} + +#[derive(Debug, Deserialize)] +pub struct CourtForm { + pub name: String, + pub surface: Option, + pub indoor: Option, +} + +#[debug_handler] +pub async fn create_court( + _auth: AdminAuth, + State(ctx): State, + Form(form): Form, +) -> Result { + courts::ActiveModel { + name: Set(Some(form.name)), + surface: Set(form.surface.filter(|s| !s.is_empty())), + indoor: Set(Some(form.indoor.is_some())), + ..Default::default() + } + .insert(&ctx.db) + .await?; + Ok(Redirect::to("/admin/courts").into_response()) +} + +// --------------------------------------------------------------- bookings -- + +fn hour_options() -> Vec { + (FIRST_HOUR..=LAST_HOUR) + .map(|h| data!({ "v": h, "label": format!("{h:02}:00") })) + .collect() +} + +#[derive(Debug, Deserialize)] +pub struct NewBookingQuery { + pub court: Option, + pub date: Option, + pub hour: Option, +} + +#[debug_handler] +pub async fn booking_new( + _auth: AdminAuth, + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, + Query(q): Query, +) -> Result { + let lang = current_lang(&jar); + let court_list = courts::Entity::find() + .order_by_asc(courts::Column::Id) + .all(&ctx.db) + .await?; + let court_id = q + .court + .or_else(|| court_list.first().map(|c| c.id)) + .unwrap_or(0); + let court_name = court_list + .iter() + .find(|c| c.id == court_id) + .and_then(|c| c.name.clone()) + .unwrap_or_else(|| format!("Court {court_id}")); + + format::render().view( + &v, + "admin/booking_form.html", + data!({ + "lang": lang, + "is_admin": true, + "mode": "new", + "action": "/admin/booking", + "court_id": court_id, + "court_name": court_name, + "date": q.date.unwrap_or_default(), + "hour": q.hour.unwrap_or(FIRST_HOUR), + "color": "#3b82f6", + "name": "", + "contact": "", + "note": "", + "hours": hour_options(), + "booking_id": 0, + }), + ) +} + +#[derive(Debug, Deserialize)] +pub struct BookingForm { + pub court_id: i32, + pub date: String, + pub hour: i32, + pub color: String, + pub name: String, + pub contact: Option, + pub note: Option, +} + +fn parse_date(s: &str) -> Result { + chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| Error::string("invalid date")) +} + +#[debug_handler] +pub async fn booking_create( + _auth: AdminAuth, + State(ctx): State, + Form(form): Form, +) -> Result { + let date = parse_date(&form.date)?; + bookings::ActiveModel { + court_id: Set(form.court_id), + date: Set(date), + hour: Set(form.hour), + color: Set(form.color), + name: Set(form.name), + contact: Set(form.contact.filter(|s| !s.is_empty())), + note: Set(form.note.filter(|s| !s.is_empty())), + ..Default::default() + } + .insert(&ctx.db) + .await?; + Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response()) +} + +#[debug_handler] +pub async fn booking_edit( + _auth: AdminAuth, + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, + Path(id): Path, +) -> Result { + let lang = current_lang(&jar); + let booking = bookings::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + let court_name = courts::Entity::find_by_id(booking.court_id) + .one(&ctx.db) + .await? + .and_then(|c| c.name) + .unwrap_or_else(|| format!("Court {}", booking.court_id)); + + format::render().view( + &v, + "admin/booking_form.html", + data!({ + "lang": lang, + "is_admin": true, + "mode": "edit", + "action": format!("/admin/booking/{id}"), + "court_id": booking.court_id, + "court_name": court_name, + "date": booking.date.format("%Y-%m-%d").to_string(), + "hour": booking.hour, + "color": booking.color, + "name": booking.name, + "contact": booking.contact.unwrap_or_default(), + "note": booking.note.unwrap_or_default(), + "hours": hour_options(), + "booking_id": id, + }), + ) +} + +#[debug_handler] +pub async fn booking_update( + _auth: AdminAuth, + State(ctx): State, + Path(id): Path, + Form(form): Form, +) -> Result { + let date = parse_date(&form.date)?; + let booking = bookings::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or(Error::NotFound)?; + let mut active = booking.into_active_model(); + active.court_id = Set(form.court_id); + active.date = Set(date); + active.hour = Set(form.hour); + active.color = Set(form.color); + active.name = Set(form.name); + active.contact = Set(form.contact.filter(|s| !s.is_empty())); + active.note = Set(form.note.filter(|s| !s.is_empty())); + active.update(&ctx.db).await?; + Ok(Redirect::to(&format!("/admin?court={}&week={}", form.court_id, form.date)).into_response()) +} + +#[debug_handler] +pub async fn booking_delete( + _auth: AdminAuth, + State(ctx): State, + Path(id): Path, +) -> Result { + let court = bookings::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .map_or(0, |b| b.court_id); + bookings::Entity::delete_by_id(id).exec(&ctx.db).await?; + Ok(Redirect::to(&format!("/admin?court={court}")).into_response()) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("admin") + .add("/login", get(login_form)) + .add("/login", post(login_submit)) + .add("/logout", post(logout)) + .add("/", get(dashboard)) + .add("/courts", get(courts_page)) + .add("/courts", post(create_court)) + .add("/booking", get(booking_new)) + .add("/booking", post(booking_create)) + .add("/booking/{id}", get(booking_edit)) + .add("/booking/{id}", post(booking_update)) + .add("/booking/{id}/delete", post(booking_delete)) +} diff --git a/ht_booking/src/controllers/calendar.rs b/ht_booking/src/controllers/calendar.rs new file mode 100644 index 0000000..459cd74 --- /dev/null +++ b/ht_booking/src/controllers/calendar.rs @@ -0,0 +1,225 @@ +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unused_async)] +//! Public, read-only week-view calendar. +//! +//! Shows a 7-day grid (one column per day, one row per hour) for a single +//! court. Booked slots are coloured; free slots are blank. The same grid is +//! reused by the admin dashboard with `is_admin = true`. + +use axum_extra::extract::cookie::CookieJar; +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use loco_rs::prelude::*; +use sea_orm::QueryOrder; +use serde::{Deserialize, Serialize}; + +use crate::models::_entities::{bookings, courts}; + +/// First bookable hour (06:00). +pub const FIRST_HOUR: i32 = 6; +/// Last bookable hour slot start (21:00 — i.e. the 21:00-22:00 slot). +pub const LAST_HOUR: i32 = 21; + +const DAY_KEYS: [&str; 7] = [ + "day-mon", "day-tue", "day-wed", "day-thu", "day-fri", "day-sat", "day-sun", +]; + +#[derive(Debug, Deserialize)] +pub struct CalQuery { + pub court: Option, + pub week: Option, +} + +#[derive(Debug, Serialize)] +pub struct CourtOpt { + pub id: i32, + pub name: String, + pub selected: bool, +} + +#[derive(Debug, Serialize)] +pub struct DayHead { + pub date: String, + pub key: String, + pub num: u32, +} + +#[derive(Debug, Serialize)] +pub struct Cell { + pub date: String, + pub hour: i32, + pub booked: bool, + pub color: String, + pub booking_id: i32, + pub name: String, +} + +#[derive(Debug, Serialize)] +pub struct Row { + pub hour_label: String, + pub cells: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CalendarPage { + pub lang: String, + pub is_admin: bool, + pub base_path: String, + pub has_courts: bool, + pub courts: Vec, + pub court_id: i32, + pub court_name: String, + pub week: String, + pub week_label: String, + pub prev_week: String, + pub this_week: String, + pub next_week: String, + pub days: Vec, + pub rows: Vec, +} + +/// Resolves the UI language from the `lang` cookie (`sk` or `en`, default `en`). +#[must_use] +pub fn current_lang(jar: &CookieJar) -> String { + match jar.get("lang").map(|c| c.value().to_string()) { + Some(ref l) if l == "sk" => "sk".to_string(), + _ => "en".to_string(), + } +} + +fn monday_of(date: NaiveDate) -> NaiveDate { + date - Duration::days(i64::from(date.weekday().num_days_from_monday())) +} + +fn week_monday(week: Option<&str>) -> NaiveDate { + let base = week + .and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok()) + .unwrap_or_else(|| Utc::now().date_naive()); + monday_of(base) +} + +/// Builds the calendar grid for the selected court and week. +pub async fn build_calendar( + ctx: &AppContext, + lang: &str, + is_admin: bool, + q_court: Option, + q_week: Option, +) -> Result { + let court_list = courts::Entity::find() + .order_by_asc(courts::Column::Id) + .all(&ctx.db) + .await?; + + let selected = q_court + .filter(|id| court_list.iter().any(|c| c.id == *id)) + .or_else(|| court_list.first().map(|c| c.id)) + .unwrap_or(0); + + let courts_opts: Vec = court_list + .iter() + .map(|c| CourtOpt { + id: c.id, + name: c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)), + selected: c.id == selected, + }) + .collect(); + + let court_name = courts_opts + .iter() + .find(|c| c.selected) + .map(|c| c.name.clone()) + .unwrap_or_default(); + + let monday = week_monday(q_week.as_deref()); + let sunday = monday + Duration::days(6); + + let days: Vec = (0..7i64) + .map(|i| { + let d = monday + Duration::days(i); + DayHead { + date: d.format("%Y-%m-%d").to_string(), + key: DAY_KEYS[i as usize].to_string(), + num: d.day(), + } + }) + .collect(); + + let week_bookings = if selected == 0 { + Vec::new() + } else { + bookings::Entity::find() + .filter(bookings::Column::CourtId.eq(selected)) + .filter(bookings::Column::Date.gte(monday)) + .filter(bookings::Column::Date.lte(sunday)) + .all(&ctx.db) + .await? + }; + + let rows: Vec = (FIRST_HOUR..=LAST_HOUR) + .map(|hour| { + let cells = (0..7i64) + .map(|i| { + let d = monday + Duration::days(i); + let iso = d.format("%Y-%m-%d").to_string(); + match week_bookings.iter().find(|b| b.date == d && b.hour == hour) { + Some(b) => Cell { + date: iso, + hour, + booked: true, + color: b.color.clone(), + booking_id: b.id, + name: b.name.clone(), + }, + None => Cell { + date: iso, + hour, + booked: false, + color: String::new(), + booking_id: 0, + name: String::new(), + }, + } + }) + .collect(); + Row { + hour_label: format!("{hour:02}:00"), + cells, + } + }) + .collect(); + + Ok(CalendarPage { + lang: lang.to_string(), + is_admin, + base_path: if is_admin { "/admin" } else { "/" }.to_string(), + has_courts: !courts_opts.is_empty(), + courts: courts_opts, + court_id: selected, + court_name, + week: monday.format("%Y-%m-%d").to_string(), + week_label: format!("{} – {}", monday.format("%d %b"), sunday.format("%d %b %Y")), + prev_week: (monday - Duration::days(7)).format("%Y-%m-%d").to_string(), + this_week: monday_of(Utc::now().date_naive()) + .format("%Y-%m-%d") + .to_string(), + next_week: (monday + Duration::days(7)).format("%Y-%m-%d").to_string(), + days, + rows, + }) +} + +#[debug_handler] +pub async fn index( + ViewEngine(v): ViewEngine, + State(ctx): State, + jar: CookieJar, + Query(q): Query, +) -> Result { + let lang = current_lang(&jar); + let page = build_calendar(&ctx, &lang, false, q.court, q.week).await?; + format::render().view(&v, "calendar/week.html", &page) +} + +pub fn routes() -> Routes { + Routes::new().add("/", get(index)) +} diff --git a/ht_booking/src/controllers/court.rs b/ht_booking/src/controllers/court.rs deleted file mode 100644 index bb4c3d7..0000000 --- a/ht_booking/src/controllers/court.rs +++ /dev/null @@ -1,117 +0,0 @@ -#![allow(clippy::missing_errors_doc)] -#![allow(clippy::unnecessary_struct_initialization)] -#![allow(clippy::unused_async)] -use loco_rs::prelude::*; -use serde::{Deserialize, Serialize}; -use axum::response::Redirect; -use axum_extra::extract::Form; -use sea_orm::{sea_query::Order, QueryOrder}; - -use crate::{ - models::_entities::courts::{ActiveModel, Column, Entity, Model}, - views, -}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Params { - pub name: Option, - pub surface: Option, - pub indoor: Option, - } - -impl Params { - fn update(&self, item: &mut ActiveModel) { - item.name = Set(self.name.clone()); - item.surface = Set(self.surface.clone()); - item.indoor = Set(self.indoor); - } -} - -async fn load_item(ctx: &AppContext, id: i32) -> Result { - let item = Entity::find_by_id(id).one(&ctx.db).await?; - item.ok_or_else(|| Error::NotFound) -} - -#[debug_handler] -pub async fn list( - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let item = Entity::find() - .order_by(Column::Id, Order::Desc) - .all(&ctx.db) - .await?; - views::court::list(&v, &item) -} - -#[debug_handler] -pub async fn new( - ViewEngine(v): ViewEngine, - State(_ctx): State, -) -> Result { - views::court::create(&v) -} - -#[debug_handler] -pub async fn update( - Path(id): Path, - State(ctx): State, - Form(params): Form, -) -> Result { - let item = load_item(&ctx, id).await?; - let mut item = item.into_active_model(); - params.update(&mut item); - item.update(&ctx.db).await?; - Ok(Redirect::to("../courts")) -} - -#[debug_handler] -pub async fn edit( - Path(id): Path, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let item = load_item(&ctx, id).await?; - views::court::edit(&v, &item) -} - -#[debug_handler] -pub async fn show( - Path(id): Path, - ViewEngine(v): ViewEngine, - State(ctx): State, -) -> Result { - let item = load_item(&ctx, id).await?; - views::court::show(&v, &item) -} - -#[debug_handler] -pub async fn add( - State(ctx): State, - Form(params): Form, -) -> Result { - let mut item = ActiveModel { - ..Default::default() - }; - params.update(&mut item); - item.insert(&ctx.db).await?; - Ok(Redirect::to("courts")) -} - -#[debug_handler] -pub async fn remove(Path(id): Path, State(ctx): State) -> Result { - load_item(&ctx, id).await?.delete(&ctx.db).await?; - format::empty() -} - -pub fn routes() -> Routes { - Routes::new() - .prefix("courts/") - .add("/", get(list)) - .add("/", post(add)) - .add("new", get(new)) - .add("{id}", get(show)) - .add("{id}/edit", get(edit)) - .add("{id}", delete(remove)) - .add("{id}", post(update)) -} diff --git a/ht_booking/src/controllers/home.rs b/ht_booking/src/controllers/home.rs deleted file mode 100644 index b3b53cc..0000000 --- a/ht_booking/src/controllers/home.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![allow(clippy::missing_errors_doc)] -#![allow(clippy::unnecessary_struct_initialization)] -#![allow(clippy::unused_async)] -use loco_rs::prelude::*; - -#[debug_handler] -pub async fn home( - ViewEngine(v): ViewEngine, - State(_ctx): State -) -> Result { - format::render().view(&v, "home/home.html", data!({})) -} - -pub fn routes() -> Routes { - Routes::new().add("/", get(home)) -} diff --git a/ht_booking/src/controllers/mod.rs b/ht_booking/src/controllers/mod.rs index d2d5814..1b8fa67 100644 --- a/ht_booking/src/controllers/mod.rs +++ b/ht_booking/src/controllers/mod.rs @@ -1,4 +1,3 @@ +pub mod admin; pub mod auth; - -pub mod court; -pub mod home; \ No newline at end of file +pub mod calendar; diff --git a/ht_booking/src/initializers/admin_seeder.rs b/ht_booking/src/initializers/admin_seeder.rs new file mode 100644 index 0000000..04fdca6 --- /dev/null +++ b/ht_booking/src/initializers/admin_seeder.rs @@ -0,0 +1,55 @@ +//! Seeds the single hardcoded admin user (and a default court) on boot. +//! +//! Credentials are read from the environment (`.env`): +//! `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `ADMIN_NAME`. + +use async_trait::async_trait; +use loco_rs::prelude::*; + +use crate::models::_entities::courts; +use crate::models::users::{self, RegisterParams}; + +pub struct AdminSeeder; + +#[async_trait] +impl Initializer for AdminSeeder { + fn name(&self) -> String { + "admin-seeder".to_string() + } + + async fn before_run(&self, ctx: &AppContext) -> Result<()> { + let email = std::env::var("ADMIN_EMAIL").unwrap_or_default(); + let password = std::env::var("ADMIN_PASSWORD").unwrap_or_default(); + let name = std::env::var("ADMIN_NAME").unwrap_or_else(|_| "Admin".to_string()); + + if email.is_empty() || password.is_empty() { + tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env — admin not seeded"); + } else if users::Model::find_by_email(&ctx.db, &email).await.is_err() { + users::Model::create_with_password( + &ctx.db, + &RegisterParams { + email: email.clone(), + password, + name, + }, + ) + .await?; + tracing::info!(admin = %email, "admin user seeded"); + } + + // The calendar is per-court, so make sure at least one court exists. + if courts::Entity::find().one(&ctx.db).await?.is_none() { + courts::ActiveModel { + name: Set(Some("Court 1".to_string())), + surface: Set(Some("Clay".to_string())), + indoor: Set(Some(false)), + ..Default::default() + } + .insert(&ctx.db) + .await?; + tracing::info!("default court seeded"); + } + + Ok(()) + } +} diff --git a/ht_booking/src/initializers/mod.rs b/ht_booking/src/initializers/mod.rs index cbe3470..a5780a7 100644 --- a/ht_booking/src/initializers/mod.rs +++ b/ht_booking/src/initializers/mod.rs @@ -1 +1,2 @@ +pub mod admin_seeder; pub mod view_engine; diff --git a/ht_booking/src/initializers/view_engine.rs b/ht_booking/src/initializers/view_engine.rs index 2201078..0546fcd 100644 --- a/ht_booking/src/initializers/view_engine.rs +++ b/ht_booking/src/initializers/view_engine.rs @@ -24,7 +24,7 @@ impl Initializer for ViewEngineInitializer { async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { let tera_engine = if std::path::Path::new(I18N_DIR).exists() { let arc = std::sync::Arc::new( - ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) + ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en")) .shared_resources(Some(&[I18N_SHARED.into()])) .customize(|bundle| bundle.set_use_isolating(false)) .build() diff --git a/ht_booking/src/models/_entities/bookings.rs b/ht_booking/src/models/_entities/bookings.rs new file mode 100644 index 0000000..366935b --- /dev/null +++ b/ht_booking/src/models/_entities/bookings.rs @@ -0,0 +1,39 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "bookings")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + pub date: Date, + pub hour: i32, + pub color: String, + pub name: String, + pub contact: Option, + #[sea_orm(column_type = "Text", nullable)] + pub note: Option, + pub court_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::courts::Entity", + from = "Column::CourtId", + to = "super::courts::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Courts, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Courts.def() + } +} diff --git a/ht_booking/src/models/_entities/courts.rs b/ht_booking/src/models/_entities/courts.rs index eb2d77a..efcd0a4 100644 --- a/ht_booking/src/models/_entities/courts.rs +++ b/ht_booking/src/models/_entities/courts.rs @@ -16,4 +16,13 @@ pub struct Model { } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::bookings::Entity")] + Bookings, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Bookings.def() + } +} diff --git a/ht_booking/src/models/_entities/mod.rs b/ht_booking/src/models/_entities/mod.rs index bd97f98..4f93ff3 100644 --- a/ht_booking/src/models/_entities/mod.rs +++ b/ht_booking/src/models/_entities/mod.rs @@ -2,5 +2,6 @@ pub mod prelude; +pub mod bookings; pub mod courts; pub mod users; diff --git a/ht_booking/src/models/_entities/prelude.rs b/ht_booking/src/models/_entities/prelude.rs index adab66a..6382987 100644 --- a/ht_booking/src/models/_entities/prelude.rs +++ b/ht_booking/src/models/_entities/prelude.rs @@ -1,4 +1,5 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 +pub use super::bookings::Entity as Bookings; pub use super::courts::Entity as Courts; pub use super::users::Entity as Users; diff --git a/ht_booking/src/models/bookings.rs b/ht_booking/src/models/bookings.rs new file mode 100644 index 0000000..a418c0d --- /dev/null +++ b/ht_booking/src/models/bookings.rs @@ -0,0 +1,28 @@ +use sea_orm::entity::prelude::*; +pub use super::_entities::bookings::{ActiveModel, Model, Entity}; +pub type Bookings = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity {} diff --git a/ht_booking/src/models/mod.rs b/ht_booking/src/models/mod.rs index 6fc9ff4..a457492 100644 --- a/ht_booking/src/models/mod.rs +++ b/ht_booking/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod _entities; pub mod users; pub mod courts; +pub mod bookings; diff --git a/ht_booking/src/views/court.rs b/ht_booking/src/views/court.rs deleted file mode 100644 index 858003c..0000000 --- a/ht_booking/src/views/court.rs +++ /dev/null @@ -1,39 +0,0 @@ -use loco_rs::prelude::*; - -use crate::models::_entities::courts; - -/// Render a list view of `courts`. -/// -/// # Errors -/// -/// When there is an issue with rendering the view. -pub fn list(v: &impl ViewRenderer, items: &Vec) -> Result { - format::render().view(v, "court/list.html", data!({"items": items})) -} - -/// Render a single `court` view. -/// -/// # Errors -/// -/// When there is an issue with rendering the view. -pub fn show(v: &impl ViewRenderer, item: &courts::Model) -> Result { - format::render().view(v, "court/show.html", data!({"item": item})) -} - -/// Render a `court` create form. -/// -/// # Errors -/// -/// When there is an issue with rendering the view. -pub fn create(v: &impl ViewRenderer) -> Result { - format::render().view(v, "court/create.html", data!({})) -} - -/// Render a `court` edit form. -/// -/// # Errors -/// -/// When there is an issue with rendering the view. -pub fn edit(v: &impl ViewRenderer, item: &courts::Model) -> Result { - format::render().view(v, "court/edit.html", data!({"item": item})) -} diff --git a/ht_booking/src/views/mod.rs b/ht_booking/src/views/mod.rs index 5b21bf7..0e4a05d 100644 --- a/ht_booking/src/views/mod.rs +++ b/ht_booking/src/views/mod.rs @@ -1,3 +1 @@ pub mod auth; - -pub mod court; \ No newline at end of file diff --git a/ht_booking/tests/models/bookings.rs b/ht_booking/tests/models/bookings.rs new file mode 100644 index 0000000..5c63cdf --- /dev/null +++ b/ht_booking/tests/models/bookings.rs @@ -0,0 +1,31 @@ +use ht_booking::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/ht_booking/tests/models/mod.rs b/ht_booking/tests/models/mod.rs index 39bfff1..d37cd0a 100644 --- a/ht_booking/tests/models/mod.rs +++ b/ht_booking/tests/models/mod.rs @@ -1,3 +1,4 @@ mod users; -mod courts; \ No newline at end of file +mod courts; +mod bookings; \ No newline at end of file