cot/
admin.rs

1//! Administration panel.
2//!
3//! This module provides an administration panel for managing models
4//! registered in the application, straight from the web interface.
5
6use std::any::Any;
7use std::marker::PhantomData;
8
9use async_trait::async_trait;
10use bytes::Bytes;
11/// Implements the [`AdminModel`] trait for a struct.
12///
13/// This is a simple method for adding a database model to the admin panel.
14/// Note that in order for this derive macro to work, the structure
15/// **must** implement [`Model`](crate::db::Model) and
16/// [`Form`] traits. These can also be derived using the `#[model]` and
17/// `#[derive(Form)]` attributes.
18pub use cot_macros::AdminModel;
19use derive_more::Debug;
20use serde::Deserialize;
21
22use crate::auth::Auth;
23use crate::common_types::Password;
24use crate::error::NotFound;
25use crate::form::{
26    Form, FormContext, FormErrorTarget, FormField, FormFieldValidationError, FormResult,
27};
28use crate::html::Html;
29use crate::request::extractors::{FromRequestHead, Path, StaticFiles, UrlQuery};
30use crate::request::{Request, RequestExt, RequestHead};
31use crate::response::{IntoResponse, Response};
32use crate::router::{Router, Urls};
33use crate::static_files::StaticFile;
34use crate::{App, Error, Method, RequestHandler, Template, reverse_redirect};
35
36struct AdminAuthenticated<T, H: Send + Sync>(H, PhantomData<fn() -> T>);
37
38impl<T, H: RequestHandler<T> + Send + Sync> AdminAuthenticated<T, H> {
39    #[must_use]
40    fn new(handler: H) -> Self {
41        Self(handler, PhantomData)
42    }
43}
44
45impl<T, H: RequestHandler<T> + Send + Sync> RequestHandler<T> for AdminAuthenticated<T, H> {
46    async fn handle(&self, mut request: Request) -> crate::Result<Response> {
47        let auth: Auth = request.extract_from_head().await?;
48        if !auth.user().is_authenticated() {
49            return Ok(reverse_redirect!(request, "login")?);
50        }
51
52        self.0.handle(request).await
53    }
54}
55
56#[derive(Debug, FromRequestHead)]
57struct BaseContext {
58    urls: Urls,
59    static_files: StaticFiles,
60}
61
62async fn index(
63    base_context: BaseContext,
64    AdminModelManagers(managers): AdminModelManagers,
65) -> crate::Result<Html> {
66    #[derive(Debug, Template)]
67    #[template(path = "admin/model_list.html")]
68    struct ModelListTemplate<'a> {
69        ctx: &'a BaseContext,
70        #[debug("..")]
71        model_managers: Vec<Box<dyn AdminModelManager>>,
72    }
73
74    let template = ModelListTemplate {
75        ctx: &base_context,
76        model_managers: managers,
77    };
78    Ok(Html::new(template.render()?))
79}
80
81#[derive(Debug, Form)]
82struct LoginForm {
83    username: String,
84    password: Password,
85}
86
87async fn login(
88    base_context: BaseContext,
89    auth: Auth,
90    mut request: Request,
91) -> crate::Result<Response> {
92    #[derive(Debug, Template)]
93    #[template(path = "admin/login.html")]
94    struct LoginTemplate<'a> {
95        ctx: &'a BaseContext,
96        form: <LoginForm as Form>::Context,
97    }
98
99    let login_form_context = if request.method() == Method::GET {
100        LoginForm::build_context(&mut request).await?
101    } else if request.method() == Method::POST {
102        let login_form = LoginForm::from_request(&mut request).await?;
103        match login_form {
104            FormResult::Ok(login_form) => {
105                if authenticate(&auth, login_form).await? {
106                    return Ok(reverse_redirect!(base_context.urls, "index")?);
107                }
108
109                let mut context = LoginForm::build_context(&mut request).await?;
110                context.add_error(
111                    FormErrorTarget::Form,
112                    FormFieldValidationError::from_static("Invalid username or password"),
113                );
114                context
115            }
116            FormResult::ValidationError(context) => context,
117        }
118    } else {
119        panic!("Unexpected request method");
120    };
121
122    let template = LoginTemplate {
123        ctx: &base_context,
124        form: login_form_context,
125    };
126    Html::new(template.render()?).into_response()
127}
128
129async fn authenticate(auth: &Auth, login_form: LoginForm) -> crate::Result<bool> {
130    #[cfg(feature = "db")]
131    let user = auth
132        .authenticate(&crate::auth::db::DatabaseUserCredentials::new(
133            login_form.username,
134            Password::new(login_form.password.into_string()),
135        ))
136        .await?;
137
138    #[cfg(not(feature = "db"))]
139    let user: Option<Box<dyn crate::auth::User + Send + Sync>> = None;
140
141    if let Some(user) = user {
142        auth.login(user).await?;
143        Ok(true)
144    } else {
145        Ok(false)
146    }
147}
148
149/// Struct representing the pagination of objects.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub struct Pagination {
152    limit: u64,
153    offset: u64,
154}
155
156impl Pagination {
157    fn new(limit: u64, page: u64) -> Self {
158        assert!(page > 0, "Page number must be greater than 0");
159
160        Self {
161            limit,
162            offset: (page - 1) * limit,
163        }
164    }
165
166    /// Returns the limit of objects per page.
167    #[must_use]
168    pub fn limit(&self) -> u64 {
169        self.limit
170    }
171
172    /// Returns the offset of objects.
173    #[must_use]
174    pub fn offset(&self) -> u64 {
175        self.offset
176    }
177}
178
179#[derive(Debug, Deserialize)]
180struct PaginationParams {
181    page: Option<u64>,
182    page_size: Option<u64>,
183}
184
185async fn view_model(
186    base_context: BaseContext,
187    managers: AdminModelManagers,
188    Path(model_name): Path<String>,
189    UrlQuery(pagination_params): UrlQuery<PaginationParams>,
190    request: Request,
191) -> crate::Result<Response> {
192    #[derive(Debug, Template)]
193    #[template(path = "admin/model.html")]
194    struct ModelTemplate<'a> {
195        ctx: &'a BaseContext,
196        #[debug("..")]
197        model: &'a dyn AdminModelManager,
198        #[debug("..")]
199        objects: Vec<Box<dyn AdminModel>>,
200        page: u64,
201        page_size: &'a u64,
202        total_object_counts: u64,
203        total_pages: u64,
204    }
205
206    const DEFAULT_PAGE_SIZE: u64 = 10;
207
208    let manager = get_manager(managers, &model_name)?;
209
210    let page = pagination_params.page.unwrap_or(1);
211    let page_size = pagination_params.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
212
213    let total_object_counts = manager.get_total_object_counts(&request).await?;
214    let total_pages = total_object_counts.div_ceil(page_size);
215
216    if (page == 0 || page > total_pages) && total_pages > 0 {
217        return Err(Error::from(NotFound::with_message(format!(
218            "page {page} not found"
219        ))));
220    }
221
222    let pagination = Pagination::new(page_size, page);
223
224    let objects = manager.get_objects(&request, pagination).await?;
225
226    let template = ModelTemplate {
227        ctx: &base_context,
228        model: &*manager,
229        objects,
230        page,
231        page_size: &page_size,
232        total_object_counts,
233        total_pages,
234    };
235
236    Html::new(template.render()?).into_response()
237}
238
239async fn create_model_instance(
240    base_context: BaseContext,
241    managers: AdminModelManagers,
242    Path(model_name): Path<String>,
243    request: Request,
244) -> cot::Result<Response> {
245    edit_model_instance_impl(base_context, managers, request, &model_name, None).await
246}
247
248async fn edit_model_instance(
249    base_context: BaseContext,
250    managers: AdminModelManagers,
251    Path((model_name, object_id)): Path<(String, String)>,
252    request: Request,
253) -> cot::Result<Response> {
254    edit_model_instance_impl(
255        base_context,
256        managers,
257        request,
258        &model_name,
259        Some(&object_id),
260    )
261    .await
262}
263
264async fn edit_model_instance_impl(
265    base_context: BaseContext,
266    managers: AdminModelManagers,
267    mut request: Request,
268    model_name: &str,
269    object_id: Option<&str>,
270) -> cot::Result<Response> {
271    #[derive(Debug, Template)]
272    #[template(path = "admin/model_edit.html")]
273    struct ModelEditTemplate<'a> {
274        ctx: &'a BaseContext,
275        #[debug("..")]
276        model: &'a dyn AdminModelManager,
277        form_context: Box<dyn FormContext>,
278        is_edit: bool,
279    }
280
281    let manager = get_manager(managers, model_name)?;
282
283    let form_context = if request.method() == Method::POST {
284        let form_context = manager.save_from_request(&mut request, object_id).await?;
285
286        if let Some(form_context) = form_context {
287            form_context
288        } else {
289            return Ok(reverse_redirect!(
290                base_context.urls,
291                "view_model",
292                model_name = manager.url_name()
293            )?);
294        }
295    } else if let Some(object_id) = object_id {
296        let object = get_object(&mut request, &*manager, object_id).await?;
297
298        manager.form_context_from_object(object).await
299    } else {
300        manager.form_context()
301    };
302
303    let template = ModelEditTemplate {
304        ctx: &base_context,
305        model: &*manager,
306        form_context,
307        is_edit: object_id.is_some(),
308    };
309
310    Html::new(template.render()?).into_response()
311}
312
313async fn remove_model_instance(
314    base_context: BaseContext,
315    managers: AdminModelManagers,
316    Path((model_name, object_id)): Path<(String, String)>,
317    mut request: Request,
318) -> cot::Result<Response> {
319    #[derive(Debug, Template)]
320    #[template(path = "admin/model_remove.html")]
321    struct ModelRemoveTemplate<'a> {
322        ctx: &'a BaseContext,
323        #[debug("..")]
324        model: &'a dyn AdminModelManager,
325        #[debug("..")]
326        object: &'a dyn AdminModel,
327    }
328
329    let manager = get_manager(managers, &model_name)?;
330    let object = get_object(&mut request, &*manager, &object_id).await?;
331
332    if request.method() == Method::POST {
333        manager.remove_by_id(&mut request, &object_id).await?;
334
335        Ok(reverse_redirect!(
336            base_context.urls,
337            "view_model",
338            model_name = manager.url_name()
339        )?)
340    } else {
341        let template = ModelRemoveTemplate {
342            ctx: &base_context,
343            model: &*manager,
344            object: &*object,
345        };
346
347        Html::new(template.render()?).into_response()
348    }
349}
350
351async fn get_object(
352    request: &mut Request,
353    manager: &dyn AdminModelManager,
354    object_id: &str,
355) -> Result<Box<dyn AdminModel>, Error> {
356    manager
357        .get_object_by_id(request, object_id)
358        .await?
359        .ok_or_else(|| {
360            Error::from(NotFound::with_message(format!(
361                "Object with ID `{}` not found in model `{}`",
362                object_id,
363                manager.name()
364            )))
365        })
366}
367
368fn get_manager(
369    AdminModelManagers(model_managers): AdminModelManagers,
370    model_name: &str,
371) -> cot::Result<Box<dyn AdminModelManager>> {
372    model_managers
373        .into_iter()
374        .find(|manager| manager.url_name() == model_name)
375        .ok_or_else(|| {
376            Error::from(NotFound::with_message(format!(
377                "Model `{model_name}` not found"
378            )))
379        })
380}
381
382#[repr(transparent)]
383struct AdminModelManagers(Vec<Box<dyn AdminModelManager>>);
384
385impl FromRequestHead for AdminModelManagers {
386    async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
387        let managers = head
388            .context()
389            .apps()
390            .iter()
391            .flat_map(|app| app.admin_model_managers())
392            .collect();
393        Ok(Self(managers))
394    }
395}
396
397/// A trait for adding admin models to the app.
398///
399/// This exposes an API over [`AdminModel`] that is dyn-compatible and
400/// hence can be dynamically added to the project.
401///
402/// See [`DefaultAdminModelManager`] for an automatic implementation of this
403/// trait.
404#[async_trait]
405pub trait AdminModelManager: Send + Sync {
406    /// Returns the display name of the model.
407    fn name(&self) -> &str;
408
409    /// Returns the URL slug for the model.
410    fn url_name(&self) -> &str;
411
412    /// Returns the list of objects of this model.
413    async fn get_objects(
414        &self,
415        request: &Request,
416        pagination: Pagination,
417    ) -> cot::Result<Vec<Box<dyn AdminModel>>>;
418
419    /// Returns the total count of objects of this model.
420    async fn get_total_object_counts(&self, request: &Request) -> cot::Result<u64>;
421
422    /// Returns the object with the given ID.
423    async fn get_object_by_id(
424        &self,
425        request: &Request,
426        id: &str,
427    ) -> cot::Result<Option<Box<dyn AdminModel>>>;
428
429    /// Returns an empty form context for this model.
430    fn form_context(&self) -> Box<dyn FormContext>;
431
432    /// Returns a form context pre-filled with the data from given object.
433    ///
434    /// It is guaranteed that `object` parameter is an object returned by either
435    /// [`Self::get_objects`] or [`Self::get_object_by_id`] methods. This means
436    /// that if you always return the same object type from these methods,
437    /// you can safely downcast the object to the same type in this method
438    /// as well.
439    async fn form_context_from_object(&self, object: Box<dyn AdminModel>) -> Box<dyn FormContext>;
440
441    /// Saves the object by using the form data from given request.
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if the object could not be saved, for instance
446    /// due to a database error.
447    async fn save_from_request(
448        &self,
449        request: &mut Request,
450        object_id: Option<&str>,
451    ) -> cot::Result<Option<Box<dyn FormContext>>>;
452
453    /// Removes the object with the given ID.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if the object with the given ID does not exist.
458    ///
459    /// Returns an error if the object could not be removed, for instance
460    /// due to a database error.
461    async fn remove_by_id(&self, request: &mut Request, object_id: &str) -> cot::Result<()>;
462}
463
464/// A default implementation of [`AdminModelManager`] for an [`AdminModel`].
465#[derive(Debug)]
466pub struct DefaultAdminModelManager<T> {
467    phantom_data: PhantomData<T>,
468}
469
470impl<T> Default for DefaultAdminModelManager<T> {
471    fn default() -> Self {
472        Self::new()
473    }
474}
475
476impl<T> DefaultAdminModelManager<T> {
477    /// Creates a new instance of the default admin model manager.
478    #[must_use]
479    pub const fn new() -> Self {
480        Self {
481            phantom_data: PhantomData,
482        }
483    }
484}
485
486#[async_trait]
487impl<T: AdminModel + Send + Sync + 'static> AdminModelManager for DefaultAdminModelManager<T> {
488    fn name(&self) -> &str {
489        T::name()
490    }
491
492    fn url_name(&self) -> &str {
493        T::url_name()
494    }
495
496    async fn get_total_object_counts(&self, request: &Request) -> cot::Result<u64> {
497        T::get_total_object_counts(request).await
498    }
499
500    async fn get_objects(
501        &self,
502        request: &Request,
503        pagination: Pagination,
504    ) -> cot::Result<Vec<Box<dyn AdminModel>>> {
505        #[expect(trivial_casts)] // Upcast to the correct Box type
506        T::get_objects(request, pagination).await.map(|objects| {
507            objects
508                .into_iter()
509                .map(|object| Box::new(object) as Box<dyn AdminModel>)
510                .collect()
511        })
512    }
513
514    async fn get_object_by_id(
515        &self,
516        request: &Request,
517        id: &str,
518    ) -> cot::Result<Option<Box<dyn AdminModel>>> {
519        #[expect(trivial_casts)] // Upcast to the correct Box type
520        T::get_object_by_id(request, id)
521            .await
522            .map(|object| object.map(|object| Box::new(object) as Box<dyn AdminModel>))
523    }
524
525    fn form_context(&self) -> Box<dyn FormContext> {
526        T::form_context()
527    }
528
529    async fn form_context_from_object(&self, object: Box<dyn AdminModel>) -> Box<dyn FormContext> {
530        let object_any: &dyn Any = &*object;
531        let object_casted = object_any.downcast_ref::<T>().expect("Invalid object type");
532
533        T::form_context_from_self(object_casted).await
534    }
535
536    async fn save_from_request(
537        &self,
538        request: &mut Request,
539        object_id: Option<&str>,
540    ) -> cot::Result<Option<Box<dyn FormContext>>> {
541        T::save_from_request(request, object_id).await
542    }
543
544    async fn remove_by_id(&self, request: &mut Request, object_id: &str) -> cot::Result<()> {
545        T::remove_by_id(request, object_id).await
546    }
547}
548
549/// A model that can be managed by the admin panel.
550#[async_trait]
551#[diagnostic::on_unimplemented(
552    message = "`{Self}` does not implement the `AdminModel` trait",
553    label = "`{Self}` is not an admin model",
554    note = "add #[derive(cot::admin::AdminModel)] to the struct to automatically derive the trait"
555)]
556pub trait AdminModel: Any + Send + 'static {
557    /// Get the objects of this model.
558    async fn get_objects(request: &Request, pagination: Pagination) -> cot::Result<Vec<Self>>
559    where
560        Self: Sized;
561
562    /// Get the total count of objects of this model.
563    async fn get_total_object_counts(request: &Request) -> cot::Result<u64>
564    where
565        Self: Sized;
566
567    /// Returns the object with the given ID.
568    async fn get_object_by_id(request: &Request, id: &str) -> cot::Result<Option<Self>>
569    where
570        Self: Sized;
571
572    /// Get the display name of this model.
573    fn name() -> &'static str
574    where
575        Self: Sized;
576
577    /// Get the URL slug for this model.
578    fn url_name() -> &'static str
579    where
580        Self: Sized;
581
582    /// Get the ID of this model instance as a [`String`].
583    fn id(&self) -> String;
584
585    /// Get the display text of this model instance.
586    fn display(&self) -> String;
587
588    /// Get the form context for this model.
589    fn form_context() -> Box<dyn FormContext>
590    where
591        Self: Sized;
592
593    /// Get the form context with the data pre-filled from this model instance.
594    async fn form_context_from_self(&self) -> Box<dyn FormContext>;
595
596    /// Save the model instance from the form data in the request.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if the object could not be saved, for instance
601    /// due to a database error.
602    async fn save_from_request(
603        request: &mut Request,
604        object_id: Option<&str>,
605    ) -> cot::Result<Option<Box<dyn FormContext>>>
606    where
607        Self: Sized;
608
609    /// Remove the model instance with the given ID.
610    ///
611    /// # Errors
612    ///
613    /// Returns an error if the object with the given ID does not exist.
614    ///
615    /// Returns an error if the object could not be removed, for instance
616    /// due to a database error.
617    async fn remove_by_id(request: &mut Request, object_id: &str) -> cot::Result<()>
618    where
619        Self: Sized;
620}
621
622/// The admin app.
623///
624/// # Examples
625///
626/// ```
627/// use cot::admin::AdminApp;
628/// use cot::project::WithConfig;
629/// use cot::{AppBuilder, Project, ProjectContext};
630///
631/// struct MyProject;
632/// impl Project for MyProject {
633///     fn register_apps(&self, apps: &mut AppBuilder, _context: &ProjectContext<WithConfig>) {
634///         apps.register_with_views(AdminApp::new(), "/admin");
635///     }
636/// }
637/// ```
638#[derive(Debug, Copy, Clone)]
639pub struct AdminApp;
640
641impl Default for AdminApp {
642    fn default() -> Self {
643        Self::new()
644    }
645}
646
647impl AdminApp {
648    /// Creates an admin app instance.
649    ///
650    /// # Examples
651    ///
652    /// ```
653    /// use cot::admin::AdminApp;
654    /// use cot::project::RegisterAppsContext;
655    /// use cot::{AppBuilder, Project};
656    ///
657    /// struct MyProject;
658    /// impl Project for MyProject {
659    ///     fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
660    ///         apps.register_with_views(AdminApp::new(), "/admin");
661    ///     }
662    /// }
663    /// ```
664    #[must_use]
665    pub fn new() -> Self {
666        Self {}
667    }
668}
669
670impl App for AdminApp {
671    fn name(&self) -> &'static str {
672        "cot_admin"
673    }
674
675    fn router(&self) -> Router {
676        Router::with_urls([
677            crate::router::Route::with_handler_and_name(
678                "/",
679                AdminAuthenticated::new(index),
680                "index",
681            ),
682            crate::router::Route::with_handler_and_name("/login/", login, "login"),
683            crate::router::Route::with_handler_and_name(
684                "/{model_name}/",
685                AdminAuthenticated::new(view_model),
686                "view_model",
687            ),
688            crate::router::Route::with_handler_and_name(
689                "/{model_name}/create/",
690                AdminAuthenticated::new(create_model_instance),
691                "create_model_instance",
692            ),
693            crate::router::Route::with_handler_and_name(
694                "/{model_name}/{pk}/edit/",
695                AdminAuthenticated::new(edit_model_instance),
696                "edit_model_instance",
697            ),
698            crate::router::Route::with_handler_and_name(
699                "/{model_name}/{pk}/remove/",
700                AdminAuthenticated::new(remove_model_instance),
701                "remove_model_instance",
702            ),
703        ])
704    }
705
706    fn static_files(&self) -> Vec<StaticFile> {
707        vec![StaticFile::new(
708            "admin/admin.css",
709            Bytes::from_static(include_bytes!(concat!(
710                env!("OUT_DIR"),
711                "/static/admin/admin.css"
712            ))),
713        )]
714    }
715}