use crate::core::model_admin::AdminUser;
use crate::core::{InlineModelAdmin, ModelAdmin};
use crate::server::admin_auth::{AdminLoginAuthenticator, AdminUserLoader};
use crate::server::type_inference::find_model_by_table_name;
use crate::types::{AdminError, AdminResult};
use async_trait::async_trait;
use dashmap::DashMap;
use parking_lot::RwLock;
use reinhardt_core::macros::injectable;
use reinhardt_db::migrations::FieldType as DbFieldType;
use reinhardt_di::{DiResult, Injectable, InjectionContext, KeyedFactoryOutput};
use std::collections::HashSet;
use std::sync::Arc;
#[injectable(scope = Singleton, prebuilt = true)]
#[derive(Clone)]
pub struct AdminSite {
name: String,
url_prefix: String,
registry: Arc<DashMap<String, Arc<dyn ModelAdmin>>>,
config: Arc<RwLock<AdminSiteConfig>>,
favicon_data: Arc<RwLock<Option<Vec<u8>>>>,
user_loader: Option<Arc<AdminUserLoader>>,
login_authenticator: Option<Arc<AdminLoginAuthenticator>>,
jwt_secret: Option<Vec<u8>>,
}
#[reinhardt_di::injectable_key]
pub struct AdminSiteKey;
#[derive(Debug, Clone)]
pub struct AdminSiteConfig {
pub site_title: String,
pub site_header: String,
pub index_title: String,
pub list_per_page: usize,
pub enable_search: bool,
pub enable_filters: bool,
}
impl Default for AdminSiteConfig {
fn default() -> Self {
Self {
site_title: "Admin Panel".into(),
site_header: "Administration".into(),
index_title: "Dashboard".into(),
list_per_page: 100,
enable_search: true,
enable_filters: true,
}
}
}
impl AdminSite {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
url_prefix: "/admin".into(),
registry: Arc::new(DashMap::new()),
config: Arc::new(RwLock::new(AdminSiteConfig::default())),
favicon_data: Arc::new(RwLock::new(None)),
user_loader: None,
login_authenticator: None,
jwt_secret: None,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn set_url_prefix(&mut self, prefix: impl Into<String>) {
self.url_prefix = prefix.into();
}
pub fn url_prefix(&self) -> &str {
&self.url_prefix
}
pub fn set_favicon(&self, data: Vec<u8>) {
*self.favicon_data.write() = Some(data);
}
pub fn favicon_data(&self) -> Option<Vec<u8>> {
self.favicon_data.read().clone()
}
pub fn configure<F>(&self, f: F)
where
F: FnOnce(&mut AdminSiteConfig),
{
let mut config = self.config.write();
f(&mut config);
}
pub fn config(&self) -> AdminSiteConfig {
self.config.read().clone()
}
pub fn set_user_type<U>(&mut self) -> &mut Self
where
U: reinhardt_auth::BaseUser
+ AdminUser
+ reinhardt_db::orm::Model
+ Clone
+ Send
+ Sync
+ 'static,
<U as reinhardt_auth::BaseUser>::PrimaryKey: std::str::FromStr + ToString + Send + Sync,
<<U as reinhardt_auth::BaseUser>::PrimaryKey as std::str::FromStr>::Err: std::fmt::Debug,
<U as reinhardt_db::orm::Model>::PrimaryKey:
From<<U as reinhardt_auth::BaseUser>::PrimaryKey>,
{
self.user_loader = Some(Arc::new(
crate::server::admin_auth::create_admin_user_loader::<U>(),
));
self.login_authenticator = Some(Arc::new(
crate::server::admin_auth::create_admin_login_authenticator::<U>(),
));
self
}
pub(crate) fn user_loader(&self) -> Option<Arc<AdminUserLoader>> {
self.user_loader.clone()
}
pub(crate) fn login_authenticator(&self) -> Option<Arc<AdminLoginAuthenticator>> {
self.login_authenticator.clone()
}
pub fn set_jwt_secret(&mut self, secret: &[u8]) -> &mut Self {
self.jwt_secret = Some(secret.to_vec());
self
}
pub(crate) fn jwt_secret(&self) -> Option<&[u8]> {
self.jwt_secret.as_deref()
}
pub fn register(
&self,
model_name: impl Into<String>,
admin: impl ModelAdmin + 'static,
) -> AdminResult<()> {
let model_name = model_name.into();
let needle = model_name.to_lowercase();
if let Some(existing) = self
.registry
.iter()
.find(|e| e.key().to_lowercase() == needle)
{
return Err(AdminError::ValidationError(format!(
"Model '{}' is already registered (as '{}')",
model_name,
existing.key()
)));
}
let table_name = admin.table_name().to_owned();
if let Some(existing) = self
.registry
.iter()
.find(|entry| entry.value().table_name() == table_name)
{
return Err(AdminError::ValidationError(format!(
"Table '{}' is already registered as '{}'",
table_name,
existing.key()
)));
}
let inlines = admin.inlines();
InlineModelAdmin::validate_for_parent(&inlines, &table_name, admin.pk_field())?;
validate_list_editable(&admin)?;
validate_actions(&admin)?;
self.registry.insert(model_name, Arc::new(admin));
Ok(())
}
pub fn unregister(&self, model_name: &str) -> AdminResult<()> {
let needle = model_name.to_lowercase();
let key = self
.registry
.iter()
.find(|entry| entry.key().to_lowercase() == needle)
.map(|entry| entry.key().clone())
.ok_or_else(|| AdminError::ModelNotRegistered(model_name.into()))?;
self.registry.remove(&key);
Ok(())
}
pub fn is_registered(&self, model_name: &str) -> bool {
let needle = model_name.to_lowercase();
self.registry
.iter()
.any(|entry| entry.key().to_lowercase() == needle)
}
pub fn get_model_admin(&self, model_name: &str) -> AdminResult<Arc<dyn ModelAdmin>> {
let needle = model_name.to_lowercase();
self.registry
.iter()
.find(|entry| entry.key().to_lowercase() == needle)
.map(|entry| Arc::clone(entry.value()))
.ok_or_else(|| AdminError::ModelNotRegistered(model_name.into()))
}
pub(crate) fn get_model_admin_by_table_name(
&self,
table_name: &str,
) -> AdminResult<Arc<dyn ModelAdmin>> {
self.registry
.iter()
.find(|entry| entry.value().table_name() == table_name)
.map(|entry| Arc::clone(entry.value()))
.ok_or_else(|| AdminError::ModelNotRegistered(table_name.into()))
}
pub fn registered_models(&self) -> Vec<String> {
self.registry
.iter()
.map(|entry| entry.key().clone())
.collect()
}
pub fn model_count(&self) -> usize {
self.registry.len()
}
pub fn clear(&self) {
self.registry.clear();
}
}
fn validate_list_editable(admin: &dyn ModelAdmin) -> AdminResult<()> {
let list_editable = admin.list_editable();
if list_editable.is_empty() {
return Ok(());
}
let list_display = admin.list_display();
let readonly_fields = admin.readonly_fields();
let mut seen = HashSet::new();
for field in &list_editable {
if !seen.insert(*field) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' appears more than once in list_editable"
)));
}
if !list_display.contains(field) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is not in list_display"
)));
}
if *field == admin.pk_field() {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is the primary key and cannot be list_editable"
)));
}
if list_display.first() == Some(field) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is the first list_display field and cannot be list_editable"
)));
}
if readonly_fields.contains(field) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is read-only and cannot be list_editable"
)));
}
}
if admin.table_name().is_empty() {
return Err(AdminError::ValidationError(
"ModelAdmin table_name cannot be empty when list_editable is configured".to_string(),
));
}
let table_name = admin.table_name();
let metadata = find_model_by_table_name(table_name).ok_or_else(|| {
AdminError::ValidationError(format!(
"Model '{}' is not registered in model metadata",
table_name
))
})?;
for field in list_editable {
let metadata = metadata.fields.get(field).ok_or_else(|| {
AdminError::ValidationError(format!("Field '{field}' is not a model field"))
})?;
if metadata.generated.is_some() {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is a generated column and cannot be list_editable"
)));
}
if metadata
.params
.get("auto_now")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
{
return Err(AdminError::ValidationError(format!(
"Field '{field}' uses auto_now and cannot be list_editable"
)));
}
if metadata
.params
.get("auto_now_add")
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
{
return Err(AdminError::ValidationError(format!(
"Field '{field}' uses auto_now_add and cannot be list_editable"
)));
}
if matches!(field, "password_hash" | "password_salt") {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is sensitive and cannot be list_editable"
)));
}
if matches!(
&metadata.field_type,
DbFieldType::Binary
| DbFieldType::Blob
| DbFieldType::TinyBlob
| DbFieldType::MediumBlob
| DbFieldType::LongBlob
| DbFieldType::Bytea
) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is binary and cannot be list_editable"
)));
}
if matches!(
&metadata.field_type,
DbFieldType::ForeignKey { .. }
| DbFieldType::OneToOne { .. }
| DbFieldType::ManyToMany { .. }
) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is a relation and cannot be list_editable"
)));
}
if let DbFieldType::Array(inner) = &metadata.field_type {
if matches!(
inner.as_ref(),
DbFieldType::Char(_)
| DbFieldType::VarChar(_)
| DbFieldType::Text
| DbFieldType::TinyText
| DbFieldType::MediumText
| DbFieldType::LongText
| DbFieldType::CIText
) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' is a string array and cannot be list_editable"
)));
}
let supported = match inner.as_ref() {
DbFieldType::Integer
| DbFieldType::BigInteger
| DbFieldType::Boolean
| DbFieldType::Float
| DbFieldType::Double
| DbFieldType::Uuid => true,
DbFieldType::Custom(name) => matches!(name.as_str(), "u8" | "u16" | "u32"),
_ => false,
};
if !supported {
return Err(AdminError::ValidationError(format!(
"Field '{field}' has an unsupported array element type and cannot be list_editable"
)));
}
}
if matches!(
&metadata.field_type,
DbFieldType::HStore
| DbFieldType::Int4Range
| DbFieldType::Int8Range
| DbFieldType::NumRange
| DbFieldType::DateRange
| DbFieldType::TsRange
| DbFieldType::TsTzRange
| DbFieldType::TsVector
| DbFieldType::TsQuery
) {
return Err(AdminError::ValidationError(format!(
"Field '{field}' has no supported inline update encoding"
)));
}
}
Ok(())
}
fn validate_actions(admin: &dyn ModelAdmin) -> AdminResult<()> {
let mut names = HashSet::new();
for action in admin.actions() {
if action.name.is_empty() {
return Err(AdminError::ValidationError(
"Admin action name cannot be empty".to_owned(),
));
}
if !names.insert(action.name.clone()) {
return Err(AdminError::ValidationError(format!(
"Admin action '{}' is registered more than once",
action.name
)));
}
}
Ok(())
}
#[async_trait]
impl Injectable for AdminSite {
async fn inject(ctx: &InjectionContext) -> DiResult<Self> {
ctx.get_singleton::<Self>()
.map(|arc| (*arc).clone())
.ok_or_else(|| reinhardt_di::DiError::NotRegistered {
type_name: "AdminSite".into(),
hint: "AdminSite must be registered as a singleton. \
Use admin_routes_with_di(site) and attach the returned \
DiRegistrationList via .with_di_registrations() on UnifiedRouter."
.into(),
})
}
}
#[reinhardt_di::injectable(scope = "singleton")]
async fn admin_site_provider(
#[inject] site: AdminSite,
) -> KeyedFactoryOutput<AdminSiteKey, AdminSite> {
KeyedFactoryOutput::new(site)
}
#[cfg(all(test, server))]
mod tests {
use super::*;
use crate::core::ModelAdminConfig;
use reinhardt_db::migrations::{
FieldMetadata, FieldType, GeneratedColumnDefinition, GeneratedStorage, ModelMetadata,
global_registry,
};
use reinhardt_di::SingletonScope;
use rstest::rstest;
use serial_test::serial;
use uuid::Uuid;
struct ModelRegistryGuard {
app_label: String,
model_name: String,
}
impl Drop for ModelRegistryGuard {
fn drop(&mut self) {
global_registry().remove_model(&self.app_label, &self.model_name);
}
}
fn register_list_editable_model(
fields: impl IntoIterator<Item = (&'static str, FieldMetadata)>,
) -> (String, ModelRegistryGuard) {
let suffix = Uuid::new_v4().simple().to_string();
let app_label = format!("admin_list_editable_{suffix}");
let model_name = format!("ListEditable{suffix}");
let mut metadata = ModelMetadata::new(&app_label, &model_name, &model_name);
for (name, field) in fields {
metadata.add_field(name.to_string(), field);
}
global_registry().register_model(metadata);
(
model_name.clone(),
ModelRegistryGuard {
app_label,
model_name,
},
)
}
struct ListEditableAdmin {
model_name: String,
pk_field: &'static str,
list_display: Vec<&'static str>,
list_editable: Vec<&'static str>,
readonly_fields: Vec<&'static str>,
}
#[async_trait]
impl ModelAdmin for ListEditableAdmin {
fn model_name(&self) -> &str {
&self.model_name
}
fn table_name(&self) -> &str {
&self.model_name
}
fn pk_field(&self) -> &str {
self.pk_field
}
fn list_display(&self) -> Vec<&str> {
self.list_display.clone()
}
fn list_editable(&self) -> Vec<&str> {
self.list_editable.clone()
}
fn readonly_fields(&self) -> Vec<&str> {
self.readonly_fields.clone()
}
}
struct EmptyActionAdmin;
#[async_trait::async_trait]
impl ModelAdmin for EmptyActionAdmin {
fn model_name(&self) -> &str {
"EmptyActionModel"
}
fn actions(&self) -> Vec<crate::types::AdminAction> {
vec![crate::types::AdminAction::new(
"",
"Empty",
crate::types::ModelPermission::Change,
false,
)]
}
}
fn list_editable_admin(
model_name: String,
list_display: Vec<&'static str>,
list_editable: Vec<&'static str>,
) -> ListEditableAdmin {
ListEditableAdmin {
model_name,
pk_field: "id",
list_display,
list_editable,
readonly_fields: vec![],
}
}
#[rstest]
fn test_admin_site_creation() {
let admin = AdminSite::new("Test Admin");
assert_eq!(admin.name(), "Test Admin");
assert_eq!(admin.url_prefix(), "/admin");
assert_eq!(admin.model_count(), 0);
}
#[rstest]
#[tokio::test]
async fn test_admin_site_resolves_through_keyed_provider() {
let singleton = Arc::new(SingletonScope::new());
let site = Arc::new(AdminSite::new("Registry Admin"));
singleton.set_arc(site);
let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
let result = reinhardt_di::KeyedDepends::<AdminSiteKey, AdminSite>::resolve_from_registry(
&ctx, true,
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap().name(), "Registry Admin");
}
#[rstest]
fn test_url_prefix() {
let mut admin = AdminSite::new("Admin");
admin.set_url_prefix("/manage");
assert_eq!(admin.url_prefix(), "/manage");
}
#[rstest]
fn test_configuration() {
let admin = AdminSite::new("Admin");
admin.configure(|config| {
config.site_title = "Custom Title".into();
config.list_per_page = 25;
});
let config = admin.config();
assert_eq!(config.site_title, "Custom Title");
assert_eq!(config.list_per_page, 25);
}
#[rstest]
fn test_register_and_unregister() {
let admin = AdminSite::new("Admin");
let model_admin = ModelAdminConfig::new("User");
assert!(!admin.is_registered("User"));
admin.register("User", model_admin).unwrap();
assert!(admin.is_registered("User"));
assert_eq!(admin.model_count(), 1);
admin.unregister("User").unwrap();
assert!(!admin.is_registered("User"));
assert_eq!(admin.model_count(), 0);
}
#[rstest]
fn test_unregister_nonexistent() {
let admin = AdminSite::new("Admin");
let result = admin.unregister("NonExistent");
assert!(result.is_err());
}
#[rstest]
fn test_get_model_admin() {
let admin = AdminSite::new("Admin");
let model_admin = ModelAdminConfig::new("User");
admin.register("User", model_admin).unwrap();
let retrieved = admin.get_model_admin("User");
assert!(retrieved.is_ok());
}
#[rstest]
fn test_get_nonexistent_model_admin() {
let admin = AdminSite::new("Admin");
let result = admin.get_model_admin("NonExistent");
assert!(result.is_err());
}
#[rstest]
fn test_get_model_admin_by_table_name_ignores_route_alias() {
let site = AdminSite::new("Admin");
let model = ModelAdminConfig::builder()
.model_name("Child")
.table_name("child_records")
.build()
.expect("child admin should build");
site.register("child-route", model)
.expect("child route should register");
let admin = site
.get_model_admin_by_table_name("child_records")
.expect("table identity should resolve the child admin");
assert_eq!(admin.model_name(), "Child");
}
#[rstest]
fn test_registered_models() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
admin
.register("Post", ModelAdminConfig::new("Post"))
.unwrap();
let models = admin.registered_models();
assert_eq!(models.len(), 2);
assert!(models.contains(&"User".into()));
assert!(models.contains(&"Post".into()));
}
#[rstest]
fn test_clear() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
admin
.register("Post", ModelAdminConfig::new("Post"))
.unwrap();
assert_eq!(admin.model_count(), 2);
admin.clear();
assert_eq!(admin.model_count(), 0);
}
#[rstest]
fn test_duplicate_registration_returns_error() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
let result = admin.register("User", ModelAdminConfig::new("User"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("already registered"));
}
#[rstest]
fn test_default_config() {
let config = AdminSiteConfig::default();
assert_eq!(config.site_title, "Admin Panel");
assert_eq!(config.site_header, "Administration");
assert_eq!(config.list_per_page, 100);
assert!(config.enable_search);
assert!(config.enable_filters);
}
#[rstest]
fn test_set_arc_stores_admin_site_with_correct_type_id() {
let singleton = SingletonScope::new();
let site = Arc::new(AdminSite::new("Test Admin"));
singleton.set_arc(site);
assert!(
singleton.get::<AdminSite>().is_some(),
"AdminSite should be retrievable via get::<AdminSite>()"
);
}
#[rstest]
fn test_set_arc_preserves_favicon_data() {
let singleton = SingletonScope::new();
let site = Arc::new(AdminSite::new("Test Admin"));
let favicon = vec![0x89, 0x50, 0x4E, 0x47];
site.set_favicon(favicon.clone());
singleton.set_arc(site);
let retrieved = singleton.get::<AdminSite>().unwrap();
assert_eq!(retrieved.favicon_data(), Some(favicon));
}
#[rstest]
#[tokio::test]
async fn test_admin_site_inject_resolves_from_singleton() {
let singleton = Arc::new(SingletonScope::new());
let site = Arc::new(AdminSite::new("Injectable Admin"));
singleton.set_arc(site);
let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
let result = AdminSite::inject(&ctx).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().name(), "Injectable Admin");
}
#[rstest]
#[tokio::test]
async fn test_admin_site_inject_returns_error_when_not_registered() {
let singleton = Arc::new(SingletonScope::new());
let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
let result = AdminSite::inject(&ctx).await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(
err.to_string().contains("AdminSite"),
"Error should mention AdminSite, got: {}",
err
);
}
#[rstest]
#[tokio::test]
async fn test_admin_site_inject_error_hint_mentions_routes_with_di() {
let singleton = Arc::new(SingletonScope::new());
let ctx = reinhardt_di::InjectionContext::builder(singleton).build();
let result = AdminSite::inject(&ctx).await;
assert!(result.is_err());
let err = result.err().unwrap();
assert!(
err.to_string().contains("admin_routes_with_di"),
"Error hint should mention admin_routes_with_di, got: {}",
err
);
}
#[rstest]
fn test_get_model_admin_case_insensitive() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
assert!(admin.get_model_admin("User").is_ok());
assert!(admin.get_model_admin("user").is_ok());
assert!(admin.get_model_admin("USER").is_ok());
assert!(admin.get_model_admin("uSeR").is_ok());
assert!(admin.get_model_admin("nonexistent").is_err());
}
#[rstest]
fn test_is_registered_case_insensitive() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
assert!(admin.is_registered("User"));
assert!(admin.is_registered("user"));
assert!(admin.is_registered("USER"));
assert!(!admin.is_registered("Post"));
}
#[rstest]
fn test_register_rejects_case_insensitive_duplicate() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
let result = admin.register("user", ModelAdminConfig::new("user"));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("already registered")
);
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_accepts_valid_list_editable_field() {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
("title", FieldMetadata::new(FieldType::Text)),
]);
let site = AdminSite::new("Admin");
let result = site.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "title"], vec!["title"]),
);
result.expect("valid inline field should register");
assert_eq!(site.model_count(), 1);
}
#[rstest]
#[serial(admin_model_registry)]
#[case::duplicate(
vec!["id", "title"],
vec!["title", "title"],
None,
"Field 'title' appears more than once in list_editable"
)]
#[case::missing_from_display(
vec!["id", "title"],
vec!["status"],
None,
"Field 'status' is not in list_display"
)]
#[case::primary_key(
vec!["title", "id"],
vec!["id"],
None,
"Field 'id' is the primary key and cannot be list_editable"
)]
#[case::first_display_link(
vec!["title", "id"],
vec!["title"],
None,
"Field 'title' is the first list_display field and cannot be list_editable"
)]
#[case::readonly(
vec!["id", "status"],
vec!["status"],
Some("status"),
"Field 'status' is read-only and cannot be list_editable"
)]
#[case::unknown_metadata(
vec!["id", "computed"],
vec!["computed"],
None,
"Field 'computed' is not a model field"
)]
fn test_register_rejects_invalid_list_editable(
#[case] list_display: Vec<&'static str>,
#[case] list_editable: Vec<&'static str>,
#[case] readonly_field: Option<&'static str>,
#[case] expected: &str,
) {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
("title", FieldMetadata::new(FieldType::Text)),
("status", FieldMetadata::new(FieldType::Text)),
("generated", FieldMetadata::new(FieldType::Text)),
]);
let site = AdminSite::new("Admin");
let mut model_admin = list_editable_admin(model_name.clone(), list_display, list_editable);
if let Some(field) = readonly_field {
model_admin.readonly_fields.push(field);
}
let error = site
.register(model_name, model_admin)
.expect_err("invalid inline field must reject registration");
assert!(matches!(
error,
AdminError::ValidationError(message) if message == expected
));
assert_eq!(site.model_count(), 0);
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_rejects_generated_list_editable_field() {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
("title", FieldMetadata::new(FieldType::Text)),
("status", FieldMetadata::new(FieldType::Text)),
(
"generated",
FieldMetadata::new(FieldType::Text).with_generated(
GeneratedColumnDefinition::tokens(
"SchemaExpr::col(\"title\")",
GeneratedStorage::Stored,
),
),
),
]);
let site = AdminSite::new("Admin");
let error = site
.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "generated"], vec!["generated"]),
)
.expect_err("generated inline field must reject registration");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Field 'generated' is a generated column and cannot be list_editable"
));
assert_eq!(site.model_count(), 0);
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_rejects_binary_list_editable_fields() {
for field_type in [
FieldType::Binary,
FieldType::Blob,
FieldType::TinyBlob,
FieldType::MediumBlob,
FieldType::LongBlob,
FieldType::Bytea,
] {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
("payload", FieldMetadata::new(field_type)),
]);
let site = AdminSite::new("Admin");
let error = site
.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "payload"], vec!["payload"]),
)
.expect_err("binary fields must not be inline editable");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Field 'payload' is binary and cannot be list_editable"
));
assert_eq!(site.model_count(), 0);
}
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_rejects_auto_now_list_editable_field() {
for value in ["true", "True"] {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
(
"updated_at",
FieldMetadata::new(FieldType::DateTime).with_param("auto_now", value),
),
]);
let site = AdminSite::new("Admin");
let error = site
.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "updated_at"], vec!["updated_at"]),
)
.expect_err("auto_now fields must not be inline editable");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Field 'updated_at' uses auto_now and cannot be list_editable"
));
assert_eq!(site.model_count(), 0);
}
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_rejects_relation_list_editable_field() {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
(
"owner",
FieldMetadata::new(FieldType::ForeignKey {
to_table: "accounts".to_string(),
to_field: "id".to_string(),
on_delete: reinhardt_db::migrations::ForeignKeyAction::Cascade,
}),
),
]);
let site = AdminSite::new("Admin");
let error = site
.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "owner"], vec!["owner"]),
)
.expect_err("relation fields must not be inline editable");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Field 'owner' is a relation and cannot be list_editable"
));
assert_eq!(site.model_count(), 0);
}
#[rstest]
#[serial(admin_model_registry)]
fn test_register_rejects_string_array_list_editable_field() {
let (model_name, _guard) = register_list_editable_model([
("id", FieldMetadata::new(FieldType::Integer)),
(
"tags",
FieldMetadata::new(FieldType::Array(Box::new(FieldType::VarChar(32)))),
),
]);
let site = AdminSite::new("Admin");
let error = site
.register(
model_name.clone(),
list_editable_admin(model_name, vec!["id", "tags"], vec!["tags"]),
)
.expect_err("string arrays must not be inline editable");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Field 'tags' is a string array and cannot be list_editable"
));
assert_eq!(site.model_count(), 0);
}
#[rstest]
fn test_register_rejects_duplicate_table_name() {
let site = AdminSite::new("Admin");
site.register(
"ChildPrimary",
ModelAdminConfig::builder()
.model_name("ChildPrimary")
.table_name("child_records")
.build()
.expect("first admin should build"),
)
.expect("first table registration should succeed");
let error = site
.register(
"ChildAlias",
ModelAdminConfig::builder()
.model_name("ChildAlias")
.table_name("child_records")
.build()
.expect("second admin should build"),
)
.expect_err("duplicate table registration must be rejected");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Table 'child_records' is already registered as 'ChildPrimary'"
));
}
#[rstest]
fn test_register_rejects_empty_action_name() {
let site = AdminSite::new("Admin");
let error = site
.register("EmptyAction", EmptyActionAdmin)
.expect_err("empty action names must be rejected");
assert!(matches!(
error,
AdminError::ValidationError(message)
if message == "Admin action name cannot be empty"
));
}
#[rstest]
fn test_unregister_case_insensitive() {
let admin = AdminSite::new("Admin");
admin
.register("User", ModelAdminConfig::new("User"))
.unwrap();
admin.unregister("user").unwrap();
assert!(!admin.is_registered("User"));
}
}