Skip to main content

actix_admin/
view_model.rs

1use async_trait::async_trait;
2use regex::Regex;
3use sea_orm::DatabaseConnection;
4use serde_derive::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7use crate::ActixAdminError;
8use crate::{model::ActixAdminModelFilterType, ActixAdminModel, SortOrder};
9use actix_session::Session;
10use std::convert::From;
11pub struct ActixAdminViewModelParams {
12    pub page: Option<u64>,
13    pub entities_per_page: Option<u64>,
14    pub viewmodel_filter: Vec<ActixAdminViewModelFilter>,
15    pub search: String,
16    pub sort_by: String,
17    pub sort_order: SortOrder,
18    pub tenant_ref: Option<i32>,
19}
20
21/// Blanket bound for anything usable as an entity primary key in the admin.
22///
23/// This is what powers the `ActixAdminViewModelTrait::Id` associated type,
24/// letting an entity be keyed by `i32`, `i64`, `String`, `uuid::Uuid`, ...
25/// as long as the type satisfies the four ubiquitous requirements:
26///
27/// * `DeserializeOwned` — needed by `actix_web::web::Path<Id>`.
28/// * `FromStr`         — needed to parse ids out of form bodies (bulk delete).
29/// * `Display`         — needed to render ids into URLs and templates.
30/// * `Clone + 'static` — needed by the generated Sea-ORM queries.
31pub trait ActixAdminPrimaryKey:
32    serde::de::DeserializeOwned + std::str::FromStr + std::fmt::Display + Clone + 'static
33{
34}
35impl<T> ActixAdminPrimaryKey for T where
36    T: serde::de::DeserializeOwned + std::str::FromStr + std::fmt::Display + Clone + 'static
37{
38}
39
40#[async_trait(?Send)]
41pub trait ActixAdminViewModelTrait {
42    /// The primary-key type of this entity. Defaults to `i32` in the derive
43    /// macro output; override by having a `#[actix_admin(primary_key)]` field
44    /// with a different type (e.g. `Uuid`, `i64`, `String`).
45    type Id: ActixAdminPrimaryKey;
46
47    async fn list(
48        db: &DatabaseConnection,
49        params: &ActixAdminViewModelParams,
50    ) -> Result<(Option<u64>, Vec<ActixAdminModel>), ActixAdminError>;
51
52    // TODO: Replace return value with proper Result Type containing Ok or Err
53    async fn create_entity(
54        db: &DatabaseConnection,
55        model: ActixAdminModel,
56        tenant_ref: Option<i32>,
57    ) -> Result<ActixAdminModel, ActixAdminError>;
58    async fn delete_entity(
59        db: &DatabaseConnection,
60        id: Self::Id,
61        tenant_ref: Option<i32>,
62    ) -> Result<bool, ActixAdminError>;
63
64    /// Bulk-delete many entities in a single query. Default implementation
65    /// falls back to a per-id loop over `delete_entity`, so existing
66    /// implementations keep working; the derive-macro override does a single
67    /// `DELETE ... WHERE pk IN (...)` query.
68    async fn delete_entities(
69        db: &DatabaseConnection,
70        ids: &[Self::Id],
71        tenant_ref: Option<i32>,
72    ) -> Result<u64, ActixAdminError> {
73        let mut deleted = 0u64;
74        for id in ids {
75            if Self::delete_entity(db, id.clone(), tenant_ref).await? {
76                deleted += 1;
77            }
78        }
79        Ok(deleted)
80    }
81
82    async fn get_entity(
83        db: &DatabaseConnection,
84        id: Self::Id,
85        tenant_ref: Option<i32>,
86    ) -> Result<ActixAdminModel, ActixAdminError>;
87    async fn edit_entity(
88        db: &DatabaseConnection,
89        id: Self::Id,
90        model: ActixAdminModel,
91        tenant_ref: Option<i32>,
92    ) -> Result<ActixAdminModel, ActixAdminError>;
93    async fn get_select_lists(
94        db: &DatabaseConnection,
95        tenant_ref: Option<i32>,
96    ) -> Result<HashMap<String, Vec<(String, String)>>, ActixAdminError>;
97    async fn get_viewmodel_filter(
98        db: &DatabaseConnection,
99    ) -> HashMap<String, ActixAdminViewModelFilter>;
100    async fn validate_entity(model: &mut ActixAdminModel, db: &DatabaseConnection);
101
102    fn get_entity_name() -> String;
103}
104
105/// A user-visible action that can be applied to a selection of rows on the
106/// list page ("Archive selected", "Send email", ...). Register via
107/// `ActixAdminBuilder::add_bulk_action_for_entity::<E>(...)`.
108#[derive(Clone, Debug, Serialize)]
109pub struct ActixAdminBulkAction {
110    /// URL-safe identifier used as the route segment (`/entity/action/{name}`).
111    pub name: String,
112    /// Human-readable label rendered in the actions dropdown.
113    pub label: String,
114    /// Optional Font Awesome icon class, e.g. `"fa-solid fa-archive"`.
115    pub icon: Option<String>,
116    /// If set, the UI prompts the user with this text before submitting.
117    pub confirm: Option<String>,
118}
119
120#[derive(Clone)]
121pub struct ActixAdminViewModel {
122    pub entity_name: String,
123    pub primary_key: String,
124    pub fields: &'static [ActixAdminViewModelField],
125    pub show_search: bool,
126    /// Top-level page access. If set and returns `false`, the entity is
127    /// invisible and every route 401s. Auth-independent (i.e. also honored
128    /// when `enable_auth = false`).
129    pub user_can_access: Option<fn(&Session) -> bool>,
130    /// Per-action permissions. When `None`, the action defaults to the value of
131    /// `user_can_access` (or `true` if that is also `None`).
132    pub user_can_create: Option<fn(&Session) -> bool>,
133    pub user_can_edit: Option<fn(&Session) -> bool>,
134    pub user_can_delete: Option<fn(&Session) -> bool>,
135    pub user_can_view_details: Option<fn(&Session) -> bool>,
136    pub user_can_export: Option<fn(&Session) -> bool>,
137    pub default_show_aside: bool,
138    pub inline_edit: bool,
139    /// Bulk actions registered for this entity. Cloned into the ViewModel by
140    /// the builder when `add_bulk_action_for_entity` is called.
141    pub bulk_actions: Vec<ActixAdminBulkAction>,
142}
143
144#[derive(Clone, Debug, Serialize)]
145pub struct ActixAdminViewModelSerializable {
146    pub entity_name: String,
147    pub primary_key: String,
148    pub fields: &'static [ActixAdminViewModelField],
149    pub show_search: bool,
150    pub default_show_aside: bool,
151    pub inline_edit: bool,
152    /// Serialized permission flags, resolved for the current session. Filled
153    /// in per-request by `add_default_context` since the fn hooks themselves
154    /// are not serializable.
155    #[serde(default)]
156    pub can_create: bool,
157    #[serde(default)]
158    pub can_edit: bool,
159    #[serde(default)]
160    pub can_delete: bool,
161    #[serde(default)]
162    pub can_view_details: bool,
163    #[serde(default)]
164    pub can_export: bool,
165    pub bulk_actions: Vec<ActixAdminBulkAction>,
166}
167
168/// Comparison operator applied by an advanced filter. Encoded on the wire as
169/// `filter_<name>__op=<snake_case_variant>` (case-insensitive).
170#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "snake_case")]
172pub enum ActixAdminFilterOperator {
173    Equals,
174    NotEquals,
175    Contains,
176    NotContains,
177    GreaterThan,
178    LessThan,
179    GreaterEquals,
180    LessEquals,
181    IsNull,
182    IsNotNull,
183    InList,
184}
185
186impl ActixAdminFilterOperator {
187    pub fn as_str(&self) -> &'static str {
188        match self {
189            Self::Equals => "equals",
190            Self::NotEquals => "not_equals",
191            Self::Contains => "contains",
192            Self::NotContains => "not_contains",
193            Self::GreaterThan => "gt",
194            Self::LessThan => "lt",
195            Self::GreaterEquals => "gte",
196            Self::LessEquals => "lte",
197            Self::IsNull => "is_null",
198            Self::IsNotNull => "is_not_null",
199            Self::InList => "in",
200        }
201    }
202
203    pub fn label(&self) -> &'static str {
204        match self {
205            Self::Equals => "=",
206            Self::NotEquals => "≠",
207            Self::Contains => "contains",
208            Self::NotContains => "does not contain",
209            Self::GreaterThan => ">",
210            Self::LessThan => "<",
211            Self::GreaterEquals => "≥",
212            Self::LessEquals => "≤",
213            Self::IsNull => "is empty",
214            Self::IsNotNull => "is not empty",
215            Self::InList => "in list",
216        }
217    }
218
219    /// Legacy convenience wrapper around the `FromStr` impl. Kept as an
220    /// inherent method for backwards compatibility — new code should use
221    /// `"...".parse::<ActixAdminFilterOperator>().ok()` directly.
222    pub fn from_str(s: &str) -> Option<Self> {
223        <Self as std::str::FromStr>::from_str(s).ok()
224    }
225}
226
227impl std::str::FromStr for ActixAdminFilterOperator {
228    type Err = ();
229    fn from_str(s: &str) -> Result<Self, Self::Err> {
230        match s {
231            "equals" | "eq" | "=" => Ok(Self::Equals),
232            "not_equals" | "ne" | "!=" => Ok(Self::NotEquals),
233            "contains" | "like" => Ok(Self::Contains),
234            "not_contains" | "not_like" => Ok(Self::NotContains),
235            "gt" | ">" => Ok(Self::GreaterThan),
236            "lt" | "<" => Ok(Self::LessThan),
237            "gte" | ">=" => Ok(Self::GreaterEquals),
238            "lte" | "<=" => Ok(Self::LessEquals),
239            "is_null" | "empty" => Ok(Self::IsNull),
240            "is_not_null" | "not_empty" => Ok(Self::IsNotNull),
241            "in" | "in_list" => Ok(Self::InList),
242            _ => Err(()),
243        }
244    }
245}
246
247#[derive(Clone, Debug, Serialize)]
248pub struct ActixAdminViewModelFilter {
249    pub name: String,
250    pub value: Option<String>,
251    pub foreign_key: Option<String>,
252    pub values: Option<Vec<(String, String)>>,
253    pub filter_type: Option<ActixAdminModelFilterType>,
254    /// Which comparison operators the user may pick from. When empty, no
255    /// operator picker is rendered and the filter closure receives
256    /// `operator = None` (legacy behavior).
257    #[serde(default)]
258    pub operators: Vec<ActixAdminFilterOperator>,
259    /// The operator selected by the current request, if any.
260    #[serde(default)]
261    pub operator: Option<ActixAdminFilterOperator>,
262}
263
264impl ActixAdminViewModelSerializable {
265    /// Build a serializable snapshot of `entity` with **all `can_*` flags set
266    /// to `false`**. Call sites that know the current session must set the
267    /// flags explicitly via [`Self::set_permissions_for`] (or the higher-level
268    /// helper `add_default_context_with_session`).
269    ///
270    /// The least-privilege default matters: any code path that forgets to
271    /// resolve permissions must render as if the user has no rights, not as
272    /// if they were an admin.
273    pub fn from_view_model(entity: &ActixAdminViewModel) -> Self {
274        ActixAdminViewModelSerializable {
275            entity_name: entity.entity_name.clone(),
276            primary_key: entity.primary_key.clone(),
277            fields: entity.fields,
278            show_search: entity.show_search,
279            default_show_aside: entity.default_show_aside,
280            inline_edit: entity.inline_edit,
281            can_create: false,
282            can_edit: false,
283            can_delete: false,
284            can_view_details: false,
285            can_export: false,
286            bulk_actions: entity.bulk_actions.clone(),
287        }
288    }
289}
290
291// Kept for backwards compatibility. Prefer
292// [`ActixAdminViewModelSerializable::from_view_model`] which defaults to
293// least-privilege.
294impl From<ActixAdminViewModel> for ActixAdminViewModelSerializable {
295    fn from(entity: ActixAdminViewModel) -> Self {
296        Self::from_view_model(&entity)
297    }
298}
299
300#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
301pub enum ActixAdminViewModelFieldType {
302    Number,
303    Text,
304    TextArea,
305    Checkbox,
306    Date,
307    Time,
308    DateTime,
309    SelectList,
310    FileUpload,
311    /// Rendered as raw HTML in list/show views (value comes from the model as-is).
312    /// **Only use this for trusted values** — the field is emitted with `| safe`.
313    Html,
314    /// A URL that is rendered as an anchor tag in list/show views.
315    Url,
316    /// An email address that is rendered as a `mailto:` link.
317    Email,
318    /// A file-upload field whose value is a filename in the entity's upload
319    /// directory; rendered as a `<img>` thumbnail in list/show views.
320    Image,
321    /// A textarea backed by a Markdown WYSIWYG editor (EasyMDE) in the
322    /// create/edit form.
323    RichText,
324}
325
326#[derive(Clone, Debug, Serialize, Deserialize)]
327pub struct ActixAdminViewModelField {
328    pub field_name: String,
329    pub html_input_type: String,
330    pub select_list: String,
331    pub dateformat: Option<String>,
332    pub is_option: bool,
333    pub field_type: ActixAdminViewModelFieldType,
334    pub list_sort_position: usize,
335    pub list_hide_column: bool,
336    #[serde(skip_serializing, skip_deserializing)]
337    pub list_regex_mask: Option<Regex>,
338    pub foreign_key: String,
339    pub is_tenant_ref: bool,
340    pub ceil: Option<u8>,
341    pub floor: Option<u8>,
342    pub shorten: Option<u16>,
343    pub use_tom_select_callback: bool,
344    /// Optional read-only field flag (present but not writable). Read-only
345    /// fields are still shown in the show view and in the edit form (disabled).
346    #[serde(default)]
347    pub readonly: bool,
348}
349
350impl ActixAdminViewModelFieldType {
351    #[allow(clippy::too_many_arguments)]
352    pub fn get_field_type(
353        type_path: &str,
354        select_list: String,
355        is_textarea: bool,
356        is_file_upload: bool,
357        is_image: bool,
358        is_html: bool,
359        is_url: bool,
360        is_email: bool,
361        is_wysiwyg: bool,
362    ) -> ActixAdminViewModelFieldType {
363        if !select_list.is_empty() {
364            return ActixAdminViewModelFieldType::SelectList;
365        }
366        if is_image {
367            return ActixAdminViewModelFieldType::Image;
368        }
369        if is_wysiwyg {
370            return ActixAdminViewModelFieldType::RichText;
371        }
372        if is_textarea {
373            return ActixAdminViewModelFieldType::TextArea;
374        }
375        if is_file_upload {
376            return ActixAdminViewModelFieldType::FileUpload;
377        }
378        if is_html {
379            return ActixAdminViewModelFieldType::Html;
380        }
381        if is_url {
382            return ActixAdminViewModelFieldType::Url;
383        }
384        if is_email {
385            return ActixAdminViewModelFieldType::Email;
386        }
387
388        match type_path {
389            "i32" => ActixAdminViewModelFieldType::Number,
390            "i64" => ActixAdminViewModelFieldType::Number,
391            "usize" => ActixAdminViewModelFieldType::Number,
392            "String" => ActixAdminViewModelFieldType::Text,
393            "bool" => ActixAdminViewModelFieldType::Checkbox,
394            "DateTimeWithTimeZone" => ActixAdminViewModelFieldType::DateTime,
395            "DateTime" => ActixAdminViewModelFieldType::DateTime,
396            "Date" => ActixAdminViewModelFieldType::Date,
397            _ => ActixAdminViewModelFieldType::Text,
398        }
399    }
400}