Skip to main content

tablo_core/panel/build/
handle.rs

1//! [`PanelHandle`]: a mounted panel kept for a job, a test or a public page.
2
3use std::sync::Arc;
4
5use toasty::Db;
6use topcoat::{
7    Result,
8    context::Cx,
9    view::{BoxView, View, ViewExt},
10};
11
12use super::{build_catalog, serves_tenants, validation_cx};
13use crate::{
14    MountError,
15    bus::NotificationBus,
16    i18n::{Catalog, CatalogSource},
17    notification::Notifications,
18    panel::{
19        Panel,
20        register::Registry,
21        state::{PanelState, current, enter},
22    },
23    resource::Mounts,
24    toasty_compat::model::AppSchema,
25};
26
27/// A panel's mounted resources and its database, which a background job or a test keeps to build
28/// a context per run without declaring the panel again. A router's mounted panel gives it through
29/// [`panel_handle`](super::RouterBuilderPanelExt::panel_handle), a panel no router mounts through
30/// [`Panel::handle`]. Cloning it is cheap.
31///
32/// ```rust,no_run
33/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt, Tenant, scoped_query};
34/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
35/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
36/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
37/// # struct PostResource;
38/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
39/// # async fn run(db: toasty::Db, tenant: tablo_core::TenantId) -> topcoat::Result<()> {
40/// let builder = Router::builder()
41///     .discover()
42///     .with(db)
43///     .panel(Panel::new("admin").resource::<PostResource>())?;
44/// let admin = builder.panel_handle("admin").expect("mounted above");
45/// let router = builder.build();
46///
47/// // In the job, once per run:
48/// let cx = admin.context().with(Tenant(tenant));
49/// let posts = scoped_query::<PostResource>(&cx)?;
50/// # let _ = (router, posts);
51/// # Ok(())
52/// # }
53/// ```
54#[derive(Clone)]
55pub struct PanelHandle {
56    pub(super) db: Db,
57    pub(super) mounts: Arc<Mounts>,
58    /// The panel's messages, which [`context`](Self::context) renders its words from.
59    pub(super) catalog: Arc<Catalog>,
60    /// The mounted panel, for a handle a router gives; `None` for [`Panel::handle`]'s.
61    pub(super) panel: Option<Arc<PanelState>>,
62    pub(super) prefix: String,
63    /// The router's bus, or one no bell listens on for [`Panel::handle`]'s.
64    pub(super) bus: NotificationBus,
65}
66
67impl PanelHandle {
68    /// A context outside any request in which the panel's resources answer as the panel mounted
69    /// them: what a job or a test passes to [`scoped_query`](crate::scoped_query),
70    /// [`can`](crate::can), [`write_create`](crate::write_create) and the other entry points that
71    /// answer from a mounted def. It holds the database and the resources, and no request,
72    /// session or tenant. Add a tenant with `cx.with(Tenant(id))`. It carries no signed-in user,
73    /// so a policy that reads one answers as for a signed-out request.
74    ///
75    /// Its words, [`t!`](crate::t) and a [`Text`](crate::Text)'s
76    /// [`localize`](crate::Text::localize), read the panel's catalogs in its default locale. A
77    /// job writing to someone in another language adds theirs, `cx.with(locale)`, which reads the
78    /// panel's best match for it.
79    ///
80    /// The context is one unit of work, as a request is: loads it memoizes stay cached for its
81    /// lifetime, so a job builds one per run. A context built with no panel at all knows no
82    /// resource: every entry point that reads a mounted def answers as for a resource the panel
83    /// does not mount.
84    pub fn context(&self) -> Cx {
85        validation_cx(&self.db, &self.mounts).with_many((
86            HeldCatalog(Arc::clone(&self.catalog)),
87            CatalogSource(held_catalog),
88        ))
89    }
90
91    /// `cx` answering as this panel, for a request outside every panel's prefix, such as a
92    /// public page: [`scoped_query`](crate::scoped_query), [`can`](crate::can) and the other
93    /// entry points that answer from a mounted def answer as this panel mounted its resources,
94    /// and, for a handle a router gives, the [`url`](crate::url) helpers answer with its URLs.
95    /// A request outside every prefix answers as no panel until it enters one, however many
96    /// panels the router mounts. It runs no auth gate and signs no one in, so a public page's
97    /// context knows no user. Enter only outside every prefix: under a panel's prefix, a handle
98    /// [`Panel::handle`] gives leaves the request answering as its own panel.
99    ///
100    /// ```rust,no_run
101    /// # use tablo_core::{NoForm, PanelHandle, Resource, scoped_query};
102    /// # use topcoat::{Result, context::Cx};
103    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
104    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
105    /// # struct PostResource;
106    /// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
107    /// async fn public_posts(cx: &Cx, admin: &PanelHandle) -> Result<Vec<Post>> {
108    ///     let cx = admin.enter(cx);
109    ///     Ok(scoped_query::<PostResource>(&cx)?
110    ///         .exec(&mut tablo_core::db::db(&cx)?)
111    ///         .await?)
112    /// }
113    /// ```
114    pub fn enter(&self, cx: &Cx) -> Cx {
115        match &self.panel {
116            Some(panel) => enter(cx, panel),
117            None => cx.with(EnteredPanel(Arc::clone(&self.mounts))),
118        }
119    }
120
121    /// The panel's prefix, e.g. `/admin`, for a public page's link to it; `None` for
122    /// [`Panel::handle`]'s, which no router mounts.
123    pub fn url(&self) -> Option<String> {
124        self.panel.as_ref().map(|panel| panel.realm.prefix.clone())
125    }
126
127    /// Sends stored notifications to this panel's users from outside a request, such as from a
128    /// job, for no tenant until [`Notifications::tenant`] names one. The bells they have open
129    /// update when the handle is a router's.
130    ///
131    /// ```rust,no_run
132    /// # use tablo_core::{Notification, PanelHandle};
133    /// # async fn nightly(admin: &PanelHandle, owner_id: &str, tenant: tablo_core::TenantId) -> topcoat::Result<()> {
134    /// admin
135    ///     .notifications()
136    ///     .tenant(tenant)
137    ///     .send(owner_id, Notification::info("The nightly import finished"))
138    ///     .await?;
139    /// # Ok(())
140    /// # }
141    /// ```
142    pub fn notifications(&self) -> Notifications {
143        Notifications::new(self.db.clone(), self.bus.clone(), self.prefix.clone(), None)
144    }
145
146    /// Deletes the panel's [`AuditEntry`](crate::AuditEntry) rows older than `age`. Nothing else
147    /// deletes them; a job calls it on the schedule the app keeps its audit trail for.
148    ///
149    /// # Errors
150    ///
151    /// An opaque infrastructure failure when the rows cannot be deleted.
152    pub async fn prune_audit_log(&self, age: std::time::Duration) -> topcoat::Result<()> {
153        let cutoff = jiff::Timestamp::now()
154            .checked_sub(jiff::SignedDuration::try_from(age).map_err(topcoat::Error::from)?)
155            .map_err(topcoat::Error::from)?;
156        let fields = crate::AuditEntry::fields();
157        crate::AuditEntry::filter(
158            fields
159                .panel()
160                .eq(self.prefix.clone())
161                .and(fields.created_at().lt(cutoff)),
162        )
163        .delete()
164        .exec(&mut self.db.clone())
165        .await
166        .map_err(|error| {
167            crate::error::driver_failure(error, "The audit log is unavailable right now.")
168        })
169    }
170
171    /// Renders a complete HTML document around a page outside every prefix with this panel's
172    /// shell settings: its stylesheet, font, scripts, favicon and dark-mode default. A handle
173    /// [`Panel::handle`] gives renders the document without them.
174    ///
175    /// ```rust,no_run
176    /// # use tablo_core::PanelHandle;
177    /// # use topcoat::{Result, context::{Cx, context}, router::{Slot, layout}, view::{View, view}};
178    /// /// The handle `panel_handle("admin")` returned, kept on the router.
179    /// struct Admin(PanelHandle);
180    ///
181    /// #[layout("/blog")]
182    /// async fn blog_layout(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
183    ///     let Admin(admin) = context::<Admin>(cx);
184    ///     admin.document(cx, "Blog", view! { <main>(slot)</main> }).await
185    /// }
186    /// ```
187    pub async fn document<'a>(
188        &'a self,
189        cx: &'a Cx,
190        title: impl Into<String>,
191        body: impl View + 'a,
192    ) -> Result<BoxView<'a>> {
193        Panel::render_document(cx, self.panel.as_deref(), title.into(), body.boxed()).await
194    }
195}
196
197/// The resources [`PanelHandle::enter`] put in scope for a request outside every panel.
198pub(super) struct EnteredPanel(Arc<Mounts>);
199
200/// The resources the request answers from: its own panel's, or the ones
201/// [`PanelHandle::enter`] put in scope.
202pub(super) fn request_mounts(cx: &Cx) -> Option<&Mounts> {
203    match current(cx) {
204        Some(panel) => Some(&*panel.mounts),
205        None => {
206            topcoat::context::try_context::<EnteredPanel>(cx).map(|EnteredPanel(mounts)| &**mounts)
207        }
208    }
209}
210
211/// The catalog [`PanelHandle::context`] renders words from, outside every request.
212struct HeldCatalog(Arc<Catalog>);
213
214/// The [`CatalogSource`] of a handle's context.
215fn held_catalog(cx: &Cx) -> Option<&Catalog> {
216    topcoat::context::try_context::<HeldCatalog>(cx).map(|HeldCatalog(catalog)| &**catalog)
217}
218
219impl Panel {
220    /// The panel's resources declared and checked against `db` without a router: what a test, or
221    /// a job that runs with no router, builds its contexts from with [`PanelHandle::context`].
222    /// A job that runs beside a router takes the mounted panel's handle from
223    /// [`panel_handle`](super::RouterBuilderPanelExt::panel_handle) instead.
224    ///
225    /// # Errors
226    ///
227    /// The declaration errors [`panel`](super::RouterBuilderPanelExt::panel) refuses the resources
228    /// with.
229    pub fn handle(self, db: &Db) -> Result<PanelHandle> {
230        let Panel {
231            prefix,
232            registrations,
233            configuration_errors,
234            uploads,
235            auth,
236            limits,
237            locales,
238            translations,
239            audit_log,
240            ..
241        } = self;
242        let mut registry = Registry::new(prefix.clone(), Some(AppSchema::of_db(db)));
243        let (mounts, mut errors) =
244            registry.register_all(registrations, audit_log, configuration_errors);
245        let serves_tenants = serves_tenants(&auth, &mounts);
246        registry.check_all(
247            db,
248            &mounts,
249            serves_tenants,
250            uploads.is_some(),
251            &limits,
252            &mut errors,
253        );
254        let catalog = build_catalog(&locales, &translations, &mut errors);
255        if !errors.is_empty() {
256            return Err(MountError::new(&prefix, errors).into());
257        }
258        Ok(PanelHandle {
259            db: db.clone(),
260            mounts,
261            panel: None,
262            catalog: Arc::new(catalog.expect("a catalog that failed to build fails the handle")),
263            prefix,
264            bus: NotificationBus::new(),
265        })
266    }
267}