819 lines
28 KiB
Rust
819 lines
28 KiB
Rust
//! The request handlers behind the six table-definition pages.
|
|
//!
|
|
//! Each page has a GET that renders it and, except for the read-only history,
|
|
//! a POST per write it offers. A write answers with its own page's body,
|
|
//! re-read from the backend, so what the user sees after a change is the
|
|
//! definition as it now is rather than the form they submitted. A refused
|
|
//! write answers the same way but with 422 and the backend's own message,
|
|
//! which `ui/base.html` swaps in because a 4xx still carries the explanation.
|
|
//!
|
|
//! Two exceptions. Staging a column changes nothing on the server, so those
|
|
//! interactions swap only the column panel. And a successful delete leaves no
|
|
//! page to return to — the table it was about is gone — so it redirects to the
|
|
//! admin panel instead.
|
|
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode},
|
|
response::{Html, IntoResponse, Redirect, Response},
|
|
};
|
|
use axum_extra::extract::Form;
|
|
|
|
use crate::{
|
|
AppState,
|
|
definitions::table_definition::{
|
|
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
|
|
ColumnPresentation, DeleteTableRequest, SetColumnPresentationRequest,
|
|
},
|
|
{i18n::Locale, tr},
|
|
schema::{ColumnForm, proto_columns},
|
|
services::{authenticated_request, reject_cross_site},
|
|
};
|
|
|
|
use super::{
|
|
loader::{self, load_page},
|
|
state::{
|
|
AliasForm, CopyForm, DeleteForm, DetailColumn, GeneratedTableView, InvoiceTemplateForm,
|
|
LoadError, OrderForm, PageInputs, Selection, TableDefinitionPageState,
|
|
},
|
|
ui,
|
|
};
|
|
|
|
/// Which page a response belongs to: what the switcher marks as current, and
|
|
/// which fragment a write on it swaps back.
|
|
#[derive(Clone, Copy)]
|
|
enum Page {
|
|
AddColumns,
|
|
Presentation,
|
|
Delete,
|
|
Copy,
|
|
Template,
|
|
History,
|
|
}
|
|
|
|
impl Page {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::AddColumns => "add-columns",
|
|
Self::Presentation => "presentation",
|
|
Self::Delete => "delete",
|
|
Self::Copy => "copy",
|
|
Self::Template => "template",
|
|
Self::History => "history",
|
|
}
|
|
}
|
|
|
|
fn render_page(self, page: &TableDefinitionPageState) -> String {
|
|
match self {
|
|
Self::AddColumns => ui::render_add_columns_page(page),
|
|
Self::Presentation => ui::render_presentation_page(page),
|
|
Self::Delete => ui::render_delete_page(page),
|
|
Self::Copy => ui::render_copy_page(page),
|
|
Self::Template => ui::render_template_page(page),
|
|
Self::History => ui::render_history_page(page),
|
|
}
|
|
}
|
|
|
|
/// The `#table-panel` swap. History has no writes, so it never asks.
|
|
fn render_fragment(self, page: &TableDefinitionPageState) -> String {
|
|
match self {
|
|
Self::AddColumns => ui::render_add_columns_fragment(page),
|
|
Self::Presentation => ui::render_presentation_fragment(page),
|
|
Self::Delete => ui::render_delete_fragment(page),
|
|
Self::Copy => ui::render_copy_fragment(page),
|
|
Self::Template => ui::render_template_fragment(page),
|
|
Self::History => ui::render_history_page(page),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── The pages ───────────────────────────────────────────────────────────────
|
|
|
|
/// GET /admin/tables/columns/add
|
|
pub(crate) async fn add_columns_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, PageInputs::for_selection(selection), Page::AddColumns).await
|
|
}
|
|
|
|
/// GET /admin/tables/presentation
|
|
pub(crate) async fn presentation_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, PageInputs::for_selection(selection), Page::Presentation).await
|
|
}
|
|
|
|
/// GET /admin/tables/delete
|
|
pub(crate) async fn delete_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, PageInputs::for_selection(selection), Page::Delete).await
|
|
}
|
|
|
|
/// GET /admin/profiles/copy
|
|
pub(crate) async fn copy_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, profile_inputs(selection), Page::Copy).await
|
|
}
|
|
|
|
/// GET /admin/tables/from-template
|
|
pub(crate) async fn template_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, profile_inputs(selection), Page::Template).await
|
|
}
|
|
|
|
/// GET /admin/profiles/history
|
|
pub(crate) async fn history_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
) -> Response {
|
|
show(state, headers, PageInputs::for_selection(selection), Page::History).await
|
|
}
|
|
|
|
/// The profile-wide pages act on a profile, so a table in the query is
|
|
/// dropped rather than carried into a form that has no use for it.
|
|
fn profile_inputs(selection: Selection) -> PageInputs {
|
|
PageInputs::for_selection(Selection {
|
|
profile: selection.profile,
|
|
table: String::new(),
|
|
})
|
|
}
|
|
|
|
async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Page) -> Response {
|
|
match load_page(state, &headers, inputs).await {
|
|
Ok(mut loaded) => {
|
|
loaded.active = page.name();
|
|
Html(page.render_page(&loaded)).into_response()
|
|
}
|
|
Err(error) => load_error_response(&headers, error),
|
|
}
|
|
}
|
|
|
|
// ── The writes ──────────────────────────────────────────────────────────────
|
|
|
|
/// POST /admin/tables/columns/add/builder — staging a column to append.
|
|
///
|
|
/// Nothing is written here; the panel is swapped back with the column added,
|
|
/// removed, or its index toggled, exactly as the Add-table builder works.
|
|
pub(crate) async fn update_columns(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
Form(form): Form<ColumnForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
// Staging a column applies the vocabulary's rules, so it is read before
|
|
// the panel is rebuilt; `load_page` reuses what is read here.
|
|
let mut definitions = state.definitions.clone();
|
|
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
|
|
Ok(catalog) => catalog,
|
|
Err(error) => return load_error_response(&headers, error),
|
|
};
|
|
|
|
let mut inputs = PageInputs::for_selection(selection);
|
|
inputs.columns = form.to_draft(catalog, false);
|
|
|
|
let index = form.index.unwrap_or(0);
|
|
let locale = Locale::from_headers(&headers);
|
|
match form.action.as_str() {
|
|
"add-column" => match inputs.columns.add_from_inputs(locale) {
|
|
Ok(status) => inputs.status = Some(status),
|
|
Err(message) => inputs.error = Some(message),
|
|
},
|
|
"remove-column" => {
|
|
if let Err(message) = inputs.columns.remove(locale, index) {
|
|
inputs.error = Some(message);
|
|
}
|
|
}
|
|
"toggle-index" => inputs.columns.toggle_indexed(index),
|
|
_ => {}
|
|
}
|
|
|
|
match load_page(state, &headers, inputs).await {
|
|
Ok(mut loaded) => {
|
|
loaded.active = Page::AddColumns.name();
|
|
Html(ui::render_column_panel(&loaded)).into_response()
|
|
}
|
|
Err(error) => load_error_response(&headers, error),
|
|
}
|
|
}
|
|
|
|
/// POST /admin/tables/columns/add — AddTableColumns.
|
|
pub(crate) async fn add_columns(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(selection): Query<Selection>,
|
|
Form(form): Form<ColumnForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
// The draft is validated before the write, so the vocabulary it is held to
|
|
// is read first; `load_page` reuses what is read here.
|
|
let mut definitions = state.definitions.clone();
|
|
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
|
|
Ok(catalog) => catalog,
|
|
Err(error) => return load_error_response(&headers, error),
|
|
};
|
|
|
|
let mut inputs = PageInputs::for_selection(selection);
|
|
inputs.columns = form.to_draft(catalog.clone(), false);
|
|
|
|
if !inputs.selection.has_table() {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-select-table-first"
|
|
);
|
|
return refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::AddColumns,
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
if inputs.columns.is_empty() {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-describe-column"
|
|
);
|
|
return refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::AddColumns,
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
// The same checks the server runs, applied to a draft that may have been
|
|
// rebuilt from a posted form rather than through the panel.
|
|
if let Err(message) = inputs.columns.validate(Locale::from_headers(&headers)) {
|
|
return refuse(state, headers, inputs, Page::AddColumns, message).await;
|
|
}
|
|
|
|
let request = AddTableColumnsRequest {
|
|
profile_name: inputs.selection.profile.clone(),
|
|
table_name: inputs.selection.table.clone(),
|
|
columns: proto_columns(&inputs.columns.added),
|
|
indexes: inputs.columns.selected_index_names(),
|
|
// The append panel names its columns itself; the only generated ones it
|
|
// can produce are the phone and IBAN companions, which it does not
|
|
// offer to rename. The table's own rename form is where that is done.
|
|
generated_aliases: Vec::new(),
|
|
};
|
|
let Ok(request) = authenticated_request(&headers, request) else {
|
|
return Redirect::to("/login").into_response();
|
|
};
|
|
|
|
match definitions.add_table_columns(request).await {
|
|
Ok(response) if response.get_ref().success => {
|
|
let added = inputs.columns.added.len();
|
|
inputs.sql = Some(response.into_inner().sql);
|
|
inputs.status = Some(tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-columns-added",
|
|
"count" => added as i64,
|
|
"table" => inputs.selection.table.clone(),
|
|
));
|
|
// The columns are the table's now, so the panel starts empty.
|
|
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
|
|
respond(state, headers, inputs, Page::AddColumns, StatusCode::OK).await
|
|
}
|
|
Ok(response) => {
|
|
let message = response.into_inner().sql;
|
|
let message = if message.is_empty() {
|
|
tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-backend-no-columns"
|
|
)
|
|
} else {
|
|
message
|
|
};
|
|
refuse(state, headers, inputs, Page::AddColumns, message).await
|
|
}
|
|
Err(error) => {
|
|
refuse(state, headers, inputs, Page::AddColumns, error.message().to_string()).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /admin/tables/presentation/alias — SetColumnPresentation, renaming one
|
|
/// column.
|
|
///
|
|
/// The request the backend wants is the whole table, so the columns this write
|
|
/// is not about are filled in from a fresh read rather than from the browser.
|
|
/// That is the point of the split: the only thing the form contributes is the
|
|
/// alias and the id of the column it was typed into.
|
|
pub(crate) async fn set_column_alias(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<AliasForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let inputs = PageInputs::for_selection(Selection {
|
|
profile: form.profile.clone(),
|
|
table: form.table.clone(),
|
|
});
|
|
|
|
let alias = form.alias.trim().to_string();
|
|
if alias.is_empty() {
|
|
let message = tr!(Locale::from_headers(&headers), "td-err-choose-rename");
|
|
return refuse(state, headers, inputs, Page::Presentation, message).await;
|
|
}
|
|
|
|
let columns = match current_columns(&state, &headers, &inputs).await {
|
|
Ok(columns) => columns,
|
|
Err(response) => return response,
|
|
};
|
|
if !columns.iter().any(|column| column.column_id == form.column_id) {
|
|
let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column");
|
|
return refuse(state, headers, inputs, Page::Presentation, message).await;
|
|
}
|
|
let presentation = columns
|
|
.iter()
|
|
.map(|column| ColumnPresentation {
|
|
column_id: column.column_id,
|
|
alias: if column.column_id == form.column_id {
|
|
alias.clone()
|
|
} else {
|
|
column.name.clone()
|
|
},
|
|
})
|
|
.collect();
|
|
|
|
apply_presentation(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
form.profile,
|
|
form.table,
|
|
form.expected_row_version,
|
|
presentation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// POST /admin/tables/presentation/order — SetColumnPresentation, saving the
|
|
/// order staged by the browser.
|
|
///
|
|
/// Every alias in the request is the name the backend just reported, so this
|
|
/// write cannot rename a column. The ids from the browser must be an exact
|
|
/// permutation of the current columns before their order is accepted.
|
|
pub(crate) async fn set_column_order(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<OrderForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let inputs = PageInputs::for_selection(Selection {
|
|
profile: form.profile.clone(),
|
|
table: form.table.clone(),
|
|
});
|
|
|
|
let columns = match current_columns(&state, &headers, &inputs).await {
|
|
Ok(columns) => columns,
|
|
Err(response) => return response,
|
|
};
|
|
let current_ids = columns
|
|
.iter()
|
|
.map(|column| column.column_id)
|
|
.collect::<std::collections::HashSet<_>>();
|
|
let submitted_ids = form
|
|
.column_ids
|
|
.iter()
|
|
.copied()
|
|
.collect::<std::collections::HashSet<_>>();
|
|
if form.column_ids.len() != columns.len()
|
|
|| submitted_ids.len() != form.column_ids.len()
|
|
|| submitted_ids != current_ids
|
|
{
|
|
let message = tr!(Locale::from_headers(&headers), "td-err-invalid-order");
|
|
return refuse(state, headers, inputs, Page::Presentation, message).await;
|
|
}
|
|
|
|
let columns_by_id = columns
|
|
.iter()
|
|
.map(|column| (column.column_id, column))
|
|
.collect::<std::collections::HashMap<_, _>>();
|
|
let presentation = form
|
|
.column_ids
|
|
.iter()
|
|
.map(|column_id| {
|
|
let column = columns_by_id[column_id];
|
|
ColumnPresentation {
|
|
column_id: *column_id,
|
|
alias: column.name.clone(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
apply_presentation(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
form.profile,
|
|
form.table,
|
|
form.expected_row_version,
|
|
presentation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// The table's columns as the backend has them now, in their current order.
|
|
///
|
|
/// Both presentation writes have to send every column, and the ones they are
|
|
/// not about must carry the name the server holds this moment -- not the name
|
|
/// the browser was showing when the page was drawn.
|
|
async fn current_columns(
|
|
state: &AppState,
|
|
headers: &HeaderMap,
|
|
inputs: &PageInputs,
|
|
) -> Result<Vec<DetailColumn>, Response> {
|
|
match load_page(state.clone(), headers, inputs.clone()).await {
|
|
Ok(page) => Ok(page.detail.map(|detail| detail.columns).unwrap_or_default()),
|
|
Err(error) => Err(load_error_response(headers, error)),
|
|
}
|
|
}
|
|
|
|
/// The half both writes share: send the presentation, answer with the page.
|
|
///
|
|
/// `expected_row_version` is the browser's, not the one the read above saw, so
|
|
/// a definition that changed under the user is still refused by the backend
|
|
/// rather than silently written over.
|
|
async fn apply_presentation(
|
|
state: AppState,
|
|
headers: HeaderMap,
|
|
inputs: PageInputs,
|
|
profile: String,
|
|
table: String,
|
|
expected_row_version: i64,
|
|
columns: Vec<ColumnPresentation>,
|
|
) -> Response {
|
|
let request = SetColumnPresentationRequest {
|
|
profile_name: profile,
|
|
table_name: table,
|
|
columns,
|
|
expected_row_version,
|
|
};
|
|
let Ok(request) = authenticated_request(&headers, request) else {
|
|
return Redirect::to("/login").into_response();
|
|
};
|
|
|
|
let mut definitions = state.definitions.clone();
|
|
match definitions.set_column_presentation(request).await {
|
|
Ok(response) if response.get_ref().success => {
|
|
let mut inputs = inputs;
|
|
inputs.status = Some(response.into_inner().message);
|
|
respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await
|
|
}
|
|
Ok(response) => {
|
|
let message = response.into_inner().message;
|
|
let message = if message.is_empty() {
|
|
tr!(Locale::from_headers(&headers), "td-err-backend-no-rename")
|
|
} else {
|
|
message
|
|
};
|
|
refuse(state, headers, inputs, Page::Presentation, message).await
|
|
}
|
|
Err(error) => {
|
|
refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::Presentation,
|
|
error.message().to_string(),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /admin/tables/delete — DeleteTable.
|
|
///
|
|
/// On success there is nothing left for this page to show, so it hands the
|
|
/// browser to the admin panel with the profile still selected.
|
|
pub(crate) async fn delete_table(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<DeleteForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let inputs = PageInputs::for_selection(Selection {
|
|
profile: form.profile.clone(),
|
|
table: form.table.clone(),
|
|
});
|
|
|
|
// The typed name is the whole guard: the drop is CASCADE, and it takes the
|
|
// profile with it when this was its last table.
|
|
if form.confirm_table_name.trim() != form.table || form.table.is_empty() {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-type-name"
|
|
);
|
|
return refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::Delete,
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let request = DeleteTableRequest {
|
|
profile_name: form.profile.clone(),
|
|
table_name: form.table.clone(),
|
|
};
|
|
let Ok(request) = authenticated_request(&headers, request) else {
|
|
return Redirect::to("/login").into_response();
|
|
};
|
|
|
|
let mut definitions = state.definitions.clone();
|
|
match definitions.delete_table(request).await {
|
|
Ok(response) if response.get_ref().success => {
|
|
// The profile may have gone with it, in which case the admin panel
|
|
// refuses the selection; landing on the bare panel is what the
|
|
// caller wants then anyway.
|
|
let location = format!("/admin?profile={}", form.profile);
|
|
match HeaderValue::try_from(location) {
|
|
Ok(location) => {
|
|
let mut response = Html(String::new()).into_response();
|
|
response.headers_mut().insert("hx-redirect", location);
|
|
response
|
|
}
|
|
Err(_) => (
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Html(tr!(
|
|
Locale::from_headers(&headers),
|
|
"ui-err-invalid-redirect"
|
|
)),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|
|
Ok(response) => {
|
|
let message = response.into_inner().message;
|
|
let message = if message.is_empty() {
|
|
tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-backend-no-delete"
|
|
)
|
|
} else {
|
|
message
|
|
};
|
|
refuse(state, headers, inputs, Page::Delete, message).await
|
|
}
|
|
Err(error) => {
|
|
refuse(state, headers, inputs, Page::Delete, error.message().to_string()).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// POST /admin/profiles/copy — CopyProfile.
|
|
pub(crate) async fn copy_profile(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<CopyForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let mut inputs = PageInputs::for_selection(Selection {
|
|
profile: form.profile.clone(),
|
|
table: String::new(),
|
|
});
|
|
inputs.copy = form.clone();
|
|
|
|
if form.target_profile_name.trim().is_empty() {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-name-profile"
|
|
);
|
|
return refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::Copy,
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let request = CopyProfileRequest {
|
|
source_profile_name: form.profile.clone(),
|
|
target_profile_name: form.target_profile_name.trim().to_string(),
|
|
// No table selected means the whole profile, which is what the hint
|
|
// promises and what the server does with an empty list. A browser
|
|
// sends no `table_names` key at all when nothing is ticked; anything
|
|
// posting the field empty would otherwise send one blank name, and
|
|
// the server rejects a blank name rather than reading it as "all".
|
|
table_names: form
|
|
.table_names
|
|
.iter()
|
|
.map(|name| name.trim())
|
|
.filter(|name| !name.is_empty())
|
|
.map(str::to_string)
|
|
.collect(),
|
|
};
|
|
let Ok(request) = authenticated_request(&headers, request) else {
|
|
return Redirect::to("/login").into_response();
|
|
};
|
|
|
|
let mut definitions = state.definitions.clone();
|
|
match definitions.copy_profile(request).await {
|
|
Ok(response) if response.get_ref().success => {
|
|
let response = response.into_inner();
|
|
inputs.status = Some(tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-profile-copied",
|
|
"message" => response.message.clone(),
|
|
"tables" => response.tables_copied as i64,
|
|
"scripts" => response.scripts_copied as i64,
|
|
));
|
|
inputs.copy = CopyForm {
|
|
profile: form.profile,
|
|
..Default::default()
|
|
};
|
|
respond(state, headers, inputs, Page::Copy, StatusCode::OK).await
|
|
}
|
|
Ok(response) => {
|
|
let message = response.into_inner().message;
|
|
let message = if message.is_empty() {
|
|
tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-backend-no-copy"
|
|
)
|
|
} else {
|
|
message
|
|
};
|
|
refuse(state, headers, inputs, Page::Copy, message).await
|
|
}
|
|
Err(error) => refuse(state, headers, inputs, Page::Copy, error.message().to_string()).await,
|
|
}
|
|
}
|
|
|
|
/// POST /admin/tables/from-template — CreateInvoiceTemplateTable.
|
|
pub(crate) async fn create_from_invoice_template(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<InvoiceTemplateForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let mut inputs = PageInputs::for_selection(Selection {
|
|
profile: form.profile.clone(),
|
|
table: String::new(),
|
|
});
|
|
inputs.invoice = form.clone();
|
|
|
|
if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-template-required"
|
|
);
|
|
return refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::Template,
|
|
message,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let request = CreateInvoiceTemplateTableRequest {
|
|
profile_name: form.profile.clone(),
|
|
table_name: form.table_name.trim().to_string(),
|
|
typst_source: form.typst_source.clone(),
|
|
row_display_columns: form.display_columns(),
|
|
};
|
|
let Ok(request) = authenticated_request(&headers, request) else {
|
|
return Redirect::to("/login").into_response();
|
|
};
|
|
|
|
let mut definitions = state.definitions.clone();
|
|
match definitions.create_invoice_template_table(request).await {
|
|
Ok(response) if response.get_ref().success => {
|
|
let response = response.into_inner();
|
|
inputs.status = Some(tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-template-tables-created",
|
|
"count" => response.tables.len() as i64,
|
|
));
|
|
inputs.generated = response
|
|
.tables
|
|
.into_iter()
|
|
.map(|table| GeneratedTableView {
|
|
table_name: table.table_name,
|
|
collection_path: table.collection_path,
|
|
parent_table_name: table.parent_table_name,
|
|
})
|
|
.collect();
|
|
inputs.invoice = InvoiceTemplateForm {
|
|
profile: form.profile,
|
|
..Default::default()
|
|
};
|
|
respond(state, headers, inputs, Page::Template, StatusCode::OK).await
|
|
}
|
|
Ok(_) => {
|
|
let message = tr!(
|
|
Locale::from_headers(&headers),
|
|
"td-err-backend-no-template"
|
|
);
|
|
refuse(
|
|
state,
|
|
headers,
|
|
inputs,
|
|
Page::Template,
|
|
message,
|
|
)
|
|
.await
|
|
}
|
|
Err(error) => {
|
|
refuse(state, headers, inputs, Page::Template, error.message().to_string()).await
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-reads the page's body and answers with it.
|
|
async fn respond(
|
|
state: AppState,
|
|
headers: HeaderMap,
|
|
inputs: PageInputs,
|
|
page: Page,
|
|
status: StatusCode,
|
|
) -> Response {
|
|
match load_page(state, &headers, inputs).await {
|
|
Ok(mut loaded) => {
|
|
loaded.active = page.name();
|
|
(status, Html(page.render_fragment(&loaded))).into_response()
|
|
}
|
|
Err(error) => load_error_response(&headers, error),
|
|
}
|
|
}
|
|
|
|
/// Answers a refused write: the page as it still is, plus the reason.
|
|
async fn refuse(
|
|
state: AppState,
|
|
headers: HeaderMap,
|
|
mut inputs: PageInputs,
|
|
page: Page,
|
|
message: String,
|
|
) -> Response {
|
|
inputs.error = Some(message);
|
|
respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await
|
|
}
|
|
|
|
fn load_error_response(headers: &HeaderMap, error: LoadError) -> Response {
|
|
match error {
|
|
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
|
LoadError::Forbidden => (
|
|
StatusCode::FORBIDDEN,
|
|
Html(ui::render_load_error(
|
|
Locale::from_headers(headers),
|
|
&tr!(
|
|
Locale::from_headers(headers),
|
|
"td-err-permission"
|
|
),
|
|
)),
|
|
)
|
|
.into_response(),
|
|
LoadError::Backend(message) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Html(ui::render_load_error(Locale::from_headers(headers), &message)),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|