1370 lines
40 KiB
Rust
1370 lines
40 KiB
Rust
#![allow(unused_variables)]
|
|
|
|
use std::fmt::{Display, Formatter};
|
|
use std::str::FromStr;
|
|
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
|
|
use lazy_static::lazy_static;
|
|
use crate::backend::banner::{is_stored_image_name, BannerOptions};
|
|
use crate::backend::color::Colors;
|
|
use crate::backend::layout::LayoutOptions;
|
|
use regex::Regex;
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
use validator::{Validate, ValidationError};
|
|
use crate::error::AppError;
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub enum ApiResponse<T> {
|
|
Data(T),
|
|
Error(String)
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default)]
|
|
pub struct Page {
|
|
page_size: i32,
|
|
page_num: i32
|
|
}
|
|
|
|
impl Page {
|
|
pub fn new(page_size: i32) -> Self {
|
|
Self {
|
|
page_size,
|
|
page_num: 1
|
|
}
|
|
}
|
|
|
|
pub fn with_page(self, page_num: i32) -> Self {
|
|
Self {
|
|
page_num,
|
|
..self
|
|
}
|
|
}
|
|
|
|
pub fn limit(&self) -> i32 {
|
|
if self.page_size == 0 {
|
|
i32::MAX
|
|
} else {
|
|
self.page_size
|
|
}
|
|
}
|
|
|
|
pub fn offset(&self) -> i32 {
|
|
(self.page_num - 1) * self.page_size
|
|
}
|
|
|
|
pub fn page_num(&self) -> i32 {
|
|
self.page_num
|
|
}
|
|
|
|
pub fn first(&mut self) {
|
|
self.page_num = 1;
|
|
}
|
|
|
|
pub fn prev(&mut self) {
|
|
if self.page_num > 1 {
|
|
self.page_num = self.page_num - 1;
|
|
}
|
|
}
|
|
|
|
pub fn next(&mut self) {
|
|
self.page_num = self.page_num + 1;
|
|
}
|
|
|
|
pub fn go_to(&mut self, page: i32) {
|
|
self.page_num = page;
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct PagedResponse<T> {
|
|
page: Page,
|
|
record_count: i64,
|
|
data: Vec<T>
|
|
}
|
|
|
|
impl<T: Clone> PagedResponse<T> {
|
|
pub fn new(data: Vec<T>, page: Page, record_count: i64) -> Self {
|
|
Self {
|
|
page,
|
|
record_count,
|
|
data
|
|
}
|
|
}
|
|
|
|
pub fn is_first(&self) -> bool {
|
|
self.page.page_num() == 1
|
|
}
|
|
|
|
pub fn is_last(&self) -> bool {
|
|
self.page.offset() as i64 + self.page.limit() as i64 >= self.record_count
|
|
}
|
|
|
|
pub fn total_pages(&self) -> i32 {
|
|
(self.record_count as f64 / self.page.limit() as f64).ceil() as i32
|
|
}
|
|
|
|
pub fn data(&self) -> Vec<T> {
|
|
self.data.clone()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct Company {
|
|
id: i32,
|
|
#[validate(length(min = 1,message = "Name cannot be empty"))]
|
|
pub name: String,
|
|
#[validate(length(min = 1,message = "Street cannot be empty"))]
|
|
pub street: String,
|
|
#[validate(length(min = 1,message = "House number cannot be empty"))]
|
|
pub house_number: String,
|
|
pub zip_code: String,
|
|
pub city: String,
|
|
}
|
|
|
|
impl Company {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct User {
|
|
id: i32,
|
|
pub login: String,
|
|
pub password: String,
|
|
pub full_name: Option<String>,
|
|
#[validate(email(message = "Enter valid email address"))]
|
|
pub email: Option<String>,
|
|
pub admin: bool,
|
|
pub get_emails: bool,
|
|
}
|
|
|
|
impl User {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
|
|
pub struct UserProfile {
|
|
#[validate(length(min = 1,message = "Username cannot be empty"))]
|
|
login: String,
|
|
#[validate(must_match(other = "password_ver", message = "Passwords doesn't match"))]
|
|
password: Option<String>,
|
|
password_ver: Option<String>,
|
|
full_name: String,
|
|
#[validate(email(message = "Enter valid email address"))]
|
|
email: String,
|
|
get_emails: Option<String>,
|
|
admin: Option<String>
|
|
}
|
|
|
|
impl UserProfile {
|
|
|
|
pub fn login(&self) -> &str {
|
|
&self.login
|
|
}
|
|
|
|
pub fn full_name(&self) -> &str {
|
|
&self.full_name
|
|
}
|
|
|
|
pub fn email(&self) -> &str {
|
|
&self.email
|
|
}
|
|
|
|
pub fn get_emails(&self) -> bool {
|
|
self.get_emails.is_some()
|
|
}
|
|
pub fn admin(&self) -> bool {
|
|
self.admin.is_some()
|
|
}
|
|
pub fn password(&self) -> &Option<String> {
|
|
&self.password
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
|
|
pub struct PwdChange {
|
|
login: String,
|
|
old_password: String,
|
|
#[validate(length(min = 1, message = "Enter new password"),
|
|
must_match(other = "password_ver", message = "Passwords doesn't match"))]
|
|
password: String,
|
|
password_ver: String
|
|
}
|
|
|
|
impl PwdChange {
|
|
pub fn login(&self) -> &str {
|
|
&self.login
|
|
}
|
|
pub fn old_password(&self) -> &str {
|
|
&self.old_password
|
|
}
|
|
pub fn password(&self) -> &str {
|
|
&self.password
|
|
}
|
|
pub fn password_ver(&self) -> &str {
|
|
&self.password_ver
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct OpeningHour {
|
|
id: i32,
|
|
pub day: i32,
|
|
pub from: NaiveTime,
|
|
pub to: NaiveTime,
|
|
pub discount: Option<i32>
|
|
}
|
|
|
|
impl OpeningHour {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct DayHours(pub Vec<DayHour>);
|
|
|
|
impl DayHours {
|
|
|
|
pub fn try_new(hours: &str) -> Result<Self, AppError> {
|
|
if hours.is_empty() {
|
|
return Ok(Self(Vec::new()))
|
|
}
|
|
|
|
let times = hours.split(",")
|
|
.map(|h| h.trim())
|
|
.map(|h| DayHour::try_from(h))
|
|
.collect::<Vec<_>>();
|
|
|
|
if times.contains(&Err(AppError::HourParseError)) {
|
|
return Err(AppError::HourParseError)
|
|
}
|
|
|
|
Ok(Self(times.into_iter().map(|h| h.unwrap()).collect()))
|
|
}
|
|
|
|
pub fn hours(&self) -> &Vec<DayHour> {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Display for DayHours {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
if self.0.is_empty() {
|
|
return write!(f, "{}", "".to_string())
|
|
}
|
|
|
|
let str = self.0.iter().map(|h| {
|
|
let discount = if let Some(d) = h.discount() {
|
|
format!(" ({})", d).to_string()
|
|
} else { "".to_string() };
|
|
format!("{} - {}{}", h.from().format("%H:%M"), h.to().format("%H:%M"), discount).to_string()
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join(", ");
|
|
write!(f, "{}", str)
|
|
}
|
|
}
|
|
|
|
#[derive(Eq, PartialEq, Serialize, Deserialize, Clone, Debug)]
|
|
pub struct DayHour {
|
|
from: NaiveTime,
|
|
to: NaiveTime,
|
|
discount: Option<i32>
|
|
}
|
|
|
|
impl DayHour {
|
|
pub fn new(from: NaiveTime, to: NaiveTime, discount: Option<i32>) -> Self {
|
|
Self { from, to, discount }
|
|
}
|
|
|
|
pub fn from(&self) -> NaiveTime {
|
|
self.from
|
|
}
|
|
pub fn to(&self) -> NaiveTime {
|
|
self.to
|
|
}
|
|
pub fn discount(&self) -> Option<i32> {
|
|
self.discount
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for DayHour {
|
|
type Error = AppError;
|
|
|
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
value.as_str().try_into()
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for DayHour {
|
|
type Error = AppError;
|
|
|
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
if value.is_empty() {
|
|
return Err(AppError::HourParseError)
|
|
}
|
|
|
|
// Optional discount in parentheses after the interval: "08:00 - 12:00 (10)"
|
|
let (interval, discount) = match value.trim().strip_suffix(")") {
|
|
Some(rest) => {
|
|
let (interval, discount) = rest.rsplit_once("(").ok_or(AppError::HourParseError)?;
|
|
if discount.is_empty() || !discount.chars().all(|c| c.is_ascii_digit()) {
|
|
return Err(AppError::HourParseError)
|
|
}
|
|
(interval, Some(discount.parse::<i32>().map_err(|_| AppError::HourParseError)?))
|
|
}
|
|
None => (value, None)
|
|
};
|
|
|
|
let times = interval.split("-")
|
|
.map(|t| t.trim()).collect::<Vec<_>>();
|
|
|
|
if times.len() != 2 {
|
|
return Err(AppError::HourParseError)
|
|
}
|
|
|
|
let from = NaiveTime::parse_from_str(times.get(0).unwrap(), "%H:%M");
|
|
let to = NaiveTime::parse_from_str(times.get(1).unwrap(), "%H:%M");
|
|
|
|
if from.is_err() || to.is_err() {
|
|
return Err(AppError::HourParseError)
|
|
}
|
|
|
|
Ok(DayHour {
|
|
from: from.unwrap(),
|
|
to: to.unwrap(),
|
|
discount
|
|
})
|
|
}
|
|
}
|
|
|
|
fn hours_error(code: &'static str, message: &'static str) -> ValidationError {
|
|
let mut err = ValidationError::new(code);
|
|
err.message = Some(message.into());
|
|
err
|
|
}
|
|
|
|
fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> {
|
|
// Wrong format is already reported by the regex on the field
|
|
if value.hours().is_empty() || !RE_HOURS.is_match(value.hours()) {
|
|
return Ok(())
|
|
}
|
|
if let Ok(h) = DayHours::try_new(value.hours()) {
|
|
for hr in h.hours() {
|
|
if hr.from() >= hr.to() {
|
|
return Err(hours_error("TO_BEFORE_FROM", "Time 'to' must be after time 'from'"))
|
|
}
|
|
}
|
|
Ok(())
|
|
} else {
|
|
Err(hours_error("HOURS_FORMAT", "Hours must be in HH:MM - HH:MM format"))
|
|
}
|
|
}
|
|
|
|
lazy_static! {
|
|
static ref RE_HOURS: Regex = Regex::new(
|
|
r"^$|^\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?( ?, ?\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?)*$").unwrap();
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Validate)]
|
|
#[validate(schema(function = "validate_hours"))]
|
|
pub struct WeekHours {
|
|
day: Weekday,
|
|
#[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))]
|
|
hours: String
|
|
}
|
|
|
|
impl Default for WeekHours {
|
|
fn default() -> Self {
|
|
Self {
|
|
day: Weekday::Mon,
|
|
hours: String::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl WeekHours {
|
|
pub fn new(day: Weekday, hours: Vec<DayHour>) -> Self {
|
|
Self {
|
|
day,
|
|
hours: DayHours(hours).to_string()
|
|
}
|
|
}
|
|
pub fn day(&self) -> Weekday {
|
|
self.day
|
|
}
|
|
pub fn hours(&self) -> &str {
|
|
&self.hours
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
|
#[cfg_attr(feature = "ssr", sqlx(type_name = "slot_type"))]
|
|
pub enum SlotType {
|
|
Quarter,
|
|
Half,
|
|
#[default]
|
|
Hour,
|
|
Day
|
|
}
|
|
|
|
impl Display for SlotType {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", match self {
|
|
SlotType::Quarter => {"Quarter an hour"}
|
|
SlotType::Half => {"Half an hour"}
|
|
SlotType::Hour => {"Hour"}
|
|
SlotType::Day => {"Day"}
|
|
})
|
|
}
|
|
}
|
|
|
|
fn def_true() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct ResProperty {
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
id: i32,
|
|
#[validate(length(min = 1,message = "Name cannot be empty"))]
|
|
pub name: String,
|
|
pub description: String,
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub price: Decimal,
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub slot: SlotType,
|
|
#[serde(default = "def_true")]
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub allow_multi: bool,
|
|
#[serde(default = "def_true")]
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub active: bool
|
|
}
|
|
|
|
impl ResProperty {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
pub struct PublicFormData {
|
|
pub property: ResProperty,
|
|
pub hours: Vec<DayHour>,
|
|
pub reservations: Vec<Reservation>,
|
|
pub closing_days: Vec<ClosingTime>
|
|
}
|
|
|
|
fn empty_slots() -> Vec<String> {
|
|
vec![]
|
|
}
|
|
|
|
fn validate_date(date: &NaiveDate) -> Result<(), ValidationError> {
|
|
if date < &Local::now().date_naive() {
|
|
Err(ValidationError::new("date_in_past"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Validate)]
|
|
pub struct CrReservation {
|
|
#[validate(custom(function = "validate_date", message = "Date can't be in past"))]
|
|
date: NaiveDate,
|
|
#[validate(length(min = 1,message = "Select at last one time slot"))]
|
|
#[serde(default = "empty_slots")]
|
|
slots: Vec<String>,
|
|
#[validate(length(min = 1,message = "Enter your full name"))]
|
|
full_name: String,
|
|
#[validate(email(message = "Enter valid email address"))]
|
|
email: String,
|
|
#[validate(length(min = 1,message = "Enter your phone number"))]
|
|
phone: String,
|
|
note: String,
|
|
remember: bool
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
|
pub struct TmCheck {
|
|
pub from: NaiveTime,
|
|
pub to: NaiveTime
|
|
}
|
|
|
|
impl FromStr for TmCheck {
|
|
type Err = AppError;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
let times = s.split("-").collect::<Vec<_>>();
|
|
if times.len() != 2 {
|
|
return Err(AppError::HourParseError);
|
|
}
|
|
|
|
Ok(TmCheck{
|
|
from: NaiveTime::from_str(times.get(0).unwrap_or(&""))?,
|
|
to: NaiveTime::from_str(times.get(1).unwrap_or(&""))?
|
|
})
|
|
}
|
|
}
|
|
|
|
impl CrReservation {
|
|
pub fn date(&self) -> NaiveDate {
|
|
self.date
|
|
}
|
|
pub fn slots(&self) -> &Vec<String> {
|
|
&self.slots
|
|
}
|
|
pub fn full_name(&self) -> &str {
|
|
&self.full_name
|
|
}
|
|
pub fn email(&self) -> &str {
|
|
&self.email
|
|
}
|
|
pub fn phone(&self) -> &str {
|
|
&self.phone
|
|
}
|
|
pub fn note(&self) -> &str {
|
|
&self.note
|
|
}
|
|
pub fn remember(&self) -> bool {
|
|
self.remember
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct Customer {
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
id: i32,
|
|
pub full_name: String,
|
|
pub email: String,
|
|
pub phone: String,
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub discount: i32
|
|
}
|
|
|
|
impl Customer {
|
|
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
|
|
pub fn new(id: i32, full_name: String, email: String, phone: String, discount: i32) -> Self {
|
|
Self { id, full_name, email, phone, discount }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
|
#[cfg_attr(feature = "ssr", sqlx(type_name = "reservation_state"))]
|
|
pub enum ReservationState {
|
|
#[default]
|
|
New,
|
|
Approved,
|
|
Canceled,
|
|
}
|
|
|
|
impl Display for ReservationState {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", match self {
|
|
ReservationState::New => {"New"}
|
|
ReservationState::Approved => {"Approved"}
|
|
ReservationState::Canceled => {"Canceled"}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct Reservation {
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
id: i32,
|
|
pub from: NaiveTime,
|
|
pub to: NaiveTime,
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub property: i32,
|
|
#[cfg_attr(feature = "ssr", sqlx(default))]
|
|
pub summary: i32,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct ResWithProperty {
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub reservation: Reservation,
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub property: ResProperty
|
|
}
|
|
|
|
pub struct Reservations(Vec<Reservation>);
|
|
|
|
// Transform slots to reservations
|
|
impl Reservations {
|
|
|
|
pub fn new() -> Self {
|
|
Reservations(vec![])
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
|
|
pub fn add_slot(&mut self, check: &TmCheck, property: i32) {
|
|
let mut added = false;
|
|
self.0.iter_mut().for_each(|r| {
|
|
if r.property == property && r.to == check.from {
|
|
r.to = check.to;
|
|
added = true;
|
|
}
|
|
});
|
|
|
|
if !added {
|
|
self.0.push(Reservation {
|
|
from: check.from,
|
|
to: check.to,
|
|
property,
|
|
id: 0,
|
|
summary: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn reservations(&self) -> &Vec<Reservation> {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Reservation {
|
|
pub fn new(from: NaiveTime, to: NaiveTime, property: i32) -> Self {
|
|
Self { id: 0, from, to, property, summary: 0 }
|
|
}
|
|
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct ReservationSum {
|
|
id: i32,
|
|
pub uuid: Uuid,
|
|
pub date: NaiveDate,
|
|
pub customer: i32,
|
|
pub price: Decimal,
|
|
pub state: ReservationState,
|
|
pub date_create: NaiveDate,
|
|
pub edited_by: Option<i32>,
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
impl ReservationSum {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
pub struct ResSumWithItems {
|
|
pub summary: ReservationSum,
|
|
pub customer: Customer,
|
|
pub reservations: Vec<ResWithProperty>
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct ResAllView {
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub reservation: ResWithProperty,
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub summary: ReservationSum,
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub customer: Customer,
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
|
#[cfg_attr(feature = "ssr", sqlx(type_name = "message_type"))]
|
|
pub enum MessageType {
|
|
#[default]
|
|
NewReservation,
|
|
NewReservationCust,
|
|
ReservationApp,
|
|
ReservationCanceled,
|
|
}
|
|
|
|
impl Display for MessageType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", match self {
|
|
MessageType::NewReservation => {"NewReservation"}
|
|
MessageType::NewReservationCust => {"NewReservationCust"}
|
|
MessageType::ReservationApp => {"ReservationApp"}
|
|
MessageType::ReservationCanceled => {"ReservationCanceled"}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Debug, Default, Validate)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct Message {
|
|
id: i32,
|
|
pub msg_type: MessageType,
|
|
#[validate(length(min = 1,message = "Enter mail subject"))]
|
|
pub subject: String,
|
|
#[validate(length(min = 1,message = "Enter text"))]
|
|
pub text: String,
|
|
}
|
|
|
|
impl Message {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct ChartData {
|
|
pub count: i64,
|
|
pub period: f64
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, Validate)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
pub struct Appearance {
|
|
id: i32,
|
|
pub banner: Option<String>,
|
|
pub text: Option<String>,
|
|
pub title: Option<String>,
|
|
// Forms that edit the title or the text don't send the colours
|
|
#[serde(default)]
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub colors: Colors,
|
|
pub logo: Option<String>,
|
|
#[serde(default)]
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub banner_options: BannerOptions,
|
|
#[serde(default)]
|
|
#[cfg_attr(feature = "ssr", sqlx(flatten))]
|
|
pub layout: LayoutOptions
|
|
}
|
|
|
|
impl Appearance {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
|
|
/// URL of the banner image, if there is one
|
|
pub fn banner_url(&self) -> Option<String> {
|
|
stored_image_url(&self.banner)
|
|
}
|
|
|
|
/// URL of the logo, if there is one
|
|
pub fn logo_url(&self) -> Option<String> {
|
|
stored_image_url(&self.logo)
|
|
}
|
|
|
|
/// The public page has a header when there is anything to show in it
|
|
pub fn has_banner(&self) -> bool {
|
|
self.banner_url().is_some()
|
|
|| self.logo_url().is_some()
|
|
|| !self.title.clone().unwrap_or_default().trim().is_empty()
|
|
}
|
|
|
|
/// CSS variables for the banner of the public page, used as its `style` attribute.
|
|
/// The styles themselves are in `assets/css/public.css`.
|
|
pub fn banner_style(&self) -> String {
|
|
banner_style(self.banner_url(), &self.banner_options)
|
|
}
|
|
}
|
|
|
|
/// Style of a banner with the given image; the preview in the appearance settings uses it
|
|
/// with an image that is not uploaded yet
|
|
pub fn banner_style(image_url: Option<String>, options: &BannerOptions) -> String {
|
|
let options = options.style(image_url.is_some());
|
|
|
|
match image_url {
|
|
Some(url) => format!("--rz-banner-image: url('{}'); {}", url, options),
|
|
None => options
|
|
}
|
|
}
|
|
|
|
/// The value is put into a `style` attribute, so it must not be able to end the `url()` or the declaration
|
|
fn stored_image_url(name: &Option<String>) -> Option<String> {
|
|
name.as_ref()
|
|
.filter(|name| is_stored_image_name(name))
|
|
.map(|name| format!("/data/{}", name))
|
|
}
|
|
|
|
pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> {
|
|
if time.from_date > time.to_date {
|
|
let mut err = ValidationError::new("to date in past");
|
|
err.message = Some("To date must be after from date".into());
|
|
Err(err)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, Validate, Eq, PartialEq)]
|
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
|
#[validate(schema(function = "validate_closing_time"))]
|
|
pub struct ClosingTime {
|
|
id: i32,
|
|
pub from_date: NaiveDate,
|
|
pub to_date: NaiveDate
|
|
}
|
|
impl ClosingTime {
|
|
pub fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::Duration;
|
|
|
|
fn time(h: u32, m: u32) -> NaiveTime {
|
|
NaiveTime::from_hms_opt(h, m, 0).unwrap()
|
|
}
|
|
|
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
|
}
|
|
|
|
fn has_field_error<T: Validate>(entity: &T, field: &str) -> bool {
|
|
match entity.validate() {
|
|
Ok(_) => false,
|
|
Err(e) => e.field_errors().contains_key(field)
|
|
}
|
|
}
|
|
|
|
// Page
|
|
|
|
#[test]
|
|
fn page_starts_on_first_page() {
|
|
let page = Page::new(10);
|
|
|
|
assert_eq!(page.page_num(), 1);
|
|
assert_eq!(page.limit(), 10);
|
|
assert_eq!(page.offset(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn page_offset_follows_page_number() {
|
|
let page = Page::new(10).with_page(3);
|
|
|
|
assert_eq!(page.page_num(), 3);
|
|
assert_eq!(page.offset(), 20);
|
|
}
|
|
|
|
#[test]
|
|
fn page_with_zero_size_is_unlimited() {
|
|
let page = Page::new(0).with_page(5);
|
|
|
|
assert_eq!(page.limit(), i32::MAX);
|
|
assert_eq!(page.offset(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn page_navigation() {
|
|
let mut page = Page::new(10);
|
|
|
|
page.prev();
|
|
assert_eq!(page.page_num(), 1);
|
|
|
|
page.next();
|
|
page.next();
|
|
assert_eq!(page.page_num(), 3);
|
|
|
|
page.prev();
|
|
assert_eq!(page.page_num(), 2);
|
|
|
|
page.go_to(7);
|
|
assert_eq!(page.page_num(), 7);
|
|
|
|
page.first();
|
|
assert_eq!(page.page_num(), 1);
|
|
}
|
|
|
|
// PagedResponse
|
|
|
|
#[test]
|
|
fn paged_response_first_and_last() {
|
|
let first = PagedResponse::new(vec![1, 2], Page::new(2), 5);
|
|
assert!(first.is_first());
|
|
assert!(!first.is_last());
|
|
|
|
let middle = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 5);
|
|
assert!(!middle.is_first());
|
|
assert!(!middle.is_last());
|
|
|
|
let last = PagedResponse::new(vec![5], Page::new(2).with_page(3), 5);
|
|
assert!(!last.is_first());
|
|
assert!(last.is_last());
|
|
}
|
|
|
|
#[test]
|
|
fn paged_response_exactly_full_last_page() {
|
|
let last = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 4);
|
|
|
|
assert!(last.is_last());
|
|
}
|
|
|
|
#[test]
|
|
fn paged_response_total_pages() {
|
|
assert_eq!(PagedResponse::new(Vec::<i32>::new(), Page::new(10), 0).total_pages(), 0);
|
|
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 1).total_pages(), 1);
|
|
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 10).total_pages(), 1);
|
|
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 11).total_pages(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn paged_response_unlimited_is_single_page() {
|
|
let resp = PagedResponse::new(vec![1, 2, 3], Page::new(0), 3);
|
|
|
|
assert!(resp.is_first());
|
|
assert!(resp.is_last());
|
|
assert_eq!(resp.total_pages(), 1);
|
|
assert_eq!(resp.data(), vec![1, 2, 3]);
|
|
}
|
|
|
|
// DayHour, DayHours
|
|
|
|
#[test]
|
|
fn day_hour_parses_with_and_without_spaces() {
|
|
let expected = DayHour::new(time(8, 0), time(12, 30), None);
|
|
|
|
assert_eq!(DayHour::try_from("08:00 - 12:30"), Ok(expected.clone()));
|
|
assert_eq!(DayHour::try_from("08:00-12:30"), Ok(expected.clone()));
|
|
assert_eq!(DayHour::try_from("08:00 - 12:30".to_string()), Ok(expected));
|
|
}
|
|
|
|
#[test]
|
|
fn day_hour_rejects_malformed_input() {
|
|
for input in ["", "08:00", "08:00 - ", "08:00 - 12:00 - 14:00", "aa:bb - cc:dd", "25:00 - 26:00", "08:00 12:00"] {
|
|
assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn day_hour_parses_discount() {
|
|
let expected = DayHour::new(time(8, 0), time(12, 30), Some(10));
|
|
|
|
assert_eq!(DayHour::try_from("08:00 - 12:30 (10)"), Ok(expected.clone()));
|
|
assert_eq!(DayHour::try_from("08:00-12:30(10)"), Ok(expected));
|
|
assert_eq!(DayHour::try_from("08:00 - 12:30 (0)"), Ok(DayHour::new(time(8, 0), time(12, 30), Some(0))));
|
|
}
|
|
|
|
#[test]
|
|
fn day_hour_rejects_malformed_discount() {
|
|
for input in ["08:00 - 12:00 ()", "08:00 - 12:00 (x)", "08:00 - 12:00 (-5)", "08:00 - 12:00 (1.5)", "08:00 - 12:00 (10",
|
|
"08:00 - 12:00 10)", "(10) 08:00 - 12:00", "08:00 (10) - 12:00", "08:00 - 12:00 (10) (20)", "(10)",
|
|
"08:00 - 12:00 (99999999999)"] {
|
|
assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn day_hours_empty_string_is_closed_day() {
|
|
let hours = DayHours::try_new("").unwrap();
|
|
|
|
assert!(hours.hours().is_empty());
|
|
assert_eq!(hours.to_string(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn day_hours_parses_list() {
|
|
let hours = DayHours::try_new("08:00 - 12:00, 13:00 - 17:00").unwrap();
|
|
|
|
assert_eq!(hours.hours(), &vec![
|
|
DayHour::new(time(8, 0), time(12, 0), None),
|
|
DayHour::new(time(13, 0), time(17, 0), None)
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn day_hours_rejects_list_with_invalid_item() {
|
|
assert!(DayHours::try_new("08:00 - 12:00, nonsense").is_err());
|
|
assert!(DayHours::try_new("08:00 - 12:00,").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn day_hours_display() {
|
|
let hours = DayHours(vec![
|
|
DayHour::new(time(8, 0), time(12, 0), None),
|
|
DayHour::new(time(13, 0), time(17, 0), Some(10))
|
|
]);
|
|
|
|
assert_eq!(hours.to_string(), "08:00 - 12:00, 13:00 - 17:00 (10)");
|
|
}
|
|
|
|
#[test]
|
|
fn day_hours_display_round_trip() {
|
|
let hours = DayHours(vec![
|
|
DayHour::new(time(8, 0), time(12, 0), None),
|
|
DayHour::new(time(13, 0), time(17, 0), Some(10)),
|
|
DayHour::new(time(18, 0), time(20, 0), Some(0))
|
|
]);
|
|
|
|
let parsed = DayHours::try_new(&hours.to_string()).unwrap();
|
|
|
|
assert_eq!(parsed.hours(), hours.hours());
|
|
}
|
|
|
|
// WeekHours
|
|
|
|
#[test]
|
|
fn week_hours_new_formats_hours() {
|
|
let hours = WeekHours::new(Weekday::Tue, vec![DayHour::new(time(9, 0), time(18, 0), None)]);
|
|
|
|
assert_eq!(hours.day(), Weekday::Tue);
|
|
assert_eq!(hours.hours(), "09:00 - 18:00");
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_accepts_valid_hours() {
|
|
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00, 13:00 - 17:00",
|
|
"08:00 - 12:00 (10)", "08:00 - 12:00 (10), 13:00 - 17:00", "08:00 - 12:00, 13:00 - 17:00(20)"] {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
assert!(hours.validate().is_ok(), "input: {:?}", input);
|
|
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_rejects_wrong_format() {
|
|
for input in ["8-12", "8:00 - 12:00", "morning", "08.00 - 12.00",
|
|
"08:00 - 12:00, nonsense", "08:00 - 12:00 garbage", "08:00 - 12:00,", ", 08:00 - 12:00"] {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
assert!(has_field_error(&hours, "hours"), "input: {:?}", input);
|
|
assert!(!has_field_error(&hours, "__all__"), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_rejects_hours_that_cannot_be_parsed() {
|
|
// Matches the format, but is not a valid time
|
|
for input in ["25:00 - 26:00", "08:00 - 12:60", "08:00 - 12:00, 13:00 - 24:00"] {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_validation_agrees_with_parser() {
|
|
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00,13:00 - 17:00", "08:00 - 12:00 , 13:00 - 17:00",
|
|
"08:00 - 12:00 (10)", "08:00 - 12:00 (10) , 13:00 - 17:00 (5)", "08:00 - 12:00 (99999999999)", "08:00 - 12:00, nonsense", "08:00 - 12:00,", "25:00 - 26:00", "08:00 - 12:00 garbage"] {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
if hours.validate().is_ok() {
|
|
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_error_messages() {
|
|
let message = |input: &str| {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
let errors = hours.validate().unwrap_err();
|
|
let all = errors.field_errors().values().flat_map(|e| e.iter()).map(|e| e.message.clone().unwrap().to_string()).collect::<Vec<_>>();
|
|
assert_eq!(all.len(), 1, "input: {:?}", input);
|
|
all[0].clone()
|
|
};
|
|
|
|
assert_eq!(message("nonsense"), "Hours must be in HH:MM - HH:MM format");
|
|
assert_eq!(message("25:00 - 26:00"), "Hours must be in HH:MM - HH:MM format");
|
|
assert_eq!(message("12:00 - 08:00"), "Time 'to' must be after time 'from'");
|
|
}
|
|
|
|
#[test]
|
|
fn week_hours_rejects_to_before_from() {
|
|
for input in ["12:00 - 08:00", "08:00 - 08:00", "08:00 - 12:00, 17:00 - 13:00", "12:00 - 08:00 (10)"] {
|
|
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
|
|
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
// TmCheck
|
|
|
|
#[test]
|
|
fn tm_check_parses_slot() {
|
|
let check = TmCheck::from_str("08:00:00-08:30:00").unwrap();
|
|
|
|
assert_eq!(check, TmCheck { from: time(8, 0), to: time(8, 30) });
|
|
}
|
|
|
|
#[test]
|
|
fn tm_check_rejects_malformed_slot() {
|
|
for input in ["", "08:00:00", "08:00:00-", "08:00:00-09:00:00-10:00:00", "a-b"] {
|
|
assert_eq!(TmCheck::from_str(input), Err(AppError::HourParseError), "input: {:?}", input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tm_check_sorts_by_time() {
|
|
let mut checks = vec![
|
|
TmCheck::from_str("10:00:00-11:00:00").unwrap(),
|
|
TmCheck::from_str("08:00:00-09:00:00").unwrap(),
|
|
TmCheck::from_str("09:00:00-10:00:00").unwrap()
|
|
];
|
|
checks.sort();
|
|
|
|
assert_eq!(checks.iter().map(|c| c.from).collect::<Vec<_>>(), vec![time(8, 0), time(9, 0), time(10, 0)]);
|
|
}
|
|
|
|
// Reservations
|
|
|
|
fn slot(from: u32, to: u32) -> TmCheck {
|
|
TmCheck { from: time(from, 0), to: time(to, 0) }
|
|
}
|
|
|
|
#[test]
|
|
fn reservations_starts_empty() {
|
|
let reservations = Reservations::new();
|
|
|
|
assert!(reservations.is_empty());
|
|
assert!(reservations.reservations().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reservations_merges_adjacent_slots() {
|
|
let mut reservations = Reservations::new();
|
|
reservations.add_slot(&slot(8, 9), 1);
|
|
reservations.add_slot(&slot(9, 10), 1);
|
|
reservations.add_slot(&slot(10, 11), 1);
|
|
|
|
assert_eq!(reservations.reservations(), &vec![Reservation::new(time(8, 0), time(11, 0), 1)]);
|
|
}
|
|
|
|
#[test]
|
|
fn reservations_keeps_gap_between_slots() {
|
|
let mut reservations = Reservations::new();
|
|
reservations.add_slot(&slot(8, 9), 1);
|
|
reservations.add_slot(&slot(10, 11), 1);
|
|
|
|
assert_eq!(reservations.reservations(), &vec![
|
|
Reservation::new(time(8, 0), time(9, 0), 1),
|
|
Reservation::new(time(10, 0), time(11, 0), 1)
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn reservations_does_not_merge_across_properties() {
|
|
let mut reservations = Reservations::new();
|
|
reservations.add_slot(&slot(8, 9), 1);
|
|
reservations.add_slot(&slot(9, 10), 2);
|
|
|
|
assert_eq!(reservations.reservations(), &vec![
|
|
Reservation::new(time(8, 0), time(9, 0), 1),
|
|
Reservation::new(time(9, 0), time(10, 0), 2)
|
|
]);
|
|
}
|
|
|
|
// Entity validation
|
|
|
|
#[test]
|
|
fn company_requires_name_street_and_house_number() {
|
|
let company = Company::default();
|
|
|
|
assert!(has_field_error(&company, "name"));
|
|
assert!(has_field_error(&company, "street"));
|
|
assert!(has_field_error(&company, "house_number"));
|
|
assert!(!has_field_error(&company, "zip_code"));
|
|
assert!(!has_field_error(&company, "city"));
|
|
}
|
|
|
|
#[test]
|
|
fn company_valid() {
|
|
let company = Company {
|
|
name: "Sport club".to_string(),
|
|
street: "Main".to_string(),
|
|
house_number: "1".to_string(),
|
|
..Company::default()
|
|
};
|
|
|
|
assert!(company.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn res_property_requires_name() {
|
|
let mut property = ResProperty::default();
|
|
assert!(has_field_error(&property, "name"));
|
|
|
|
property.name = "Court 1".to_string();
|
|
assert!(property.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn user_profile_validation() {
|
|
let valid = UserProfile {
|
|
login: "joe".to_string(),
|
|
password: Some("secret".to_string()),
|
|
password_ver: Some("secret".to_string()),
|
|
full_name: "Joe Doe".to_string(),
|
|
email: "joe@example.com".to_string(),
|
|
get_emails: None,
|
|
admin: None
|
|
};
|
|
assert!(valid.validate().is_ok());
|
|
|
|
assert!(has_field_error(&UserProfile { login: "".to_string(), ..valid.clone() }, "login"));
|
|
assert!(has_field_error(&UserProfile { email: "not an email".to_string(), ..valid.clone() }, "email"));
|
|
assert!(has_field_error(&UserProfile { password_ver: Some("other".to_string()), ..valid.clone() }, "password"));
|
|
}
|
|
|
|
#[test]
|
|
fn user_profile_checkboxes() {
|
|
let unchecked = UserProfile::default();
|
|
assert!(!unchecked.admin());
|
|
assert!(!unchecked.get_emails());
|
|
|
|
let checked = UserProfile { admin: Some("on".to_string()), get_emails: Some("on".to_string()), ..UserProfile::default() };
|
|
assert!(checked.admin());
|
|
assert!(checked.get_emails());
|
|
}
|
|
|
|
#[test]
|
|
fn pwd_change_validation() {
|
|
let valid = PwdChange {
|
|
login: "joe".to_string(),
|
|
old_password: "old".to_string(),
|
|
password: "new".to_string(),
|
|
password_ver: "new".to_string()
|
|
};
|
|
assert!(valid.validate().is_ok());
|
|
|
|
assert!(has_field_error(&PwdChange { password_ver: "different".to_string(), ..valid.clone() }, "password"));
|
|
assert!(has_field_error(&PwdChange { password: "".to_string(), password_ver: "".to_string(), ..valid.clone() }, "password"));
|
|
}
|
|
|
|
#[test]
|
|
fn message_requires_subject_and_text() {
|
|
let mut message = Message::default();
|
|
assert!(has_field_error(&message, "subject"));
|
|
assert!(has_field_error(&message, "text"));
|
|
|
|
message.subject = "Booking".to_string();
|
|
message.text = "Hello".to_string();
|
|
assert!(message.validate().is_ok());
|
|
}
|
|
|
|
fn valid_reservation() -> CrReservation {
|
|
CrReservation {
|
|
date: Local::now().date_naive(),
|
|
slots: vec!["08:00:00-09:00:00|1".to_string()],
|
|
full_name: "Joe Doe".to_string(),
|
|
email: "joe@example.com".to_string(),
|
|
phone: "123456789".to_string(),
|
|
note: "".to_string(),
|
|
remember: false
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cr_reservation_valid_for_today_and_future() {
|
|
assert!(valid_reservation().validate().is_ok());
|
|
|
|
let future = CrReservation { date: Local::now().date_naive() + Duration::days(30), ..valid_reservation() };
|
|
assert!(future.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn cr_reservation_rejects_past_date() {
|
|
let past = CrReservation { date: Local::now().date_naive() - Duration::days(1), ..valid_reservation() };
|
|
|
|
assert!(has_field_error(&past, "date"));
|
|
}
|
|
|
|
#[test]
|
|
fn cr_reservation_requires_slot_and_contact() {
|
|
assert!(has_field_error(&CrReservation { slots: vec![], ..valid_reservation() }, "slots"));
|
|
assert!(has_field_error(&CrReservation { full_name: "".to_string(), ..valid_reservation() }, "full_name"));
|
|
assert!(has_field_error(&CrReservation { email: "joe".to_string(), ..valid_reservation() }, "email"));
|
|
assert!(has_field_error(&CrReservation { phone: "".to_string(), ..valid_reservation() }, "phone"));
|
|
}
|
|
|
|
#[test]
|
|
fn closing_time_validation() {
|
|
let one_day = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2030, 12, 24) };
|
|
assert!(one_day.validate().is_ok());
|
|
|
|
let range = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2031, 1, 1) };
|
|
assert!(range.validate().is_ok());
|
|
|
|
let reversed = ClosingTime { id: 0, from_date: date(2031, 1, 1), to_date: date(2030, 12, 24) };
|
|
assert!(has_field_error(&reversed, "__all__"));
|
|
}
|
|
|
|
// Enums stored in the database must keep these names
|
|
|
|
#[test]
|
|
fn enum_display() {
|
|
assert_eq!(ReservationState::New.to_string(), "New");
|
|
assert_eq!(ReservationState::Approved.to_string(), "Approved");
|
|
assert_eq!(ReservationState::Canceled.to_string(), "Canceled");
|
|
|
|
assert_eq!(MessageType::NewReservation.to_string(), "NewReservation");
|
|
assert_eq!(MessageType::NewReservationCust.to_string(), "NewReservationCust");
|
|
assert_eq!(MessageType::ReservationApp.to_string(), "ReservationApp");
|
|
assert_eq!(MessageType::ReservationCanceled.to_string(), "ReservationCanceled");
|
|
|
|
assert_eq!(SlotType::Quarter.to_string(), "Quarter an hour");
|
|
assert_eq!(SlotType::Half.to_string(), "Half an hour");
|
|
assert_eq!(SlotType::Hour.to_string(), "Hour");
|
|
assert_eq!(SlotType::Day.to_string(), "Day");
|
|
}
|
|
|
|
#[test]
|
|
fn banner_style_sets_image_variable() {
|
|
let appearance = Appearance { banner: Some("my-banner_1.jpg".to_string()), ..Default::default() };
|
|
assert_eq!(appearance.banner_style(),
|
|
format!("--rz-banner-image: url('/data/my-banner_1.jpg'); {}", BannerOptions::default().style(true)));
|
|
assert!(appearance.has_banner());
|
|
}
|
|
|
|
#[test]
|
|
fn banner_style_without_image_has_only_options() {
|
|
assert_eq!(Appearance::default().banner_style(), BannerOptions::default().style(false));
|
|
}
|
|
|
|
#[test]
|
|
fn banner_style_ignores_unsafe_file_names() {
|
|
for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", ".hidden"] {
|
|
let appearance = Appearance { banner: Some(name.to_string()), logo: Some(name.to_string()), ..Default::default() };
|
|
assert_eq!(appearance.banner_style(), BannerOptions::default().style(false), "name: {:?}", name);
|
|
assert_eq!(appearance.banner_url(), None, "name: {:?}", name);
|
|
assert_eq!(appearance.logo_url(), None, "name: {:?}", name);
|
|
assert!(!appearance.has_banner(), "name: {:?}", name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn banner_is_shown_for_title_logo_or_image() {
|
|
assert!(!Appearance::default().has_banner());
|
|
assert!(!Appearance { title: Some(" ".to_string()), ..Default::default() }.has_banner());
|
|
assert!(Appearance { title: Some("Tennis".to_string()), ..Default::default() }.has_banner());
|
|
|
|
let with_logo = Appearance { logo: Some("logo-1a2b3c4d.png".to_string()), ..Default::default() };
|
|
assert!(with_logo.has_banner());
|
|
assert_eq!(with_logo.logo_url(), Some("/data/logo-1a2b3c4d.png".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn enum_defaults() {
|
|
assert_eq!(ReservationState::default(), ReservationState::New);
|
|
assert_eq!(SlotType::default(), SlotType::Hour);
|
|
}
|
|
}
|