299 lines
9.1 KiB
Rust
299 lines
9.1 KiB
Rust
//! Admin CRUD for reusable discount profiles (a named percentage over a product
|
|
//! scope). Profiles are assigned to business accounts on the customer page; here
|
|
//! the admin only defines them.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use axum_extra::extract::cookie::CookieJar;
|
|
use loco_rs::prelude::*;
|
|
use sea_orm::{
|
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait,
|
|
QueryFilter, QueryOrder, Set, TransactionTrait,
|
|
};
|
|
use serde_json::json;
|
|
|
|
use crate::{
|
|
controllers::i18n::current_lang,
|
|
models::{discount_profile_products, discount_profiles, products},
|
|
shared::{
|
|
guard,
|
|
money::{format_bp, parse_percent, percent_to_bp},
|
|
},
|
|
};
|
|
|
|
/// Scalar + repeated fields parsed from the profile form. `product_ids` is a
|
|
/// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect,
|
|
/// so the body is parsed directly.
|
|
struct ProfileInput {
|
|
name: String,
|
|
percent: String,
|
|
scope_type: String,
|
|
product_ids: Vec<i32>,
|
|
}
|
|
|
|
fn parse_profile_form(body: &str) -> ProfileInput {
|
|
let mut name = String::new();
|
|
let mut percent = String::new();
|
|
let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string();
|
|
let mut product_ids = Vec::new();
|
|
for (key, value) in form_urlencoded::parse(body.as_bytes()) {
|
|
match key.as_ref() {
|
|
"name" => name = value.into_owned(),
|
|
"percent" => percent = value.into_owned(),
|
|
"scope_type" => scope_type = value.into_owned(),
|
|
"product_ids" => {
|
|
if let Ok(id) = value.parse::<i32>() {
|
|
product_ids.push(id);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ProfileInput {
|
|
name,
|
|
percent,
|
|
scope_type,
|
|
product_ids,
|
|
}
|
|
}
|
|
|
|
async fn profile_by_id(ctx: &AppContext, id: i32) -> Result<discount_profiles::Model> {
|
|
discount_profiles::Entity::find_by_id(id)
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn index(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let profiles = discount_profiles::Entity::find()
|
|
.order_by_asc(discount_profiles::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
|
|
let mut rows = Vec::with_capacity(profiles.len());
|
|
for profile in &profiles {
|
|
let count = discount_profile_products::Entity::find()
|
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id))
|
|
.count(&ctx.db)
|
|
.await?;
|
|
rows.push(json!({
|
|
"id": profile.id,
|
|
"name": profile.name,
|
|
"percent": format_bp(profile.percent_bp),
|
|
"scope_type": profile.scope_type,
|
|
"product_count": count,
|
|
}));
|
|
}
|
|
|
|
format::view(
|
|
&v,
|
|
"admin/catalog/discount_profiles.html",
|
|
json!({ "profiles": rows, "lang": current_lang(&jar) }),
|
|
)
|
|
}
|
|
|
|
/// Render the create/edit form. `profile` is null on create.
|
|
async fn render_form(
|
|
ctx: &AppContext,
|
|
v: &TeraView,
|
|
jar: &CookieJar,
|
|
profile: Option<&discount_profiles::Model>,
|
|
selected: &HashSet<i32>,
|
|
error: Option<&str>,
|
|
) -> Result<Response> {
|
|
let all_products = products::Entity::find()
|
|
.order_by_asc(products::Column::Name)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let product_rows: Vec<serde_json::Value> = all_products
|
|
.iter()
|
|
.map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) }))
|
|
.collect();
|
|
|
|
let profile_json = match profile {
|
|
Some(p) => json!({
|
|
"id": p.id,
|
|
"name": p.name,
|
|
"percent": format_bp(p.percent_bp),
|
|
"scope_type": p.scope_type,
|
|
}),
|
|
None => serde_json::Value::Null,
|
|
};
|
|
|
|
format::view(
|
|
v,
|
|
"admin/catalog/discount_profile_form.html",
|
|
json!({
|
|
"profile": profile_json,
|
|
"products": product_rows,
|
|
"error": error,
|
|
"lang": current_lang(jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn new(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn edit(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let profile = profile_by_id(&ctx, id).await?;
|
|
let selected = member_ids(&ctx, id).await?;
|
|
render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await
|
|
}
|
|
|
|
async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result<HashSet<i32>> {
|
|
Ok(discount_profile_products::Entity::find()
|
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
|
.all(&ctx.db)
|
|
.await?
|
|
.into_iter()
|
|
.map(|r| r.product_id)
|
|
.collect())
|
|
}
|
|
|
|
/// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key.
|
|
fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> {
|
|
let name = input.name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Err("profile-name-required");
|
|
}
|
|
let pct = parse_percent(&input.percent).ok_or("discount-invalid")?;
|
|
if pct <= 0.0 || pct >= 100.0 {
|
|
return Err("discount-percent-range");
|
|
}
|
|
let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT {
|
|
discount_profiles::SCOPE_ALL_EXCEPT
|
|
} else {
|
|
discount_profiles::SCOPE_INCLUDE
|
|
};
|
|
Ok((name, percent_to_bp(pct), scope.to_string()))
|
|
}
|
|
|
|
/// Replace a profile's product membership with `product_ids`.
|
|
async fn sync_membership(
|
|
ctx: &AppContext,
|
|
profile_id: i32,
|
|
product_ids: &[i32],
|
|
) -> Result<()> {
|
|
let txn = ctx.db.begin().await?;
|
|
discount_profile_products::Entity::delete_many()
|
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
|
.exec(&txn)
|
|
.await?;
|
|
for product_id in product_ids {
|
|
discount_profile_products::ActiveModel {
|
|
discount_profile_id: Set(profile_id),
|
|
product_id: Set(*product_id),
|
|
..Default::default()
|
|
}
|
|
.insert(&txn)
|
|
.await?;
|
|
}
|
|
txn.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn create(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
body: String,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let input = parse_profile_form(&body);
|
|
let (name, percent_bp, scope_type) = match validate(&input) {
|
|
Ok(values) => values,
|
|
Err(key) => {
|
|
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
|
return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await;
|
|
}
|
|
};
|
|
|
|
let profile = discount_profiles::ActiveModel {
|
|
name: Set(name),
|
|
percent_bp: Set(percent_bp),
|
|
scope_type: Set(scope_type),
|
|
..Default::default()
|
|
}
|
|
.insert(&ctx.db)
|
|
.await?;
|
|
sync_membership(&ctx, profile.id, &input.product_ids).await?;
|
|
format::redirect("/admin/catalog/discount-profiles")
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn update(
|
|
auth: auth::JWT,
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
body: String,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
let profile = profile_by_id(&ctx, id).await?;
|
|
let input = parse_profile_form(&body);
|
|
let (name, percent_bp, scope_type) = match validate(&input) {
|
|
Ok(values) => values,
|
|
Err(key) => {
|
|
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
|
return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await;
|
|
}
|
|
};
|
|
|
|
let mut active = profile.into_active_model();
|
|
active.name = Set(name);
|
|
active.percent_bp = Set(percent_bp);
|
|
active.scope_type = Set(scope_type);
|
|
active.update(&ctx.db).await?;
|
|
sync_membership(&ctx, id, &input.product_ids).await?;
|
|
format::redirect("/admin/catalog/discount-profiles")
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn delete(
|
|
auth: auth::JWT,
|
|
Path(id): Path<i32>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
guard::current_admin(auth, &ctx).await?;
|
|
// FK cascades remove membership, assignments and resolutions.
|
|
profile_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
|
format::redirect("/admin/catalog/discount-profiles")
|
|
}
|
|
|
|
pub fn routes() -> Routes {
|
|
Routes::new()
|
|
.add("/admin/catalog/discount-profiles", get(index))
|
|
.add("/admin/catalog/discount-profiles/new", get(new))
|
|
.add("/admin/catalog/discount-profiles", post(create))
|
|
.add("/admin/catalog/discount-profiles/{id}/edit", get(edit))
|
|
.add("/admin/catalog/discount-profiles/{id}", post(update))
|
|
.add("/admin/catalog/discount-profiles/{id}/delete", post(delete))
|
|
}
|