conversions web + put table definition
This commit is contained in:
@@ -90,6 +90,7 @@ mod tests {
|
||||
"/admin/validation/sets/new",
|
||||
"/admin/import",
|
||||
"/admin/export",
|
||||
"/admin/exchange-rates",
|
||||
"/logout",
|
||||
] {
|
||||
assert!(html.contains(route), "missing admin action route {route}");
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
ecb::GetEcbPipelineStatusRequest,
|
||||
AppState, auth::GetAuthorizationRequest, ecb::GetEcbPipelineStatusRequest,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ use axum::{
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::{AppState, {i18n::Locale, tr}};
|
||||
use crate::{
|
||||
AppState,
|
||||
{i18n::Locale, tr},
|
||||
};
|
||||
|
||||
use super::{loader::load_ecb_page, state::LoadError, ui};
|
||||
|
||||
@@ -25,10 +28,7 @@ pub(crate) async fn page(State(state): State<AppState>, headers: HeaderMap) -> R
|
||||
/// Polled only while a batch is running; the card stops asking for itself once
|
||||
/// the batch it was watching has finished, because the fragment it swaps in
|
||||
/// carries no trigger.
|
||||
pub(crate) async fn status_fragment(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
pub(crate) async fn status_fragment(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
match load_ecb_page(state, &headers).await {
|
||||
Ok(page) => Html(ui::render_status(&page)).into_response(),
|
||||
Err(error) => error_response(&headers, error),
|
||||
@@ -42,19 +42,14 @@ fn error_response(headers: &HeaderMap, error: LoadError) -> Response {
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_error(
|
||||
Locale::from_headers(headers),
|
||||
&tr!(
|
||||
Locale::from_headers(headers),
|
||||
"ecb-forbidden"
|
||||
),
|
||||
&tr!(Locale::from_headers(headers), "ecb-forbidden"),
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_error(Locale::from_headers(headers), &message)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
LoadError::Backend(message) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_error(Locale::from_headers(headers), &message)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
//! Health of the ECB reference-rate pipeline.
|
||||
//!
|
||||
//! Read-only, and deliberately not profile-scoped: one importer feeds every
|
||||
//! profile, so the question "are the rates current" has one answer for the
|
||||
//! whole deployment. That is also why it is its own page rather than a panel
|
||||
//! on a profile's — it is not about a profile at all.
|
||||
//!
|
||||
//! The page matters because coverage is a precondition, not a detail: a
|
||||
//! conversion whose publication date falls beyond verified coverage is refused
|
||||
//! outright by the backend. When posting starts failing for that reason, this
|
||||
//! is the screen that says so.
|
||||
//! Profile configuration, conversion audit tools, and provider health for
|
||||
//! exchange-rate administration.
|
||||
|
||||
mod loader;
|
||||
mod logic;
|
||||
mod state;
|
||||
mod ui;
|
||||
mod workspace_loader;
|
||||
mod workspace_logic;
|
||||
mod workspace_state;
|
||||
mod workspace_ui;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/ecb", get(logic::page))
|
||||
.route("/admin/exchange-rates", get(workspace_logic::page))
|
||||
.route(
|
||||
"/admin/exchange-rates/configuration",
|
||||
post(workspace_logic::add_configuration),
|
||||
)
|
||||
.route(
|
||||
"/admin/exchange-rates/preview",
|
||||
post(workspace_logic::preview),
|
||||
)
|
||||
.route(
|
||||
"/admin/exchange-rates/evidence",
|
||||
post(workspace_logic::evidence),
|
||||
)
|
||||
.route("/admin/ecb", get(workspace_logic::legacy_ecb))
|
||||
.route("/admin/ecb/provider", get(logic::page))
|
||||
// The status card on its own, for the poll that keeps it live while a
|
||||
// batch is running.
|
||||
.route("/admin/ecb/status", get(logic::status_fragment))
|
||||
|
||||
@@ -48,7 +48,10 @@ fn coarse(locale: Locale, duration: SignedDuration) -> String {
|
||||
}
|
||||
|
||||
fn elapsed_between(locale: Locale, from: &str, to: &str) -> Option<String> {
|
||||
let (from, to) = (from.parse::<Timestamp>().ok()?, to.parse::<Timestamp>().ok()?);
|
||||
let (from, to) = (
|
||||
from.parse::<Timestamp>().ok()?,
|
||||
to.parse::<Timestamp>().ok()?,
|
||||
);
|
||||
Some(coarse(locale, from.duration_until(to)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use askama::Template;
|
||||
|
||||
use crate::{i18n::Locale, tr};
|
||||
use crate::ui::{ErrorPage, Nav, render};
|
||||
use crate::{i18n::Locale, tr};
|
||||
|
||||
use super::state::EcbPageState;
|
||||
|
||||
@@ -21,6 +21,14 @@ struct StatusFragment<'a> {
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
/// Provider import attempts embedded in the administration workspace.
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/attempts.html")]
|
||||
struct AttemptsFragment<'a> {
|
||||
nav: Nav,
|
||||
page: &'a EcbPageState,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &EcbPageState) -> String {
|
||||
render(&EcbPage {
|
||||
nav: page.nav.clone(),
|
||||
@@ -35,6 +43,13 @@ pub(crate) fn render_status(page: &EcbPageState) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_attempts(page: &EcbPageState) -> String {
|
||||
render(&AttemptsFragment {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(locale: Locale, message: &str) -> String {
|
||||
render(&ErrorPage {
|
||||
nav: Nav::default(),
|
||||
|
||||
141
web/src/pages/admin/ecb/workspace_loader.rs
Normal file
141
web/src/pages/admin/ecb/workspace_loader.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Loads profile configuration and provider health for the exchange-rate workspace.
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::{common::Empty, exchange_rates::GetProfileExchangeRateSettingsRequest},
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader::load_ecb_page,
|
||||
state::LoadError,
|
||||
workspace_state::{
|
||||
CurrencySettingsView, CurrencySourceView, ExchangeRateWorkspaceState, ProfileSettingsView,
|
||||
WorkspaceInputs,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) async fn load_workspace(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
mut inputs: WorkspaceInputs,
|
||||
) -> Result<ExchangeRateWorkspaceState, LoadError> {
|
||||
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
|
||||
.map_err(|_| LoadError::Unauthenticated)?;
|
||||
let mut auth = state.auth.clone();
|
||||
let authorization = auth
|
||||
.get_authorization(authorization_request)
|
||||
.await
|
||||
.map_err(|error| match error.code() {
|
||||
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
|
||||
tonic::Code::PermissionDenied => LoadError::Forbidden,
|
||||
_ => LoadError::Backend(error.message().to_string()),
|
||||
})?
|
||||
.into_inner();
|
||||
if !crate::authz::can_read_exchange_rates(&authorization) {
|
||||
return Err(LoadError::Forbidden);
|
||||
}
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
let tree = definitions
|
||||
.get_profile_tree(
|
||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
let profiles = tree
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|profile| profile.name)
|
||||
.collect::<Vec<_>>();
|
||||
if inputs.selected_profile.is_empty() {
|
||||
inputs.selected_profile = profiles.first().cloned().unwrap_or_default();
|
||||
}
|
||||
if !inputs.selected_profile.is_empty()
|
||||
&& !profiles
|
||||
.iter()
|
||||
.any(|profile| profile == &inputs.selected_profile)
|
||||
{
|
||||
inputs.error.get_or_insert(crate::tr!(
|
||||
crate::i18n::Locale::from_headers(headers),
|
||||
"rates-err-profile-missing"
|
||||
));
|
||||
inputs.selected_profile = profiles.first().cloned().unwrap_or_default();
|
||||
}
|
||||
|
||||
let mut exchange_rates = state.exchange_rates.clone();
|
||||
let providers = exchange_rates
|
||||
.list_rate_sources(
|
||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|source| source.id)
|
||||
.collect();
|
||||
|
||||
let settings = if inputs.selected_profile.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let response = exchange_rates
|
||||
.get_profile_exchange_rate_settings(
|
||||
authenticated_request(
|
||||
headers,
|
||||
GetProfileExchangeRateSettingsRequest {
|
||||
profile_name: inputs.selected_profile.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|_| LoadError::Unauthenticated)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
Some(ProfileSettingsView {
|
||||
accounting_currency: response.accounting_currency,
|
||||
foreign_currencies: response
|
||||
.foreign_currencies
|
||||
.into_iter()
|
||||
.map(|currency| CurrencySettingsView {
|
||||
currency: currency.currency,
|
||||
default_source_id: currency.default_source_id,
|
||||
sources: currency
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|source| CurrencySourceView {
|
||||
source_id: source.source_id,
|
||||
coverage_complete: source.coverage_complete,
|
||||
verified_from_date: source.verified_from_date,
|
||||
verified_through_date: source.verified_through_date,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
};
|
||||
|
||||
let provider_health = load_ecb_page(state, headers).await?;
|
||||
Ok(ExchangeRateWorkspaceState {
|
||||
nav: crate::ui::Nav::from_authorization(headers, "ecb", &authorization),
|
||||
profiles,
|
||||
selected_profile: inputs.selected_profile,
|
||||
providers,
|
||||
settings,
|
||||
can_configure: crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE),
|
||||
configuration: inputs.configuration,
|
||||
preview_form: inputs.preview_form,
|
||||
preview: inputs.preview,
|
||||
evidence_form: inputs.evidence_form,
|
||||
evidence: inputs.evidence,
|
||||
evidence_has_exchange: inputs.evidence_has_exchange,
|
||||
evidence_has_more: inputs.evidence_has_more,
|
||||
provider_health,
|
||||
status: inputs.status,
|
||||
error: inputs.error,
|
||||
})
|
||||
}
|
||||
250
web/src/pages/admin/ecb/workspace_logic.rs
Normal file
250
web/src/pages/admin/ecb/workspace_logic.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
//! HTTP handlers for the provider-neutral exchange-rate administration workspace.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
definitions::exchange_rates::{
|
||||
AddProfileCurrencySourceRequest, ExchangeRateDateRule, ExchangeRateSource,
|
||||
ListConversionEvidenceRequest,
|
||||
},
|
||||
services::{authenticated_request, reject_cross_site},
|
||||
};
|
||||
|
||||
use super::{
|
||||
state::LoadError,
|
||||
workspace_loader::load_workspace,
|
||||
workspace_state::{
|
||||
ConfigurationForm, EvidenceForm, EvidenceView, PreviewForm, PreviewView, WorkspaceInputs,
|
||||
WorkspaceQuery,
|
||||
},
|
||||
workspace_ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn legacy_ecb() -> Redirect {
|
||||
Redirect::permanent("/admin/exchange-rates#providers")
|
||||
}
|
||||
|
||||
pub(crate) async fn page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<WorkspaceQuery>,
|
||||
) -> Response {
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, WorkspaceInputs::for_profile(query.profile)).await,
|
||||
StatusCode::OK,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn add_configuration(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ConfigurationForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
let mut inputs = WorkspaceInputs::for_profile(form.profile.clone());
|
||||
inputs.configuration = form.clone();
|
||||
let added_source = form.source_id.trim().to_string();
|
||||
let request = AddProfileCurrencySourceRequest {
|
||||
profile_name: form.profile,
|
||||
currency: form.currency.trim().to_ascii_uppercase(),
|
||||
source_id: form.source_id.trim().to_string(),
|
||||
make_default: form.make_default,
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
let mut exchange_rates = state.exchange_rates.clone();
|
||||
match exchange_rates.add_profile_currency_source(request).await {
|
||||
Ok(currency) => {
|
||||
let currency = currency.into_inner();
|
||||
inputs.configuration = ConfigurationForm::default();
|
||||
inputs.status = Some(crate::tr!(
|
||||
crate::i18n::Locale::from_headers(&headers),
|
||||
"rates-configuration-added",
|
||||
"currency" => currency.currency,
|
||||
"source" => added_source,
|
||||
));
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::OK,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
inputs.error = Some(error.message().to_string());
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn preview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<PreviewForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
let mut inputs = WorkspaceInputs::for_profile(form.profile.clone());
|
||||
inputs.preview_form = form.clone();
|
||||
let request = match form.request() {
|
||||
Ok(request) => request,
|
||||
Err(key) => {
|
||||
inputs.error = Some(crate::tr!(crate::i18n::Locale::from_headers(&headers), key));
|
||||
return render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
let mut exchange_rates = state.exchange_rates.clone();
|
||||
match exchange_rates.preview_direct_conversion(request).await {
|
||||
Ok(response) => {
|
||||
let response = response.into_inner();
|
||||
let locale = crate::i18n::Locale::from_headers(&headers);
|
||||
inputs.preview = Some(PreviewView {
|
||||
applied_method: crate::tr!(locale, rate_method_key(response.applied_source)),
|
||||
applied_date_rule: crate::tr!(locale, date_rule_key(response.applied_date_rule)),
|
||||
response,
|
||||
});
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::OK,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
inputs.error = Some(error.message().to_string());
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn evidence(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<EvidenceForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
let mut inputs = WorkspaceInputs::for_profile(form.profile.clone());
|
||||
inputs.evidence_form = form.clone();
|
||||
let record_id = match form.record_id.trim().parse::<i64>() {
|
||||
Ok(id) if id > 0 => id,
|
||||
_ => {
|
||||
inputs.error = Some(crate::tr!(
|
||||
crate::i18n::Locale::from_headers(&headers),
|
||||
"rates-err-record-id"
|
||||
));
|
||||
return render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
};
|
||||
let request = ListConversionEvidenceRequest {
|
||||
profile_name: form.profile,
|
||||
table_name: form.table_name.trim().to_string(),
|
||||
record_id,
|
||||
column_name: form.column_name.trim().to_string(),
|
||||
limit: 50,
|
||||
before_evidence_id: None,
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
let mut exchange_rates = state.exchange_rates.clone();
|
||||
match exchange_rates.list_conversion_evidence(request).await {
|
||||
Ok(response) => {
|
||||
let response = response.into_inner();
|
||||
inputs.evidence_has_exchange = Some(response.has_exchange);
|
||||
inputs.evidence_has_more = response.has_more;
|
||||
inputs.evidence = response
|
||||
.evidence
|
||||
.into_iter()
|
||||
.map(|evidence| EvidenceView { evidence })
|
||||
.collect();
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::OK,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
inputs.error = Some(error.message().to_string());
|
||||
render_loaded(
|
||||
&headers,
|
||||
load_workspace(state, &headers, inputs).await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rate_method_key(value: i32) -> &'static str {
|
||||
match ExchangeRateSource::try_from(value).ok() {
|
||||
Some(ExchangeRateSource::Official) => "rates-source-official",
|
||||
Some(ExchangeRateSource::SavedCustom) => "rates-source-saved",
|
||||
Some(ExchangeRateSource::Manual) => "rates-source-manual",
|
||||
_ => "rates-unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn date_rule_key(value: i32) -> &'static str {
|
||||
match ExchangeRateDateRule::try_from(value).ok() {
|
||||
Some(ExchangeRateDateRule::PreviousPublication) => "rates-rule-previous",
|
||||
Some(ExchangeRateDateRule::OnOrBeforeDate) => "rates-rule-on-or-before",
|
||||
Some(ExchangeRateDateRule::SpecificPublicationDate) => "rates-rule-specific",
|
||||
_ => "rates-unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loaded(
|
||||
headers: &HeaderMap,
|
||||
result: Result<super::workspace_state::ExchangeRateWorkspaceState, LoadError>,
|
||||
success_status: StatusCode,
|
||||
) -> Response {
|
||||
match result {
|
||||
Ok(page) => (success_status, Html(workspace_ui::render_page(&page))).into_response(),
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(workspace_ui::render_error(
|
||||
crate::i18n::Locale::from_headers(headers),
|
||||
&crate::tr!(crate::i18n::Locale::from_headers(headers), "ecb-forbidden"),
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
Err(LoadError::Backend(message)) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(workspace_ui::render_error(
|
||||
crate::i18n::Locale::from_headers(headers),
|
||||
&message,
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
253
web/src/pages/admin/ecb/workspace_state.rs
Normal file
253
web/src/pages/admin/ecb/workspace_state.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! State and form wire formats for the provider-neutral exchange-rate workspace.
|
||||
|
||||
use crate::definitions::exchange_rates::{
|
||||
ConversionEvidence, ExchangeRateDateRule, ExchangeRateSelection, ExchangeRateSource,
|
||||
PreviewDirectConversionRequest, PreviewDirectConversionResponse,
|
||||
};
|
||||
|
||||
use super::state::EcbPageState;
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct WorkspaceQuery {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct ConfigurationForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub currency: String,
|
||||
#[serde(default)]
|
||||
pub source_id: String,
|
||||
#[serde(default)]
|
||||
pub make_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub(crate) struct PreviewForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub original_amount: String,
|
||||
#[serde(default)]
|
||||
pub original_currency: String,
|
||||
#[serde(default)]
|
||||
pub conversion_basis_date: String,
|
||||
#[serde(default = "automatic_official")]
|
||||
pub method: String,
|
||||
#[serde(default = "ordinary_context")]
|
||||
pub context: String,
|
||||
#[serde(default)]
|
||||
pub specific_rate_date: String,
|
||||
#[serde(default)]
|
||||
pub manual_foreign_units: String,
|
||||
#[serde(default)]
|
||||
pub rate_source_id: String,
|
||||
#[serde(default)]
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
fn automatic_official() -> String {
|
||||
"automatic-official".to_string()
|
||||
}
|
||||
|
||||
fn ordinary_context() -> String {
|
||||
"ordinary".to_string()
|
||||
}
|
||||
|
||||
impl Default for PreviewForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profile: String::new(),
|
||||
original_amount: String::new(),
|
||||
original_currency: String::new(),
|
||||
conversion_basis_date: String::new(),
|
||||
method: automatic_official(),
|
||||
context: ordinary_context(),
|
||||
specific_rate_date: String::new(),
|
||||
manual_foreign_units: String::new(),
|
||||
rate_source_id: String::new(),
|
||||
reason: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PreviewForm {
|
||||
pub(crate) fn request(&self) -> Result<PreviewDirectConversionRequest, &'static str> {
|
||||
let date_rule = match self.method.as_str() {
|
||||
"official-specific" | "manual" => ExchangeRateDateRule::SpecificPublicationDate,
|
||||
_ if self.context == "statement" => ExchangeRateDateRule::OnOrBeforeDate,
|
||||
_ => ExchangeRateDateRule::PreviousPublication,
|
||||
};
|
||||
let source = match self.method.as_str() {
|
||||
"automatic-official" | "official-specific" => ExchangeRateSource::Official,
|
||||
"saved-custom" => ExchangeRateSource::SavedCustom,
|
||||
"manual" => ExchangeRateSource::Manual,
|
||||
_ => return Err("rates-err-preview-method"),
|
||||
};
|
||||
let use_default_selection = self.method == "automatic-official"
|
||||
&& self.context != "statement"
|
||||
&& self.rate_source_id.trim().is_empty();
|
||||
let selection = (!use_default_selection).then(|| ExchangeRateSelection {
|
||||
date_rule: date_rule as i32,
|
||||
source: source as i32,
|
||||
specific_rate_date: (!self.specific_rate_date.trim().is_empty())
|
||||
.then(|| self.specific_rate_date.trim().to_string()),
|
||||
manual_foreign_units: (!self.manual_foreign_units.trim().is_empty())
|
||||
.then(|| self.manual_foreign_units.trim().to_string()),
|
||||
reason: self.reason.trim().to_string(),
|
||||
rate_source_id: (!self.rate_source_id.trim().is_empty())
|
||||
.then(|| self.rate_source_id.trim().to_string()),
|
||||
});
|
||||
Ok(PreviewDirectConversionRequest {
|
||||
original_amount: self.original_amount.trim().to_string(),
|
||||
original_currency: self.original_currency.trim().to_ascii_uppercase(),
|
||||
conversion_basis_date: self.conversion_basis_date.trim().to_string(),
|
||||
profile_name: self.profile.clone(),
|
||||
exchange_rate_selection: selection,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct EvidenceForm {
|
||||
#[serde(default)]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub record_id: String,
|
||||
#[serde(default)]
|
||||
pub column_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CurrencySettingsView {
|
||||
pub currency: String,
|
||||
pub default_source_id: String,
|
||||
pub sources: Vec<CurrencySourceView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CurrencySourceView {
|
||||
pub source_id: String,
|
||||
pub coverage_complete: bool,
|
||||
pub verified_from_date: Option<String>,
|
||||
pub verified_through_date: Option<String>,
|
||||
}
|
||||
|
||||
impl CurrencySourceView {
|
||||
pub(crate) fn coverage_class(&self) -> &'static str {
|
||||
if self.coverage_complete {
|
||||
"tag-ok"
|
||||
} else {
|
||||
"tag-bad"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ProfileSettingsView {
|
||||
pub accounting_currency: String,
|
||||
pub foreign_currencies: Vec<CurrencySettingsView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PreviewView {
|
||||
pub response: PreviewDirectConversionResponse,
|
||||
pub applied_method: String,
|
||||
pub applied_date_rule: String,
|
||||
}
|
||||
|
||||
impl PreviewView {
|
||||
pub(crate) fn quote(&self) -> String {
|
||||
match (
|
||||
self.response.accounting_units.as_deref(),
|
||||
self.response.foreign_units.as_deref(),
|
||||
self.response.foreign_currency.as_deref(),
|
||||
) {
|
||||
(Some(accounting_units), Some(foreign_units), Some(foreign_currency)) => format!(
|
||||
"{} {} = {} {}",
|
||||
accounting_units,
|
||||
self.response.accounting_currency,
|
||||
foreign_units,
|
||||
foreign_currency,
|
||||
),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observation_id(&self) -> String {
|
||||
self.response
|
||||
.official_observation_id
|
||||
.map_or_else(|| "—".to_string(), |id| id.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn import_batch_id(&self) -> String {
|
||||
self.response
|
||||
.import_batch_id
|
||||
.map_or_else(|| "—".to_string(), |id| id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EvidenceView {
|
||||
pub evidence: ConversionEvidence,
|
||||
}
|
||||
|
||||
pub(crate) struct WorkspaceInputs {
|
||||
pub selected_profile: String,
|
||||
pub configuration: ConfigurationForm,
|
||||
pub preview_form: PreviewForm,
|
||||
pub preview: Option<PreviewView>,
|
||||
pub evidence_form: EvidenceForm,
|
||||
pub evidence: Vec<EvidenceView>,
|
||||
pub evidence_has_exchange: Option<bool>,
|
||||
pub evidence_has_more: bool,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceInputs {
|
||||
pub(crate) fn for_profile(profile: String) -> Self {
|
||||
Self {
|
||||
selected_profile: profile,
|
||||
configuration: ConfigurationForm::default(),
|
||||
preview_form: PreviewForm::default(),
|
||||
preview: None,
|
||||
evidence_form: EvidenceForm::default(),
|
||||
evidence: Vec::new(),
|
||||
evidence_has_exchange: None,
|
||||
evidence_has_more: false,
|
||||
status: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ExchangeRateWorkspaceState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub selected_profile: String,
|
||||
pub providers: Vec<String>,
|
||||
pub settings: Option<ProfileSettingsView>,
|
||||
pub can_configure: bool,
|
||||
pub configuration: ConfigurationForm,
|
||||
pub preview_form: PreviewForm,
|
||||
pub preview: Option<PreviewView>,
|
||||
pub evidence_form: EvidenceForm,
|
||||
pub evidence: Vec<EvidenceView>,
|
||||
pub evidence_has_exchange: Option<bool>,
|
||||
pub evidence_has_more: bool,
|
||||
pub provider_health: EcbPageState,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl ExchangeRateWorkspaceState {
|
||||
pub(crate) fn is_selected_profile(&self, profile: &str) -> bool {
|
||||
self.selected_profile == profile
|
||||
}
|
||||
}
|
||||
139
web/src/pages/admin/ecb/workspace_ui.rs
Normal file
139
web/src/pages/admin/ecb/workspace_ui.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! Rendering for the exchange-rate administration workspace.
|
||||
|
||||
use askama::Template;
|
||||
|
||||
use crate::{
|
||||
i18n::Locale,
|
||||
schema::CURRENCY_CODES,
|
||||
ui::{ErrorPage, Nav, render},
|
||||
};
|
||||
|
||||
use super::workspace_state::ExchangeRateWorkspaceState;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages/admin/ecb/workspace.html")]
|
||||
struct WorkspacePage<'a> {
|
||||
nav: Nav,
|
||||
page: &'a ExchangeRateWorkspaceState,
|
||||
currency_codes: &'static [&'static str],
|
||||
provider_status: String,
|
||||
provider_attempts: String,
|
||||
}
|
||||
|
||||
pub(crate) fn render_page(page: &ExchangeRateWorkspaceState) -> String {
|
||||
render(&WorkspacePage {
|
||||
nav: page.nav.clone(),
|
||||
page,
|
||||
currency_codes: CURRENCY_CODES,
|
||||
provider_status: super::ui::render_status(&page.provider_health),
|
||||
provider_attempts: super::ui::render_attempts(&page.provider_health),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_error(locale: Locale, message: &str) -> String {
|
||||
render(&ErrorPage {
|
||||
nav: Nav::default(),
|
||||
heading: &crate::tr!(locale, "rates-unavailable-title"),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pages::admin::ecb::{
|
||||
state::{EcbPageState, ImportBatchView},
|
||||
workspace_state::{
|
||||
ConfigurationForm, CurrencySettingsView, CurrencySourceView, EvidenceForm,
|
||||
ExchangeRateWorkspaceState, PreviewForm, ProfileSettingsView,
|
||||
},
|
||||
};
|
||||
|
||||
fn health() -> EcbPageState {
|
||||
EcbPageState {
|
||||
nav: Nav::default(),
|
||||
verified_through_date: Some("2026-08-23".to_string()),
|
||||
latest_verifiable_date: "2026-08-23".to_string(),
|
||||
healthy: true,
|
||||
days_behind: 0,
|
||||
import_running: false,
|
||||
next_import_at: "2026-08-24T15:00:00Z".to_string(),
|
||||
batches: vec![ImportBatchView {
|
||||
batch_id: 4,
|
||||
status: "succeeded".to_string(),
|
||||
requested_from: "2026-08-23".to_string(),
|
||||
requested_through: "2026-08-23".to_string(),
|
||||
endpoint: "https://example.test".to_string(),
|
||||
started_at: "2026-08-23T15:00:00Z".to_string(),
|
||||
completed_at: Some("2026-08-23T15:00:01Z".to_string()),
|
||||
verified_through_date: Some("2026-08-23".to_string()),
|
||||
observation_count: Some(2),
|
||||
inserted_observation_count: Some(2),
|
||||
error_message: None,
|
||||
}],
|
||||
covered_currencies: vec!["CZK".to_string()],
|
||||
transactions_postable_through: Some("2026-08-24".to_string()),
|
||||
statements_postable_through: Some("2026-08-23".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn page() -> ExchangeRateWorkspaceState {
|
||||
ExchangeRateWorkspaceState {
|
||||
nav: Nav::default(),
|
||||
profiles: vec!["books".to_string()],
|
||||
selected_profile: "books".to_string(),
|
||||
providers: vec!["ecb".to_string()],
|
||||
settings: Some(ProfileSettingsView {
|
||||
accounting_currency: "EUR".to_string(),
|
||||
foreign_currencies: vec![CurrencySettingsView {
|
||||
currency: "CZK".to_string(),
|
||||
default_source_id: "ecb".to_string(),
|
||||
sources: vec![CurrencySourceView {
|
||||
source_id: "ecb".to_string(),
|
||||
coverage_complete: true,
|
||||
verified_from_date: Some("2020-01-01".to_string()),
|
||||
verified_through_date: Some("2026-08-23".to_string()),
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
can_configure: true,
|
||||
configuration: ConfigurationForm::default(),
|
||||
preview_form: PreviewForm::default(),
|
||||
preview: None,
|
||||
evidence_form: EvidenceForm::default(),
|
||||
evidence: Vec::new(),
|
||||
evidence_has_exchange: None,
|
||||
evidence_has_more: false,
|
||||
provider_health: health(),
|
||||
status: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_contains_every_admin_operation() {
|
||||
let html = render_page(&page());
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Profile configuration"), "{html}");
|
||||
assert!(html.contains("Conversion preview"), "{html}");
|
||||
assert!(html.contains("Evidence lookup"), "{html}");
|
||||
assert!(html.contains("Provider health"), "{html}");
|
||||
assert!(html.contains("CZK"), "{html}");
|
||||
assert!(html.contains("ecb"), "{html}");
|
||||
assert!(html.contains("Ready"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_admin_sees_configuration_without_write_controls() {
|
||||
let mut page = page();
|
||||
page.can_configure = false;
|
||||
let html = render_page(&page);
|
||||
|
||||
assert!(html.contains("CZK"), "{html}");
|
||||
assert!(
|
||||
!html.contains(r#"action="/admin/exchange-rates/configuration""#),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,8 @@ pub(crate) async fn update_columns(
|
||||
}
|
||||
}
|
||||
"toggle-index" => inputs.columns.toggle_indexed(index),
|
||||
"move-column-up" => inputs.status = inputs.columns.move_column(locale, index, -1),
|
||||
"move-column-down" => inputs.status = inputs.columns.move_column(locale, index, 1),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,12 @@ impl TableDefinitionPageState {
|
||||
self.table_is_writable() && self.detail.as_ref().is_some_and(|detail| !detail.has_data)
|
||||
}
|
||||
|
||||
/// How many columns the table already has, for the unified "Columns"
|
||||
/// count the append panel shows alongside the create builder's own.
|
||||
pub(crate) fn existing_column_count(&self) -> usize {
|
||||
self.detail.as_ref().map_or(0, |detail| detail.columns.len())
|
||||
}
|
||||
|
||||
pub(crate) fn column_is_selected_for_removal(&self, column_id: &i64) -> bool {
|
||||
self.remove_column_ids.contains(column_id)
|
||||
}
|
||||
|
||||
@@ -465,6 +465,51 @@ mod tests {
|
||||
assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}");
|
||||
}
|
||||
|
||||
/// The edit page reuses the create builder's own section/heading/count
|
||||
/// styling, so the two read as the same component prefilled with the
|
||||
/// existing table rather than a differently-shaped screen.
|
||||
#[test]
|
||||
fn the_add_columns_page_looks_like_the_builder() {
|
||||
let mut state = page();
|
||||
state.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "total".to_string(),
|
||||
data_type: "money".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: crate::schema::MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
state.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "issued_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
required: false,
|
||||
money_mode: crate::schema::MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
|
||||
let html = render_add_columns_page(&state);
|
||||
|
||||
assert!(!html.contains(r#"class="panel""#), "{html}");
|
||||
assert!(html.contains(r#"class="builder-section""#), "{html}");
|
||||
assert!(html.contains("Add a column"), "{html}");
|
||||
// One unified table: the existing column and the staged ones all
|
||||
// under the same "Columns" heading, with a count spanning both.
|
||||
assert!(html.contains("Columns <span class=\"count\">3</span>"), "{html}");
|
||||
assert!(html.contains(r#"class="generated-row""#), "{html}");
|
||||
assert!(html.contains(r#"<code>number</code>"#), "{html}");
|
||||
assert!(html.contains(r#"<code>total</code>"#), "{html}");
|
||||
// The existing row is locked: nothing to reorder, and its remove
|
||||
// button reads the same as the staged row's but warns before it
|
||||
// toggles, since there is no undoing it once saved.
|
||||
assert!(html.contains(r#"class="danger""#), "{html}");
|
||||
assert!(html.contains("confirm("), "{html}");
|
||||
assert!(html.contains(r#""action": "move-column-up""#), "{html}");
|
||||
assert!(html.contains(r#"href="/admin?profile=billing&table=invoice">Cancel"#), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_populated_table_offers_append_only_columns() {
|
||||
let mut state = page();
|
||||
|
||||
Reference in New Issue
Block a user