diff --git a/Cargo.lock b/Cargo.lock index b6f84a9..90c3740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3467,7 +3467,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "0.1.13" +version = "0.1.14" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a032de1..38d1300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "0.1.14" +version = "0.1.15" edition = "2024" [dependencies] diff --git a/src/admin.rs b/src/admin.rs index 64c2f26..cbbf0eb 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -366,7 +366,7 @@ struct ScheduleEditTemplate<'a> { lang: Lang, admin_name: &'a str, visit: Visit, - clients: Vec, + client: Client, users: Vec, media: Vec, } @@ -943,8 +943,10 @@ async fn admin_index(request: Request, session: Session, db: Database) -> cot::R let tz = crate::tz::load_tz(&db).await; let today = crate::tz::today_in_tz(tz); - let all_visits = Visit::objects().all(&db).await?; - let clients = Client::objects().all(&db).await?; + let mut all_visits = Visit::objects().all(&db).await?; + all_visits.retain(|v| v.status != "deleted"); + let mut clients = Client::objects().all(&db).await?; + clients.retain(|c| c.status != "deleted"); let mut today_visits: Vec = all_visits .iter() @@ -1046,11 +1048,12 @@ async fn clients_page(request: Request, session: Session, db: Database) -> cot:: Err(resp) => return Ok(resp), }; let show_all = has_query_flag(&request, "all"); - let clients = if show_all { + let mut clients = if show_all { Client::objects().all(&db).await? } else { query!(Client, $status == "active").all(&db).await? }; + clients.retain(|c| c.status != "deleted"); let body = ClientsTemplate { t: lang.t(), lang, @@ -1464,6 +1467,24 @@ async fn client_activate( Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response() } +async fn client_delete( + request: Request, + session: Session, + db: Database, + Path(client_id): Path, +) -> cot::Result { + let lang = detect_lang(&request); + if let Err(resp) = require_auth(&session, lang).await { + return Ok(resp); + } + if let Some(mut client) = query!(Client, $id == client_id).get(&db).await? { + client.status = "deleted".to_string(); + client.updated_at = now_utc(); + client.save(&db).await?; + } + Redirect::new(format!("/admin/clients?lang={}", lang.code())).into_response() +} + async fn user_archive( request: Request, session: Session, @@ -1681,12 +1702,18 @@ async fn schedule_events( let mut events = Vec::new(); for v in &visits { + if v.status == "deleted" { + continue; + } if v.visit_date < start_date || v.visit_date > end_date { continue; } let client_id_val: i64 = v.client_id.primary_key().unwrap(); let user_id_val: i64 = v.user_id.primary_key().unwrap(); let client = clients.iter().find(|c| c.id.unwrap() == client_id_val); + if client.map(|c| c.status.as_str()) == Some("deleted") { + continue; + } let user = users.iter().find(|u| u.id.unwrap() == user_id_val); let client_name = client.map(|c| c.name.as_str()).unwrap_or("?"); let client_phone = client.and_then(|c| c.phone.as_deref()).unwrap_or(""); @@ -1810,7 +1837,16 @@ async fn schedule_edit_page( return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); } }; - let clients = query!(Client, $status == "active").all(&db).await?; + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } + let client_id: i64 = visit.client_id.primary_key().unwrap(); + let client = match query!(Client, $id == client_id).get(&db).await? { + Some(c) => c, + None => { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } + }; let users = query!(User, $status == "active").all(&db).await?; let mut visit_media = Media::objects().all(&db).await?; visit_media.retain(|m| { @@ -1826,7 +1862,7 @@ async fn schedule_edit_page( lang, admin_name: &admin_name, visit, - clients, + client, users, media: visit_media, } @@ -1836,7 +1872,6 @@ async fn schedule_edit_page( #[derive(Deserialize)] struct EditVisitForm { - client_id: i64, user_id: i64, visit_date: String, time_start: String, @@ -1857,7 +1892,9 @@ async fn schedule_edit_submit( return Ok(resp); } if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { - visit.client_id = ForeignKey::PrimaryKey(Auto::fixed(form.client_id)); + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id)); if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") { visit.visit_date = d; @@ -1883,7 +1920,11 @@ async fn visit_delete( if let Err(resp) = require_auth(&session, lang).await { return Ok(resp); } - query!(Visit, $id == visit_id).delete(&db).await?; + if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { + visit.status = "deleted".to_string(); + visit.updated_at = now_utc(); + visit.save(&db).await?; + } Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response() } @@ -1898,6 +1939,9 @@ async fn visit_set_done( return Ok(resp); } if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } visit.status = "completed".to_string(); visit.updated_at = now_utc(); visit.save(&db).await?; @@ -1916,6 +1960,9 @@ async fn visit_set_cancel( return Ok(resp); } if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } visit.status = "cancelled".to_string(); visit.updated_at = now_utc(); visit.save(&db).await?; @@ -1945,16 +1992,40 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re }) .unwrap_or(0); + let clients_all = Client::objects().all(&db).await?; + let visits_all = Visit::objects().all(&db).await?; let mut media_list = Media::objects().all(&db).await?; - media_list.retain(|m| m.status == "active"); + media_list.retain(|m| { + if m.status != "active" { + return false; + } + let cid: i64 = m.client_id.primary_key().unwrap(); + if clients_all + .iter() + .find(|c| c.id.unwrap() == cid) + .map(|c| c.status.as_str()) + == Some("deleted") + { + return false; + } + if let Some(fk) = &m.visit_id { + let vid: i64 = fk.primary_key().unwrap(); + if visits_all + .iter() + .find(|v| v.id.unwrap() == vid) + .map(|v| v.status.as_str()) + == Some("deleted") + { + return false; + } + } + true + }); if filter_client_id > 0 { media_list.retain(|m| m.client_id.primary_key().unwrap() == filter_client_id); } media_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - let clients_all = Client::objects().all(&db).await?; - let visits_all = Visit::objects().all(&db).await?; - let items: Vec = media_list .into_iter() .map(|m| { @@ -2008,6 +2079,9 @@ async fn media_upload_page( Some(v) => v, None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(), }; + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } let cid: i64 = visit.client_id.primary_key().unwrap(); let client = query!(Client, $id == cid).get(&db).await?; let client_name = client.map(|c| c.name).unwrap_or_default(); @@ -2056,6 +2130,9 @@ async fn media_upload_submit( Some(v) => v, None => return Redirect::new(format!("/admin/?lang={}", lang.code())).into_response(), }; + if visit.status == "deleted" { + return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); + } let client_id: i64 = visit.client_id.primary_key().unwrap(); let bytes = request.into_body().into_bytes().await?; @@ -2591,6 +2668,11 @@ pub fn admin_router() -> Router { client_activate, "admin-client-activate", ), + Route::with_handler_and_name( + "/clients/{client_id}/delete", + client_delete, + "admin-client-delete", + ), Route::with_handler_and_name("/schedule", schedule_page, "admin-schedule"), Route::with_handler_and_name("/schedule/new", schedule_new_page, "admin-schedule-new"), Route::with_handler_and_name("/schedule/events", schedule_events, "admin-schedule-events"), diff --git a/src/i18n.rs b/src/i18n.rs index e77dabf..d1ef239 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -103,8 +103,11 @@ pub struct Translations { pub clients_media_link: &'static str, pub clients_add_title: &'static str, pub clients_add_button: &'static str, + pub clients_delete: &'static str, + pub clients_delete_confirm: &'static str, pub client_status_active: &'static str, pub client_status_archived: &'static str, + pub client_status_deleted: &'static str, // Users pub users_title: &'static str, @@ -256,6 +259,7 @@ pub struct Translations { pub visit_status_scheduled: &'static str, pub visit_status_completed: &'static str, pub visit_status_cancelled: &'static str, + pub visit_status_deleted: &'static str, pub schedule_mark_done: &'static str, pub schedule_cancel: &'static str, pub schedule_edit_title: &'static str, @@ -336,8 +340,11 @@ static RU: Translations = Translations { clients_media_link: "Медиа", clients_add_title: "Добавить клиента", clients_add_button: "Добавить", + clients_delete: "Удалить клиента", + clients_delete_confirm: "Точно удалить этого клиента?", client_status_active: "Активный", client_status_archived: "Архив", + client_status_deleted: "Удалён", users_title: "Администраторы", users_login: "Логин", @@ -453,6 +460,7 @@ static RU: Translations = Translations { visit_status_scheduled: "Запланирован", visit_status_completed: "Выполнен", visit_status_cancelled: "Отменён", + visit_status_deleted: "Удалён", schedule_mark_done: "Выполнен", schedule_cancel: "Отменить", schedule_edit_title: "Редактировать визит", @@ -557,8 +565,11 @@ static EN: Translations = Translations { clients_media_link: "Media", clients_add_title: "Add Client", clients_add_button: "Add", + clients_delete: "Delete client", + clients_delete_confirm: "Are you sure you want to delete this client?", client_status_active: "Active", client_status_archived: "Archived", + client_status_deleted: "Deleted", users_title: "Administrators", users_login: "Login", @@ -674,6 +685,7 @@ static EN: Translations = Translations { visit_status_scheduled: "Scheduled", visit_status_completed: "Completed", visit_status_cancelled: "Cancelled", + visit_status_deleted: "Deleted", schedule_mark_done: "Done", schedule_cancel: "Cancel", schedule_edit_title: "Edit Visit", @@ -760,6 +772,7 @@ impl Translations { "scheduled" => self.visit_status_scheduled, "completed" => self.visit_status_completed, "cancelled" => self.visit_status_cancelled, + "deleted" => self.visit_status_deleted, _ => "?", } } @@ -768,6 +781,7 @@ impl Translations { match status { "active" => self.client_status_active, "archived" => self.client_status_archived, + "deleted" => self.client_status_deleted, _ => "?", } } diff --git a/src/main.rs b/src/main.rs index addbc01..0e066b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,14 +51,32 @@ impl App for PublicApp { struct PettingProject; +fn parse_bool_env(name: &str) -> Option { + let value = std::env::var(name).ok()?; + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +fn debug_enabled(config_name: &str) -> bool { + parse_bool_env("WEB_PETTING_DEBUG").unwrap_or_else(|| { + matches!( + config_name, + "dev" | "development" | "debug" | "local" | "test" + ) + }) +} + impl Project for PettingProject { fn cli_metadata(&self) -> CliMetadata { cot::cli::metadata!() } - fn config(&self, _config_name: &str) -> cot::Result { + fn config(&self, config_name: &str) -> cot::Result { Ok(ProjectConfig::builder() - .debug(true) + .debug(debug_enabled(config_name)) .database( DatabaseConfig::builder() .url("sqlite://db.sqlite3?mode=rwc") diff --git a/src/models.rs b/src/models.rs index a510919..2358821 100644 --- a/src/models.rs +++ b/src/models.rs @@ -43,6 +43,7 @@ pub enum VisitStatus { Scheduled, Completed, Cancelled, + Deleted, } impl VisitStatus { @@ -51,6 +52,7 @@ impl VisitStatus { Self::Scheduled => "scheduled", Self::Completed => "completed", Self::Cancelled => "cancelled", + Self::Deleted => "deleted", } } } @@ -146,7 +148,7 @@ pub struct Visit { pub public_notes: Option, /// Feedback text from client via portal. pub client_feedback: Option, - /// scheduled | completed | cancelled + /// scheduled | completed | cancelled | deleted pub status: String, pub created_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime, diff --git a/src/public.rs b/src/public.rs index 27a56bc..22f28c6 100644 --- a/src/public.rs +++ b/src/public.rs @@ -240,7 +240,8 @@ async fn client_portal( .unwrap_or(false); let client = match query!(Client, $media_token == token).get(&db).await? { - Some(c) => c, + Some(c) if c.status != "deleted" => c, + Some(_) => return Html::new("404").into_response(), None => return Html::new("404").into_response(), }; @@ -249,7 +250,11 @@ async fn client_portal( let today = crate::tz::today_in_tz(tz); let mut visits = Visit::objects().all(&db).await?; - visits.retain(|v| v.client_id.primary_key().unwrap() == client_id && v.status != "cancelled"); + visits.retain(|v| { + v.client_id.primary_key().unwrap() == client_id + && v.status != "cancelled" + && v.status != "deleted" + }); visits.sort_by(|a, b| { a.visit_date .cmp(&b.visit_date) @@ -327,7 +332,8 @@ async fn submit_feedback( // Verify token matches visit's client let token_clone = token.clone(); let client = match query!(Client, $media_token == token).get(&db).await? { - Some(c) => c, + Some(c) if c.status != "deleted" => c, + Some(_) => return Html::new("404").into_response(), None => return Html::new("404").into_response(), }; let client_id = client.id.unwrap(); @@ -346,6 +352,14 @@ async fn submit_feedback( } if let Some(mut visit) = query!(Visit, $id == visit_id).get(&db).await? { + if visit.status == "deleted" { + return Redirect::new(format!( + "/client/{}?lang={}", + token_clone, + lang.code() + )) + .into_response(); + } if visit.client_id.primary_key().unwrap() == client_id { visit.client_feedback = Some(form.feedback); visit.updated_at = now_utc(); @@ -369,7 +383,8 @@ async fn portal_media( ) -> cot::Result { // Verify token let client = match query!(Client, $media_token == token).get(&db).await? { - Some(c) => c, + Some(c) if c.status != "deleted" => c, + Some(_) => return Html::new("404").into_response(), None => return Html::new("404").into_response(), }; let client_id = client.id.unwrap(); @@ -378,6 +393,13 @@ async fn portal_media( Some(m) if m.client_id.primary_key().unwrap() == client_id && m.status == "active" => m, _ => return Html::new("404").into_response(), }; + if let Some(fk) = &media.visit_id { + let visit_id: i64 = fk.primary_key().unwrap(); + match query!(Visit, $id == visit_id).get(&db).await? { + Some(v) if v.status != "deleted" => {} + _ => return Html::new("404").into_response(), + } + } match tokio::fs::read(&media.file_path).await { Ok(data) => { diff --git a/templates/admin/client_form.html b/templates/admin/client_form.html index 35f3986..5d2ced2 100644 --- a/templates/admin/client_form.html +++ b/templates/admin/client_form.html @@ -76,11 +76,16 @@
- {% else %} + {% else if client_status == "archived" %}
{% endif %} + {% if client_status != "deleted" %} +
+ +
+ {% endif %} {% endif %} {% endblock %} diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index c13a31c..511485b 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -14,15 +14,7 @@
-
- -
+
@@ -132,6 +124,9 @@ {% if let Some(cap) = m.caption.as_deref() %}
{{ cap }}
{% endif %} +
+ +
{% endfor %} @@ -141,6 +136,9 @@ + {% for m in &media %} +
+ {% endfor %}
@@ -254,6 +252,14 @@ overflow: hidden; text-overflow: ellipsis; } + .visit-media-delete { + padding: 0.25rem 0.4rem 0.4rem; + } + .visit-media-delete .button { + width: 100%; + font-size: 0.68rem; + min-height: 1.65rem; + } .upload-modal-bg { display: none; position: fixed;