mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add permanent device deletion and refine frontend UI/snackbars
Backend:
- Add db::devices::delete to erase a device and its events atomically
- Expose DELETE /api/devices/{mac}/permanently, wired to OpenAPI
- Cover the new db method and endpoint with tests
Frontend:
- Delete action for not-registered devices (detail screen + list row)
and an opt-in "permanently delete" checkbox in the Forget dialog
- Navigate to the devices list after deleting from the detail screen
- Refine button emphasis to M3: single filled primary, error-colored
text buttons for destructive actions, Test demoted to filled-tonal
- Flash the backend-config Test button red on a failed connection test
- Render snackbars through a top-level ScaffoldMessenger host so they
show above dialogs; keep the built-in SnackBar (with an Overlay host)
Docs:
- CLAUDE.md: rustfmt edition 2024, don't revert formatter-only changes,
prefer built-in Flutter components, follow existing patterns + M3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2541d0989c
commit
587e097291
@@ -406,6 +406,28 @@ pub fn unregister(mac_address: String) -> Result<(), DbError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently deletes a device and all of its related events. Both deletes run inside a single
|
||||
/// transaction so the device and its history are removed atomically. Returns the number of device
|
||||
/// rows removed (0 when the device did not exist).
|
||||
pub fn delete(mac_address: String) -> Result<usize, DbError> {
|
||||
let mut conn = db::get_db_connection()?;
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"DELETE FROM device_events WHERE mac_address=?1",
|
||||
params![mac_address],
|
||||
)?;
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM devices WHERE mac_address=?1",
|
||||
params![mac_address],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
debug!("Deleted device {mac_address} and its events (device rows removed: {deleted})");
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
@@ -1086,6 +1108,55 @@ mod tests {
|
||||
assert_eq!(device.device_type, "Phone".to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete() {
|
||||
use crate::db::device_events;
|
||||
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
||||
|
||||
tests_common::setup().await;
|
||||
|
||||
let last_seen = Utc::now();
|
||||
insert(Device::new(
|
||||
"rr:rr:rr:rr:rr:03".to_string(),
|
||||
"192.168.230.3".to_string(),
|
||||
"Test vendor".to_string(),
|
||||
last_seen,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
device_events::insert(DeviceEvent::new(
|
||||
"rr:rr:rr:rr:rr:03".to_string(),
|
||||
Utc::now(),
|
||||
DeviceEventType::NewDevice,
|
||||
"192.168.230.3".to_string(),
|
||||
"Test vendor".to_string(),
|
||||
DeviceEventScanner::Arp,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete("rr:rr:rr:rr:rr:03".to_string()).unwrap();
|
||||
assert_eq!(deleted, 1, "One device row should be removed");
|
||||
|
||||
assert!(
|
||||
read("rr:rr:rr:rr:rr:03".to_string()).is_none(),
|
||||
"Device should no longer exist"
|
||||
);
|
||||
assert!(
|
||||
device_events::list(Some("rr:rr:rr:rr:rr:03".to_string()), None, None, None)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"All device events should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_unknown_device() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let deleted = delete("rr:rr:rr:rr:rr:99".to_string()).unwrap();
|
||||
assert_eq!(deleted, 0, "Deleting an unknown device removes no rows");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_insert() {
|
||||
tests_common::setup().await;
|
||||
|
||||
@@ -60,6 +60,7 @@ pub mod utils;
|
||||
devices::register,
|
||||
devices::update,
|
||||
devices::unregister,
|
||||
devices::delete,
|
||||
notifications::list,
|
||||
notifications::send_test,
|
||||
notifications::read,
|
||||
@@ -177,6 +178,10 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
.route("/api/devices", put(devices::register))
|
||||
.route("/api/devices/summary", get(devices::summary))
|
||||
.route("/api/devices/{mac_address}", delete(devices::unregister))
|
||||
.route(
|
||||
"/api/devices/{mac_address}/permanently",
|
||||
delete(devices::delete),
|
||||
)
|
||||
.route("/api/devices/{mac_address}", get(devices::read))
|
||||
.route("/api/devices/{mac_address}", put(devices::update))
|
||||
.route(
|
||||
@@ -204,10 +209,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
post(notifications::mark_as_new),
|
||||
)
|
||||
.route("/api/push_tokens", put(push_tokens::register))
|
||||
.route(
|
||||
"/api/push_tokens/{token}",
|
||||
delete(push_tokens::unregister),
|
||||
)
|
||||
.route("/api/push_tokens/{token}", delete(push_tokens::unregister))
|
||||
.route_layer(axum::middleware::from_fn(auth))
|
||||
.layer(ServiceBuilder::new().layer(cors_layer))
|
||||
// Send visitors straight to the UI; the bare "/" has no content of its own.
|
||||
|
||||
@@ -280,6 +280,43 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/devices/{mac_address}/permanently",
|
||||
tag = "devices",
|
||||
params(
|
||||
("mac_address" = String, Path, description = "MAC address of the device"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Device permanently deleted"),
|
||||
(status = 404, description = "Device not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete(Path(mac_address): Path<String>) -> impl IntoResponse {
|
||||
db::run_blocking(move || {
|
||||
if db::devices::read(mac_address.clone()).is_none() {
|
||||
return (
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"Device not found or could not be read",
|
||||
);
|
||||
}
|
||||
|
||||
match db::devices::delete(mac_address) {
|
||||
Ok(_) => (axum::http::StatusCode::OK, "Device permanently deleted"),
|
||||
Err(err) => {
|
||||
error!("Error deleting device from the database: {}", err);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error deleting device in the server, check your logs",
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/devices/summary",
|
||||
@@ -317,3 +354,35 @@ pub struct UpdateDevicePayload {
|
||||
vendor: String,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests_common;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_existing_device() {
|
||||
tests_common::setup().await;
|
||||
|
||||
// Seed device from tests/database_setup/02-devices.sql
|
||||
let mac = "aa:aa:aa:aa:aa:aa".to_string();
|
||||
assert!(db::devices::read(mac.clone()).is_some());
|
||||
|
||||
let response = delete(Path(mac.clone())).await.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
db::devices::read(mac).is_none(),
|
||||
"Deleted device should be gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_device_returns_404() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let response = delete(Path("ff:ff:ff:ff:ff:ff".to_string()))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
db, notifications,
|
||||
db,
|
||||
model::notifications::{Notification, NotificationListResponse},
|
||||
notifications,
|
||||
web_server::utils,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,16 +20,18 @@ use crate::model::push_tokens::PushPlatform;
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn register(Json(payload): Json<RegisterPushTokenPayload>) -> impl IntoResponse {
|
||||
db::run_blocking(move || match db::push_tokens::upsert(&payload.token, payload.platform) {
|
||||
Ok(_) => (StatusCode::OK, "Push token registered"),
|
||||
Err(err) => {
|
||||
error!("Error registering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error registering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
})
|
||||
db::run_blocking(
|
||||
move || match db::push_tokens::upsert(&payload.token, payload.platform) {
|
||||
Ok(_) => (StatusCode::OK, "Push token registered"),
|
||||
Err(err) => {
|
||||
error!("Error registering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error registering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user