use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::error::Result;
use crate::http::FormData;
use crate::orm::{Db, Value};
pub(crate) type CreateResult<'a> =
Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
pub(crate) type UpdateResult<'a> =
Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
#[derive(Debug, Clone, serde::Serialize)]
pub struct UserProfileSection {
pub label: String,
pub rows: Vec<UserProfileRow>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UserProfileRow {
pub label: String,
pub value: String,
}
pub(crate) type UserProfileExtensionFn =
Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
pub(crate) type UserProfileExtensionFuture =
Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FieldType {
I32,
I64,
Bool,
String,
DateTime,
OptionalI64,
OptionalString,
OptionalDateTime,
}
impl FieldType {
pub fn widget(&self) -> &'static str {
match self {
FieldType::Bool => "checkbox",
FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
FieldType::String | FieldType::OptionalString => "text",
}
}
pub fn nullable(&self) -> bool {
matches!(
self,
FieldType::OptionalI64 | FieldType::OptionalString | FieldType::OptionalDateTime
)
}
}
#[derive(Debug, Clone)]
pub struct AdminField {
pub name: &'static str,
pub label: &'static str,
pub field_type: FieldType,
pub editable: bool,
pub relation: Option<AdminRelation>,
pub choices: Option<&'static [&'static str]>,
}
#[derive(Debug, Clone)]
pub struct AdminRelation {
pub target_model: &'static str,
pub display_field: Option<&'static str>,
pub multi: bool,
}
pub trait AdminModel: Send + Sync + 'static {
const ADMIN_NAME: &'static str;
const DISPLAY_NAME: &'static str;
const SINGULAR_NAME: &'static str;
const FIELDS: &'static [AdminField];
fn display_values(&self) -> Vec<(String, String)>;
fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
where
Self: Sized;
fn object_label(&self) -> String;
fn id(&self) -> i64;
fn values_to_update(&self) -> Vec<(&'static str, Value)>;
}
pub struct AdminEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub table: &'static str,
pub fields: &'static [AdminField],
pub core: bool,
pub list_display: &'static [&'static str],
pub list_filter: &'static [&'static str],
pub search_fields: &'static [&'static str],
pub ordering: &'static [&'static str],
pub list_per_page: usize,
pub readonly_fields: &'static [&'static str],
pub fieldsets: &'static [super::modeladmin::Fieldset],
pub(crate) ops: Arc<dyn AdminOps>,
}
#[derive(Debug, Clone, Default)]
pub struct ListOpts {
pub ordering: Vec<(String, super::modeladmin::SortDir)>,
pub filters: Vec<(String, String)>,
pub search: Option<(String, Vec<String>)>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Default)]
pub struct ListPage {
pub rows: Vec<ListRow>,
pub total: i64,
}
pub(crate) trait AdminOps: Send + Sync {
fn list<'a>(
&'a self,
db: &'a Db,
opts: ListOpts,
) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
fn find_row<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
fn delete<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
fn object_label<'a>(
&'a self,
db: &'a Db,
id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
}
#[derive(Debug)]
pub struct ListRow {
pub id: i64,
pub cells: Vec<String>,
}
#[derive(Debug)]
pub struct EditRow {
#[allow(dead_code)]
pub id: i64,
pub values: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
pub struct SiteBranding {
pub site_title: String,
pub site_header: String,
pub index_title: String,
pub footer_copyright: String,
pub domain: String,
}
impl Default for SiteBranding {
fn default() -> Self {
Self {
site_title: "RustIO administration".into(),
site_header: "RustIO administration".into(),
index_title: "Site administration".into(),
footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
domain: "rustio.local".into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdminTheme {
pub accent: String,
pub bg: String,
pub surface: String,
pub text: String,
pub text_muted: String,
pub border: String,
}
impl Default for AdminTheme {
fn default() -> Self {
Self {
accent: "#2563EB".into(),
bg: "#F4F6FB".into(),
surface: "#FFFFFF".into(),
text: "#111827".into(),
text_muted: "#4B5563".into(),
border: "#D1D5DB".into(),
}
}
}
pub struct Admin {
pub(crate) entries: Vec<AdminEntry>,
pub(crate) site_branding: SiteBranding,
pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
pub(crate) theme: AdminTheme,
}
impl Default for Admin {
fn default() -> Self {
Self::new()
}
}
impl Admin {
pub fn new() -> Self {
Self {
entries: vec![core_user_entry()],
site_branding: SiteBranding::default(),
user_profile_ext: None,
theme: AdminTheme::default(),
}
}
pub fn site_branding(mut self, branding: SiteBranding) -> Self {
self.site_branding = branding;
self
}
pub fn branding(&self) -> &SiteBranding {
&self.site_branding
}
pub fn accent_color(mut self, color: impl Into<String>) -> Self {
self.theme.accent = normalise_hex(color);
self
}
pub fn theme(mut self, theme: AdminTheme) -> Self {
self.theme = theme;
self
}
pub fn accent(&self) -> &str {
&self.theme.accent
}
pub fn active_theme(&self) -> &AdminTheme {
&self.theme
}
pub fn model<M>(mut self) -> Self
where
M: super::ModelAdmin + crate::orm::Model,
{
let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
self.entries.push(AdminEntry {
admin_name: M::ADMIN_NAME,
display_name: M::DISPLAY_NAME,
singular_name: M::SINGULAR_NAME,
table: <M as crate::orm::Model>::TABLE,
fields: M::FIELDS,
core: false,
list_display: M::list_display(),
list_filter: M::list_filter(),
search_fields: M::search_fields(),
ordering: M::ordering(),
list_per_page: M::list_per_page(),
readonly_fields: M::readonly_fields(),
fieldsets: M::fieldsets(),
ops,
});
self
}
pub fn entries(&self) -> &[AdminEntry] {
&self.entries
}
pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
where
F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
{
self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
self
}
#[allow(dead_code)]
pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
self.user_profile_ext.as_ref()
}
pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
self.entries.iter().find(|e| e.admin_name == admin_name)
}
pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
for entry in &self.entries {
let singular = entry.singular_name.to_ascii_lowercase();
crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
}
Ok(())
}
}
const CORE_USER_FIELDS: &[AdminField] = &[
AdminField {
name: "id",
label: "id",
field_type: FieldType::I64,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "email",
label: "email",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "password_hash",
label: "password_hash",
field_type: FieldType::String,
editable: false,
relation: None,
choices: None,
},
AdminField {
name: "role",
label: "role",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "is_active",
label: "is_active",
field_type: FieldType::Bool,
editable: true,
relation: None,
choices: None,
},
AdminField {
name: "created_at",
label: "created_at",
field_type: FieldType::DateTime,
editable: false,
relation: None,
choices: None,
},
];
pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
let raw = input.into();
let trimmed = raw.trim().trim_start_matches('#');
format!("#{trimmed}")
}
fn core_user_entry() -> AdminEntry {
AdminEntry {
admin_name: "users",
display_name: "Users",
singular_name: "User",
table: "rustio_users",
fields: CORE_USER_FIELDS,
core: true,
list_display: &[],
list_filter: &[],
search_fields: &[],
ordering: &["-id"],
list_per_page: 50,
readonly_fields: &[],
fieldsets: &[],
ops: Arc::new(CoreUserOps),
}
}
struct CoreUserOps;
fn core_user_route_error() -> crate::error::Error {
crate::error::Error::Internal(
"the core User entry is route-only — use the dedicated /admin/users page".into(),
)
}
impl AdminOps for CoreUserOps {
fn list<'a>(
&'a self,
_db: &'a Db,
_opts: ListOpts,
) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
Box::pin(async { Err(core_user_route_error()) })
}
fn find_row<'a>(
&'a self,
_db: &'a Db,
_id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
Box::pin(async { Err(core_user_route_error()) })
}
fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
Box::pin(async { Err(core_user_route_error()) })
}
fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
Box::pin(async { Err(core_user_route_error()) })
}
fn delete<'a>(
&'a self,
_db: &'a Db,
_id: i64,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async { Err(core_user_route_error()) })
}
fn object_label<'a>(
&'a self,
_db: &'a Db,
_id: i64,
) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
Box::pin(async { Err(core_user_route_error()) })
}
}