armature_admin/
model.rs

1//! Model definitions for admin
2
3use crate::field::{FieldDefinition, FieldType};
4use serde::{Deserialize, Serialize};
5
6/// Model definition for admin registration
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ModelDefinition {
9    /// Model name (used in URLs)
10    pub name: String,
11    /// Display name (plural)
12    pub verbose_name: String,
13    /// Singular display name
14    pub verbose_name_singular: String,
15    /// Database table name
16    pub table_name: String,
17    /// Fields
18    pub fields: Vec<FieldDefinition>,
19    /// Primary key field name
20    pub primary_key: String,
21    /// Fields to display in list view
22    pub list_display: Vec<String>,
23    /// Fields that are searchable
24    pub search_fields: Vec<String>,
25    /// Default ordering
26    pub ordering: Vec<OrderingField>,
27    /// Fields that can be filtered
28    pub list_filter: Vec<String>,
29    /// Read-only fields
30    pub readonly_fields: Vec<String>,
31    /// Fields excluded from forms
32    pub exclude: Vec<String>,
33    /// Fieldsets for form organization
34    pub fieldsets: Vec<Fieldset>,
35    /// Actions available for this model
36    pub actions: Vec<AdminAction>,
37    /// Icon (for sidebar)
38    pub icon: Option<String>,
39    /// Custom list template
40    pub list_template: Option<String>,
41    /// Custom detail template
42    pub detail_template: Option<String>,
43    /// Custom form template
44    pub form_template: Option<String>,
45    /// Inline models (for related data)
46    pub inlines: Vec<InlineDefinition>,
47    /// Can add new records?
48    pub can_add: bool,
49    /// Can edit records?
50    pub can_edit: bool,
51    /// Can delete records?
52    pub can_delete: bool,
53    /// Can export records?
54    pub can_export: bool,
55}
56
57impl ModelDefinition {
58    /// Create a new model definition builder
59    pub fn builder(name: impl Into<String>) -> ModelDefinitionBuilder {
60        ModelDefinitionBuilder::new(name)
61    }
62
63    /// Get a field by name
64    pub fn get_field(&self, name: &str) -> Option<&FieldDefinition> {
65        self.fields.iter().find(|f| f.name == name)
66    }
67
68    /// Get display fields
69    pub fn display_fields(&self) -> Vec<&FieldDefinition> {
70        self.list_display
71            .iter()
72            .filter_map(|name| self.get_field(name))
73            .collect()
74    }
75
76    /// Get searchable fields
77    pub fn searchable_fields(&self) -> Vec<&FieldDefinition> {
78        self.search_fields
79            .iter()
80            .filter_map(|name| self.get_field(name))
81            .collect()
82    }
83
84    /// Get filterable fields
85    pub fn filterable_fields(&self) -> Vec<&FieldDefinition> {
86        self.list_filter
87            .iter()
88            .filter_map(|name| self.get_field(name))
89            .collect()
90    }
91
92    /// Get editable fields for forms
93    pub fn form_fields(&self) -> Vec<&FieldDefinition> {
94        self.fields
95            .iter()
96            .filter(|f| !f.primary_key && !self.exclude.contains(&f.name))
97            .collect()
98    }
99
100    /// Get primary key field
101    pub fn pk_field(&self) -> Option<&FieldDefinition> {
102        self.get_field(&self.primary_key)
103    }
104}
105
106/// Builder for model definitions
107pub struct ModelDefinitionBuilder {
108    name: String,
109    verbose_name: Option<String>,
110    verbose_name_singular: Option<String>,
111    table_name: Option<String>,
112    fields: Vec<FieldDefinition>,
113    primary_key: String,
114    list_display: Vec<String>,
115    search_fields: Vec<String>,
116    ordering: Vec<OrderingField>,
117    list_filter: Vec<String>,
118    readonly_fields: Vec<String>,
119    exclude: Vec<String>,
120    fieldsets: Vec<Fieldset>,
121    actions: Vec<AdminAction>,
122    icon: Option<String>,
123    inlines: Vec<InlineDefinition>,
124    can_add: bool,
125    can_edit: bool,
126    can_delete: bool,
127    can_export: bool,
128}
129
130impl ModelDefinitionBuilder {
131    /// Create a new builder
132    pub fn new(name: impl Into<String>) -> Self {
133        let name = name.into();
134        Self {
135            name: name.clone(),
136            verbose_name: None,
137            verbose_name_singular: None,
138            table_name: None,
139            fields: Vec::new(),
140            primary_key: "id".to_string(),
141            list_display: Vec::new(),
142            search_fields: Vec::new(),
143            ordering: vec![OrderingField::desc("id")],
144            list_filter: Vec::new(),
145            readonly_fields: Vec::new(),
146            exclude: Vec::new(),
147            fieldsets: Vec::new(),
148            actions: Vec::new(),
149            icon: None,
150            inlines: Vec::new(),
151            can_add: true,
152            can_edit: true,
153            can_delete: true,
154            can_export: true,
155        }
156    }
157
158    /// Set verbose name
159    pub fn verbose_name(mut self, name: impl Into<String>) -> Self {
160        self.verbose_name = Some(name.into());
161        self
162    }
163
164    /// Set table name
165    pub fn table_name(mut self, name: impl Into<String>) -> Self {
166        self.table_name = Some(name.into());
167        self
168    }
169
170    /// Add a field
171    pub fn field(mut self, field: FieldDefinition) -> Self {
172        if field.primary_key {
173            self.primary_key = field.name.clone();
174        }
175        self.fields.push(field);
176        self
177    }
178
179    /// Add ID field (common pattern)
180    pub fn id_field(self) -> Self {
181        self.field(
182            FieldDefinition::new("id", FieldType::BigInteger)
183                .primary_key()
184                .label("ID"),
185        )
186    }
187
188    /// Add timestamp fields
189    pub fn timestamps(self) -> Self {
190        self.field(
191            FieldDefinition::new("created_at", FieldType::DateTime)
192                .readonly()
193                .label("Created"),
194        )
195        .field(
196            FieldDefinition::new("updated_at", FieldType::DateTime)
197                .readonly()
198                .label("Updated"),
199        )
200    }
201
202    /// Set list display fields
203    pub fn list_display(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
204        self.list_display = fields.into_iter().map(Into::into).collect();
205        self
206    }
207
208    /// Set search fields
209    pub fn search_fields(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
210        self.search_fields = fields.into_iter().map(Into::into).collect();
211        self
212    }
213
214    /// Set ordering
215    pub fn ordering(mut self, fields: impl IntoIterator<Item = OrderingField>) -> Self {
216        self.ordering = fields.into_iter().collect();
217        self
218    }
219
220    /// Set list filter fields
221    pub fn list_filter(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
222        self.list_filter = fields.into_iter().map(Into::into).collect();
223        self
224    }
225
226    /// Set readonly fields
227    pub fn readonly_fields(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
228        self.readonly_fields = fields.into_iter().map(Into::into).collect();
229        self
230    }
231
232    /// Set excluded fields
233    pub fn exclude(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
234        self.exclude = fields.into_iter().map(Into::into).collect();
235        self
236    }
237
238    /// Add a fieldset
239    pub fn fieldset(mut self, fieldset: Fieldset) -> Self {
240        self.fieldsets.push(fieldset);
241        self
242    }
243
244    /// Add an action
245    pub fn action(mut self, action: AdminAction) -> Self {
246        self.actions.push(action);
247        self
248    }
249
250    /// Set icon
251    pub fn icon(mut self, icon: impl Into<String>) -> Self {
252        self.icon = Some(icon.into());
253        self
254    }
255
256    /// Add inline
257    pub fn inline(mut self, inline: InlineDefinition) -> Self {
258        self.inlines.push(inline);
259        self
260    }
261
262    /// Disable adding
263    pub fn no_add(mut self) -> Self {
264        self.can_add = false;
265        self
266    }
267
268    /// Disable editing
269    pub fn no_edit(mut self) -> Self {
270        self.can_edit = false;
271        self
272    }
273
274    /// Disable deleting
275    pub fn no_delete(mut self) -> Self {
276        self.can_delete = false;
277        self
278    }
279
280    /// Build the model definition
281    pub fn build(self) -> ModelDefinition {
282        let verbose_name = self.verbose_name.unwrap_or_else(|| {
283            // Pluralize name
284            let name = self.name.replace('_', " ");
285            if name.ends_with('s') {
286                name
287            } else {
288                format!("{}s", name)
289            }
290        });
291
292        let verbose_name_singular = self.verbose_name_singular.unwrap_or_else(|| {
293            self.name.replace('_', " ")
294        });
295
296        let table_name = self.table_name.unwrap_or_else(|| {
297            self.name.to_lowercase().replace(' ', "_")
298        });
299
300        // If no list_display set, use all fields
301        let list_display = if self.list_display.is_empty() {
302            self.fields.iter().take(5).map(|f| f.name.clone()).collect()
303        } else {
304            self.list_display
305        };
306
307        ModelDefinition {
308            name: self.name,
309            verbose_name,
310            verbose_name_singular,
311            table_name,
312            fields: self.fields,
313            primary_key: self.primary_key,
314            list_display,
315            search_fields: self.search_fields,
316            ordering: self.ordering,
317            list_filter: self.list_filter,
318            readonly_fields: self.readonly_fields,
319            exclude: self.exclude,
320            fieldsets: self.fieldsets,
321            actions: self.actions,
322            icon: self.icon,
323            list_template: None,
324            detail_template: None,
325            form_template: None,
326            inlines: self.inlines,
327            can_add: self.can_add,
328            can_edit: self.can_edit,
329            can_delete: self.can_delete,
330            can_export: self.can_export,
331        }
332    }
333}
334
335/// Ordering field
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct OrderingField {
338    /// Field name
339    pub field: String,
340    /// Is descending?
341    pub descending: bool,
342}
343
344impl OrderingField {
345    /// Ascending order
346    pub fn asc(field: impl Into<String>) -> Self {
347        Self {
348            field: field.into(),
349            descending: false,
350        }
351    }
352
353    /// Descending order
354    pub fn desc(field: impl Into<String>) -> Self {
355        Self {
356            field: field.into(),
357            descending: true,
358        }
359    }
360
361    /// Get SQL representation
362    pub fn as_sql(&self) -> String {
363        format!(
364            "{} {}",
365            self.field,
366            if self.descending { "DESC" } else { "ASC" }
367        )
368    }
369}
370
371/// Fieldset for organizing form fields
372#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct Fieldset {
374    /// Fieldset name
375    pub name: Option<String>,
376    /// Fields in this set
377    pub fields: Vec<String>,
378    /// CSS classes
379    pub classes: Vec<String>,
380    /// Description
381    pub description: Option<String>,
382    /// Is collapsible?
383    pub collapsible: bool,
384    /// Is initially collapsed?
385    pub collapsed: bool,
386}
387
388impl Fieldset {
389    /// Create a new fieldset
390    pub fn new(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
391        Self {
392            name: None,
393            fields: fields.into_iter().map(Into::into).collect(),
394            classes: Vec::new(),
395            description: None,
396            collapsible: false,
397            collapsed: false,
398        }
399    }
400
401    /// Named fieldset
402    pub fn named(name: impl Into<String>, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
403        Self {
404            name: Some(name.into()),
405            fields: fields.into_iter().map(Into::into).collect(),
406            classes: Vec::new(),
407            description: None,
408            collapsible: false,
409            collapsed: false,
410        }
411    }
412
413    /// Set description
414    pub fn description(mut self, desc: impl Into<String>) -> Self {
415        self.description = Some(desc.into());
416        self
417    }
418
419    /// Make collapsible
420    pub fn collapsible(mut self) -> Self {
421        self.collapsible = true;
422        self
423    }
424
425    /// Start collapsed
426    pub fn collapsed(mut self) -> Self {
427        self.collapsible = true;
428        self.collapsed = true;
429        self
430    }
431}
432
433/// Admin action
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct AdminAction {
436    /// Action name (identifier)
437    pub name: String,
438    /// Display label
439    pub label: String,
440    /// Description
441    pub description: Option<String>,
442    /// Icon
443    pub icon: Option<String>,
444    /// Is dangerous (requires confirmation)?
445    pub dangerous: bool,
446    /// Requires selection?
447    pub requires_selection: bool,
448}
449
450impl AdminAction {
451    /// Create a new action
452    pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
453        Self {
454            name: name.into(),
455            label: label.into(),
456            description: None,
457            icon: None,
458            dangerous: false,
459            requires_selection: true,
460        }
461    }
462
463    /// Delete action preset
464    pub fn delete() -> Self {
465        Self {
466            name: "delete".to_string(),
467            label: "Delete selected".to_string(),
468            description: Some("Permanently delete selected items".to_string()),
469            icon: Some("trash".to_string()),
470            dangerous: true,
471            requires_selection: true,
472        }
473    }
474
475    /// Export action preset
476    pub fn export() -> Self {
477        Self {
478            name: "export".to_string(),
479            label: "Export".to_string(),
480            description: Some("Export selected items to CSV".to_string()),
481            icon: Some("download".to_string()),
482            dangerous: false,
483            requires_selection: false,
484        }
485    }
486
487    /// Set description
488    pub fn description(mut self, desc: impl Into<String>) -> Self {
489        self.description = Some(desc.into());
490        self
491    }
492
493    /// Set icon
494    pub fn icon(mut self, icon: impl Into<String>) -> Self {
495        self.icon = Some(icon.into());
496        self
497    }
498
499    /// Mark as dangerous
500    pub fn dangerous(mut self) -> Self {
501        self.dangerous = true;
502        self
503    }
504}
505
506/// Inline definition for related models
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct InlineDefinition {
509    /// Related model name
510    pub model: String,
511    /// Foreign key field in related model
512    pub fk_field: String,
513    /// Fields to display
514    pub fields: Vec<String>,
515    /// Extra rows to show
516    pub extra: usize,
517    /// Maximum rows
518    pub max_num: Option<usize>,
519    /// Minimum rows
520    pub min_num: usize,
521    /// Can delete?
522    pub can_delete: bool,
523    /// Verbose name
524    pub verbose_name: Option<String>,
525}
526
527impl InlineDefinition {
528    /// Create a new inline
529    pub fn new(model: impl Into<String>, fk_field: impl Into<String>) -> Self {
530        Self {
531            model: model.into(),
532            fk_field: fk_field.into(),
533            fields: Vec::new(),
534            extra: 3,
535            max_num: None,
536            min_num: 0,
537            can_delete: true,
538            verbose_name: None,
539        }
540    }
541
542    /// Set fields
543    pub fn fields(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
544        self.fields = fields.into_iter().map(Into::into).collect();
545        self
546    }
547
548    /// Set extra rows
549    pub fn extra(mut self, extra: usize) -> Self {
550        self.extra = extra;
551        self
552    }
553
554    /// Set max rows
555    pub fn max_num(mut self, max: usize) -> Self {
556        self.max_num = Some(max);
557        self
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn test_model_builder() {
567        let model = ModelDefinition::builder("user")
568            .id_field()
569            .field(FieldDefinition::new("name", FieldType::String).required())
570            .field(FieldDefinition::new("email", FieldType::Email))
571            .timestamps()
572            .list_display(["id", "name", "email"])
573            .search_fields(["name", "email"])
574            .build();
575
576        assert_eq!(model.name, "user");
577        assert_eq!(model.verbose_name, "users");
578        assert_eq!(model.primary_key, "id");
579        assert_eq!(model.fields.len(), 5);
580    }
581
582    #[test]
583    fn test_fieldset() {
584        let fieldset = Fieldset::named("Personal Info", ["name", "email"])
585            .description("User's personal information")
586            .collapsible();
587
588        assert_eq!(fieldset.name, Some("Personal Info".to_string()));
589        assert_eq!(fieldset.fields.len(), 2);
590        assert!(fieldset.collapsible);
591    }
592
593    #[test]
594    fn test_ordering() {
595        let desc = OrderingField::desc("created_at");
596        assert_eq!(desc.as_sql(), "created_at DESC");
597
598        let asc = OrderingField::asc("name");
599        assert_eq!(asc.as_sql(), "name ASC");
600    }
601}
602