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}