From ef6a8f5aeeaace55ccc26f4430d902b7adcf97d4 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Sun, 1 Oct 2023 21:14:39 +0200 Subject: [PATCH] Add the campsite type cost per season --- demo/demo.sql | 15 ++ deploy/campsite_type_cost.sql | 56 ++++ deploy/parse_price.sql | 57 ++++ deploy/set_campsite_type_cost.sql | 28 ++ deploy/to_price.sql | 34 +++ pkg/campsite/types/admin.go | 137 +++++++++- pkg/campsite/types/public.go | 32 +++ pkg/database/tx.go | 6 + pkg/form/validator.go | 10 + po/ca.po | 74 +++++- po/es.po | 74 +++++- revert/campsite_type_cost.sql | 7 + revert/parse_price.sql | 7 + revert/set_campsite_type_cost.sql | 7 + revert/to_price.sql | 7 + sqitch.plan | 4 + test/campsite_type_cost.sql | 246 ++++++++++++++++++ test/parse_price.sql | 61 +++++ test/set_campsite_type_cost.sql | 86 ++++++ test/to_price.sql | 46 ++++ verify/campsite_type_cost.sql | 18 ++ verify/parse_price.sql | 7 + verify/set_campsite_type_cost.sql | 7 + verify/to_price.sql | 7 + web/static/public.css | 18 ++ web/templates/admin/campsite/type/form.gohtml | 26 ++ web/templates/public/campsite/type.gohtml | 20 ++ 27 files changed, 1059 insertions(+), 38 deletions(-) create mode 100644 deploy/campsite_type_cost.sql create mode 100644 deploy/parse_price.sql create mode 100644 deploy/set_campsite_type_cost.sql create mode 100644 deploy/to_price.sql create mode 100644 revert/campsite_type_cost.sql create mode 100644 revert/parse_price.sql create mode 100644 revert/set_campsite_type_cost.sql create mode 100644 revert/to_price.sql create mode 100644 test/campsite_type_cost.sql create mode 100644 test/parse_price.sql create mode 100644 test/set_campsite_type_cost.sql create mode 100644 test/to_price.sql create mode 100644 verify/campsite_type_cost.sql create mode 100644 verify/parse_price.sql create mode 100644 verify/set_campsite_type_cost.sql create mode 100644 verify/to_price.sql diff --git a/demo/demo.sql b/demo/demo.sql index 777d862..e0c9a39 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -265,5 +265,20 @@ select set_season_range(94, '[2023-09-24, 2023-09-28]'); select set_season_range(93, '[2023-09-29, 2023-09-30]'); select set_season_range(94, '[2023-10-01, 2023-10-12]'); +insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) +values (72, 92, 20000, 2) + , (72, 93, 16500, 2) + , (72, 94, 12500, 2) + , (73, 92, 20000, 2) + , (73, 93, 16500, 2) + , (73, 94, 12500, 2) + , (74, 92, 20000, 2) + , (74, 93, 16500, 2) + , (74, 94, 12500, 2) + , (75, 92, 20000, 2) + , (75, 93, 16500, 2) + , (75, 94, 12500, 2) +; + commit; diff --git a/deploy/campsite_type_cost.sql b/deploy/campsite_type_cost.sql new file mode 100644 index 0000000..fac3317 --- /dev/null +++ b/deploy/campsite_type_cost.sql @@ -0,0 +1,56 @@ +-- Deploy camper:campsite_type_cost to pg +-- requires: roles +-- requires: schema_camper +-- requires: campsite_type +-- requires: season +-- requires: user_profile + +begin; + +set search_path to camper, public; + +create table campsite_type_cost ( + campsite_type_id integer not null references campsite_type, + season_id integer not null references season, + cost_per_night integer not null constraint cost_not_negative check(cost_per_night >= 0), + min_nights integer not null constraint at_least_one_night check(min_nights > 0), + primary key (campsite_type_id, season_id) +); + +grant select on table campsite_type_cost to guest; +grant select on table campsite_type_cost to employee; +grant select, insert, update, delete on table campsite_type_cost to admin; + +alter table campsite_type_cost enable row level security; + +create policy guest_ok +on campsite_type_cost +for select +using (true) +; + +create policy insert_to_company +on campsite_type_cost +for insert +with check ( + exists (select 1 from campsite_type join season using (company_id) join user_profile using (company_id) where campsite_type.campsite_type_id = campsite_type_cost.campsite_type_id and season.season_id = campsite_type_cost.season_id) +) +; + +create policy update_company +on campsite_type_cost +for update +using ( + exists (select 1 from campsite_type join season using (company_id) join user_profile using (company_id) where campsite_type.campsite_type_id = campsite_type_cost.campsite_type_id and season.season_id = campsite_type_cost.season_id) +) +; + +create policy delete_from_company +on campsite_type_cost +for delete +using ( + exists (select 1 from campsite_type join season using (company_id) join user_profile using (company_id) where campsite_type.campsite_type_id = campsite_type_cost.campsite_type_id and season.season_id = campsite_type_cost.season_id) +) +; + +commit; diff --git a/deploy/parse_price.sql b/deploy/parse_price.sql new file mode 100644 index 0000000..f9d5fd6 --- /dev/null +++ b/deploy/parse_price.sql @@ -0,0 +1,57 @@ +-- Deploy camper:parse_price to pg +-- requires: roles +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create or replace function parse_price(price text, decimal_digits integer default 2) returns integer as +$$ +declare + parts text[]; + result int; + frac_part text; + sign int := 1; +begin + if price like '-%' Then + sign := -1; + price := substring(price from 2); + end if; + + parts := string_to_array(price, '.'); + if array_length(parts, 1) > 2 then + raise invalid_parameter_value using message = price || ' is not a valid price representation.'; + end if; + + result := parts[1]::integer; + if result is null then + raise invalid_parameter_value using message = price || ' is not a valid price representation.'; + end if; + for d in 1..decimal_digits loop + result := result * 10; + end loop; + + if array_length(parts, 1) = 2 then + frac_part := rtrim(parts[2], '0'); + if length(frac_part) > decimal_digits then + raise invalid_parameter_value using message = price || ' has too many digits in the fraction part.'; + end if; + frac_part := rpad(frac_part, decimal_digits, '0'); + result := result + frac_part::integer; + end if; + + return sign * result; +end; +$$ +language plpgsql +immutable; + +comment on function parse_price(text, integer) is +'Converts the string representation of a price in decimal form to cents, according to the number of decimal digits passed.'; + +revoke execute on function parse_price(text, integer) from public; +grant execute on function parse_price(text, integer) to employee; +grant execute on function parse_price(text, integer) to admin; + +commit; diff --git a/deploy/set_campsite_type_cost.sql b/deploy/set_campsite_type_cost.sql new file mode 100644 index 0000000..8322f0f --- /dev/null +++ b/deploy/set_campsite_type_cost.sql @@ -0,0 +1,28 @@ +-- Deploy camper:set_campsite_type_cost to pg +-- requires: roles +-- requires: schema_camper +-- requires: campsite_type_cost +-- requires: parse_price + +begin; + +set search_path to camper, public; + +create or replace function set_campsite_type_cost(slug uuid, season_id integer, min_nights integer, cost_per_night text, decimal_places integer default 2) returns void as +$$ + insert into campsite_type_cost (campsite_type_id, season_id, min_nights, cost_per_night) + select campsite_type_id, season_id, min_nights, parse_price(cost_per_night, decimal_places) + from campsite_type + where campsite_type.slug = set_campsite_type_cost.slug + on conflict (campsite_type_id, season_id) do update + set min_nights = excluded.min_nights + , cost_per_night = excluded.cost_per_night + ; +$$ + language sql +; + +revoke execute on function set_campsite_type_cost(uuid, integer, integer, text, integer) from public; +grant execute on function set_campsite_type_cost(uuid, integer, integer, text, integer) to admin; + +commit; diff --git a/deploy/to_price.sql b/deploy/to_price.sql new file mode 100644 index 0000000..6ccde34 --- /dev/null +++ b/deploy/to_price.sql @@ -0,0 +1,34 @@ +-- Deploy camper:to_price to pg +-- requires: roles +-- requires: schema_camper + +begin; + +set search_path to camper, public; + +create or replace function to_price(cents integer, decimal_digits integer default 2) returns text as +$$ +declare + result text; + scale integer := 10^decimal_digits; + sign text := ''; +begin + if cents < 0 then + sign := '-'; + cents = -cents; + end if; + result = cents::text; + return sign || (cents / scale)::text || '.' || to_char(mod(cents, scale), rpad('FM', decimal_digits + 2, '0')); +end; +$$ +language plpgsql +immutable; + +comment on function to_price(integer, integer) is +'Converts the cents to a price representation, without currency and any other separater than decimal.'; + +revoke execute on function to_price(integer, integer) from public; +grant execute on function to_price(integer, integer) to employee; +grant execute on function to_price(integer, integer) to admin; + +commit; diff --git a/pkg/campsite/types/admin.go b/pkg/campsite/types/admin.go index 8864e7b..210c68b 100644 --- a/pkg/campsite/types/admin.go +++ b/pkg/campsite/types/admin.go @@ -7,6 +7,7 @@ package types import ( "context" + "fmt" "net/http" "github.com/jackc/pgx/v4" @@ -37,7 +38,10 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat case "new": switch r.Method { case http.MethodGet: - f := newTypeForm() + f, err := newTypeForm(r.Context(), company, conn) + if err != nil { + panic(err) + } f.MustRender(w, r, user, company) default: httplib.MethodNotAllowed(w, r, http.MethodGet) @@ -56,7 +60,10 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat http.NotFound(w, r) return } - f := newTypeForm() + f, err := newTypeForm(r.Context(), company, conn) + if err != nil { + panic(err) + } if err := f.FillFromDatabase(r.Context(), conn, head); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) @@ -181,19 +188,38 @@ func (page *typeIndex) MustRender(w http.ResponseWriter, r *http.Request, user * } func addType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { - f := newTypeForm() - processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) { - conn.MustExec(ctx, "select add_campsite_type($1, $2, $3, $4, $5, $6)", company.ID, f.Media, f.Name, f.Description, f.MaxCampers, f.DogsAllowed) + f, err := newTypeForm(r.Context(), company, conn) + if err != nil { + panic(err) + } + processTypeForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { + slug, err := tx.GetText(ctx, "select add_campsite_type($1, $2, $3, $4, $5, $6)", company.ID, f.Media, f.Name, f.Description, f.MaxCampers, f.DogsAllowed) + if err != nil { + return err + } + return setPrices(ctx, tx, slug, f.Prices) }) } +func setPrices(ctx context.Context, tx *database.Tx, slug string, prices map[int]*typePriceForm) error { + for seasonID, p := range prices { + if _, err := tx.Exec(ctx, "select set_campsite_type_cost($1, $2, $3, $4)", slug, seasonID, p.MinNights, p.PricePerNight); err != nil { + return err + } + } + return nil +} + func editType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) { - processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) { - conn.MustExec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5, $6, $7)", f.Slug, f.Media, f.Name, f.Description, f.MaxCampers, f.DogsAllowed, f.Active) + processTypeForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error { + if _, err := conn.Exec(ctx, "select edit_campsite_type($1, $2, $3, $4, $5, $6, $7)", f.Slug, f.Media, f.Name, f.Description, f.MaxCampers, f.DogsAllowed, f.Active); err != nil { + return err + } + return setPrices(ctx, tx, f.Slug, f.Prices) }) } -func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm, act func(ctx context.Context)) { +func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm, act func(ctx context.Context, tx *database.Tx) error) { if err := f.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -211,7 +237,14 @@ func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, co f.MustRender(w, r, user, company) return } - act(r.Context()) + + tx := conn.MustBegin(r.Context()) + if err := act(r.Context(), tx); err == nil { + tx.Commit(r.Context()) + } else { + tx.Rollback(r.Context()) + panic(err) + } httplib.Redirect(w, r, "/admin/campsites/types", http.StatusSeeOther) } @@ -223,10 +256,17 @@ type typeForm struct { MaxCampers *form.Input DogsAllowed *form.Checkbox Description *form.Input + Prices map[int]*typePriceForm } -func newTypeForm() *typeForm { - return &typeForm{ +type typePriceForm struct { + SeasonName string + PricePerNight *form.Input + MinNights *form.Input +} + +func newTypeForm(ctx context.Context, company *auth.Company, conn *database.Conn) (*typeForm, error) { + f := &typeForm{ Active: &form.Checkbox{ Name: "active", Checked: true, @@ -251,6 +291,34 @@ func newTypeForm() *typeForm { Name: "description", }, } + + rows, err := conn.Query(ctx, "select season_id, name from season where active and company_id = $1", company.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + f.Prices = make(map[int]*typePriceForm) + for rows.Next() { + var id int + var name string + if err = rows.Scan(&id, &name); err != nil { + return nil, err + } + f.Prices[id] = &typePriceForm{ + SeasonName: name, + PricePerNight: &form.Input{ + Name: fmt.Sprintf("season.%d.price_per_night", id), + Val: "0", + }, + MinNights: &form.Input{ + Name: fmt.Sprintf("season.%d.min_nights", id), + Val: "1", + }, + } + } + + return f, nil } func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error { @@ -265,7 +333,36 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl from campsite_type where slug = $1 `, slug) - return row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.MaxCampers.Val, &f.DogsAllowed.Checked, &f.Active.Checked) + if err := row.Scan(&f.Name.Val, &f.Description.Val, &f.Media.Val, &f.MaxCampers.Val, &f.DogsAllowed.Checked, &f.Active.Checked); err != nil { + return err + } + + rows, err := conn.Query(ctx, ` + select season_id + , min_nights::text + , to_price(cost_per_night) + from campsite_type + join campsite_type_cost using (campsite_type_id) + where slug = $1 + `, slug) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var seasonID int + var minNights string + var pricePerNight string + if err := rows.Scan(&seasonID, &minNights, &pricePerNight); err != nil { + return err + } + if p, ok := f.Prices[seasonID]; ok { + p.MinNights.Val = minNights + p.PricePerNight.Val = pricePerNight + } + } + return rows.Err() } func (f *typeForm) Parse(r *http.Request) error { @@ -278,6 +375,10 @@ func (f *typeForm) Parse(r *http.Request) error { f.DogsAllowed.FillValue(r) f.Description.FillValue(r) f.Media.FillValue(r) + for _, p := range f.Prices { + p.PricePerNight.FillValue(r) + p.MinNights.FillValue(r) + } return nil } @@ -296,6 +397,18 @@ func (f *typeForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Loc v.CheckMinInteger(f.MaxCampers, 1, l.GettextNoop("Maximum number of campers must be one or greater.")) } } + for _, p := range f.Prices { + if v.CheckRequired(p.PricePerNight, l.GettextNoop("Price per night can not be empty.")) { + if v.CheckValidDecimal(p.PricePerNight, l.GettextNoop("Price per night must be a decimal number.")) { + v.CheckMinDecimal(p.PricePerNight, 0.0, l.GettextNoop("Price per night must be zero or greater.")) + } + } + if v.CheckRequired(p.MinNights, l.GettextNoop("Minimum number of nights can not be empty.")) { + if v.CheckValidInteger(p.MinNights, l.GettextNoop("Minimum number of nights must be an integer.")) { + v.CheckMinInteger(p.MinNights, 0, l.GettextNoop("Minimum number of nights must be one or greater.")) + } + } + } return v.AllOK, nil } diff --git a/pkg/campsite/types/public.go b/pkg/campsite/types/public.go index 72cfa13..12ec185 100644 --- a/pkg/campsite/types/public.go +++ b/pkg/campsite/types/public.go @@ -48,9 +48,17 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da type publicPage struct { *template.PublicPage Name string + Prices []*typePrice Description gotemplate.HTML } +type typePrice struct { + SeasonName string + SeasonColor string + MinNights int + PricePerNight string +} + func newPublicPage(ctx context.Context, user *auth.User, conn *database.Conn, slug string) (*publicPage, error) { page := &publicPage{ PublicPage: template.NewPublicPage(), @@ -67,6 +75,30 @@ func newPublicPage(ctx context.Context, user *auth.User, conn *database.Conn, sl return nil, err } + rows, err := conn.Query(ctx, ` + select season.name --coalesce(i18n.name, season.name) as l10n_name + , to_color(season.color) + , coalesce(min_nights, 1) + , to_price(coalesce(cost_per_night, 0))::text + from season + --left join season_i18n as i18n on season.season_id = i18n.season_id and i18n.lang_tag = $1 + left join ( + campsite_type_cost as cost join campsite_type as type on cost.campsite_type_id = type.campsite_type_id and type.slug = $1 + ) as cost on cost.season_id = season.season_id + where season.active + `, slug) + if err != nil { + return nil, err + } + + for rows.Next() { + price := &typePrice{} + if err := rows.Scan(&price.SeasonName, &price.SeasonColor, &price.MinNights, &price.PricePerNight); err != nil { + return nil, err + } + page.Prices = append(page.Prices, price) + } + return page, nil } diff --git a/pkg/database/tx.go b/pkg/database/tx.go index fad6cdc..97ed887 100644 --- a/pkg/database/tx.go +++ b/pkg/database/tx.go @@ -29,6 +29,12 @@ func (tx *Tx) MustExec(ctx context.Context, sql string, args ...interface{}) pgc return tag } +func (tx *Tx) GetText(ctx context.Context, sql string, args ...interface{}) (string, error) { + var result string + err := tx.QueryRow(ctx, sql, args...).Scan(&result) + return result, err +} + func (tx *Tx) GetInt(ctx context.Context, sql string, args ...interface{}) (int, error) { var result int err := tx.QueryRow(ctx, sql, args...).Scan(&result) diff --git a/pkg/form/validator.go b/pkg/form/validator.go index d3131e8..95074d5 100644 --- a/pkg/form/validator.go +++ b/pkg/form/validator.go @@ -43,11 +43,21 @@ func (v *Validator) CheckMinInteger(input *Input, min int, message string) bool return v.Check(input, i >= min, message) } +func (v *Validator) CheckMinDecimal(input *Input, min float64, message string) bool { + f, _ := strconv.ParseFloat(input.Val, 64) + return v.Check(input, f >= min, message) +} + func (v *Validator) CheckValidInteger(input *Input, message string) bool { _, err := strconv.Atoi(input.Val) return v.Check(input, err == nil, message) } +func (v *Validator) CheckValidDecimal(input *Input, message string) bool { + _, err := strconv.ParseFloat(input.Val, 64) + return v.Check(input, err == nil, message) +} + func (v *Validator) CheckValidEmail(input *Input, message string) bool { _, err := mail.ParseAddress(input.Val) return v.Check(input, err == nil, message) diff --git a/po/ca.po b/po/ca.po index 7ebeb5d..eef19bb 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-09-29 20:08+0200\n" +"POT-Creation-Date: 2023-10-01 21:09+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -74,6 +74,20 @@ msgstr "Descobreix l’entorn" msgid "Come and enjoy!" msgstr "Vine a gaudir!" +#: web/templates/public/campsite/type.gohtml:17 +#: web/templates/admin/campsite/type/form.gohtml:73 +msgctxt "title" +msgid "Prices" +msgstr "Preus" + +#: web/templates/public/campsite/type.gohtml:26 +msgid "%s €/night" +msgstr "%s €/nit" + +#: web/templates/public/campsite/type.gohtml:28 +msgid "*Minimum %d nights per stay" +msgstr "*Mínim %d nits per estada" + #: web/templates/public/surroundings.gohtml:13 msgctxt "title" msgid "What to Do Outside the Campsite?" @@ -165,7 +179,7 @@ msgstr "Llegenda" #: web/templates/admin/carousel/form.gohtml:47 #: web/templates/admin/campsite/form.gohtml:70 -#: web/templates/admin/campsite/type/form.gohtml:82 +#: web/templates/admin/campsite/type/form.gohtml:108 #: web/templates/admin/season/form.gohtml:64 #: web/templates/admin/services/form.gohtml:69 #: web/templates/admin/media/form.gohtml:35 @@ -175,7 +189,7 @@ msgstr "Actualitza" #: web/templates/admin/carousel/form.gohtml:49 #: web/templates/admin/campsite/form.gohtml:72 -#: web/templates/admin/campsite/type/form.gohtml:84 +#: web/templates/admin/campsite/type/form.gohtml:110 #: web/templates/admin/season/form.gohtml:66 #: web/templates/admin/services/form.gohtml:71 msgctxt "action" @@ -320,7 +334,17 @@ msgctxt "input" msgid "Dogs allowed" msgstr "Es permeten gossos" -#: web/templates/admin/campsite/type/form.gohtml:73 +#: web/templates/admin/campsite/type/form.gohtml:79 +msgctxt "input" +msgid "Price per night" +msgstr "Preu per nit" + +#: web/templates/admin/campsite/type/form.gohtml:87 +msgctxt "input" +msgid "Minimum number of nights" +msgstr "Número mínim de nits" + +#: web/templates/admin/campsite/type/form.gohtml:99 #: web/templates/admin/campsite/type/l10n.gohtml:32 #: web/templates/admin/services/form.gohtml:60 #: web/templates/admin/services/l10n.gohtml:32 @@ -812,7 +836,7 @@ msgid "Automatic" msgstr "Automàtic" #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 -#: pkg/campsite/types/admin.go:286 pkg/season/admin.go:335 +#: pkg/campsite/types/admin.go:387 pkg/season/admin.go:335 #: pkg/services/l10n.go:73 pkg/services/admin.go:266 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." @@ -833,40 +857,64 @@ msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." msgid "Access forbidden" msgstr "Accés prohibit" -#: pkg/campsite/types/admin.go:238 +#: pkg/campsite/types/admin.go:278 msgctxt "input" msgid "Cover image" msgstr "Imatge de portada" -#: pkg/campsite/types/admin.go:239 +#: pkg/campsite/types/admin.go:279 msgctxt "action" msgid "Set campsite type cover" msgstr "Estableix la portada del tipus d’allotjament" -#: pkg/campsite/types/admin.go:287 +#: pkg/campsite/types/admin.go:388 msgid "Name must have at least one letter." msgstr "El nom ha de tenir com a mínim una lletra." -#: pkg/campsite/types/admin.go:289 +#: pkg/campsite/types/admin.go:390 msgid "Cover image can not be empty." msgstr "No podeu deixar la imatge de portada en blanc." -#: pkg/campsite/types/admin.go:290 +#: pkg/campsite/types/admin.go:391 msgid "Cover image must be an image media type." msgstr "La imatge de portada ha de ser un mèdia de tipus imatge." -#: pkg/campsite/types/admin.go:294 +#: pkg/campsite/types/admin.go:395 msgid "Maximum number of campers can not be empty." msgstr "No podeu deixar el número màxim de persones en blanc." -#: pkg/campsite/types/admin.go:295 +#: pkg/campsite/types/admin.go:396 msgid "Maximum number of campers must be an integer number." msgstr "El número màxim de persones ha de ser enter." -#: pkg/campsite/types/admin.go:296 +#: pkg/campsite/types/admin.go:397 msgid "Maximum number of campers must be one or greater." msgstr "El número màxim de persones no pot ser zero." +#: pkg/campsite/types/admin.go:401 +msgid "Price per night can not be empty." +msgstr "No podeu deixar el preu per nit en blanc." + +#: pkg/campsite/types/admin.go:402 +msgid "Price per night must be a decimal number." +msgstr "El preu per nit ha de ser un número decimal." + +#: pkg/campsite/types/admin.go:403 +msgid "Price per night must be zero or greater." +msgstr "El preu per nit ha de ser com a mínim zero." + +#: pkg/campsite/types/admin.go:406 +msgid "Minimum number of nights can not be empty." +msgstr "No podeu deixar el número mínim de nits en blanc." + +#: pkg/campsite/types/admin.go:407 +msgid "Minimum number of nights must be an integer." +msgstr "El número mínim de nits ha de ser enter." + +#: pkg/campsite/types/admin.go:408 +msgid "Minimum number of nights must be one or greater." +msgstr "El número mínim de nits no pot ser zero." + #: pkg/campsite/admin.go:226 msgid "Selected campsite type is not valid." msgstr "El tipus d’allotjament escollit no és vàlid." diff --git a/po/es.po b/po/es.po index cd60386..712252a 100644 --- a/po/es.po +++ b/po/es.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-09-29 20:08+0200\n" +"POT-Creation-Date: 2023-10-01 21:09+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -74,6 +74,20 @@ msgstr "Descubre el entorno" msgid "Come and enjoy!" msgstr "¡Ven a disfrutar!" +#: web/templates/public/campsite/type.gohtml:17 +#: web/templates/admin/campsite/type/form.gohtml:73 +msgctxt "title" +msgid "Prices" +msgstr "Precios" + +#: web/templates/public/campsite/type.gohtml:26 +msgid "%s €/night" +msgstr "%s €/noche" + +#: web/templates/public/campsite/type.gohtml:28 +msgid "*Minimum %d nights per stay" +msgstr "*Mínimo %d noches por estancia" + #: web/templates/public/surroundings.gohtml:13 msgctxt "title" msgid "What to Do Outside the Campsite?" @@ -165,7 +179,7 @@ msgstr "Leyenda" #: web/templates/admin/carousel/form.gohtml:47 #: web/templates/admin/campsite/form.gohtml:70 -#: web/templates/admin/campsite/type/form.gohtml:82 +#: web/templates/admin/campsite/type/form.gohtml:108 #: web/templates/admin/season/form.gohtml:64 #: web/templates/admin/services/form.gohtml:69 #: web/templates/admin/media/form.gohtml:35 @@ -175,7 +189,7 @@ msgstr "Actualizar" #: web/templates/admin/carousel/form.gohtml:49 #: web/templates/admin/campsite/form.gohtml:72 -#: web/templates/admin/campsite/type/form.gohtml:84 +#: web/templates/admin/campsite/type/form.gohtml:110 #: web/templates/admin/season/form.gohtml:66 #: web/templates/admin/services/form.gohtml:71 msgctxt "action" @@ -320,7 +334,17 @@ msgctxt "input" msgid "Dogs allowed" msgstr "Se permiten perros" -#: web/templates/admin/campsite/type/form.gohtml:73 +#: web/templates/admin/campsite/type/form.gohtml:79 +msgctxt "input" +msgid "Price per night" +msgstr "Precio por noche" + +#: web/templates/admin/campsite/type/form.gohtml:87 +msgctxt "input" +msgid "Minimum number of nights" +msgstr "Número mínimos de noches" + +#: web/templates/admin/campsite/type/form.gohtml:99 #: web/templates/admin/campsite/type/l10n.gohtml:32 #: web/templates/admin/services/form.gohtml:60 #: web/templates/admin/services/l10n.gohtml:32 @@ -812,7 +836,7 @@ msgid "Automatic" msgstr "Automático" #: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 -#: pkg/campsite/types/admin.go:286 pkg/season/admin.go:335 +#: pkg/campsite/types/admin.go:387 pkg/season/admin.go:335 #: pkg/services/l10n.go:73 pkg/services/admin.go:266 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." @@ -833,40 +857,64 @@ msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." msgid "Access forbidden" msgstr "Acceso prohibido" -#: pkg/campsite/types/admin.go:238 +#: pkg/campsite/types/admin.go:278 msgctxt "input" msgid "Cover image" msgstr "Imagen de portada" -#: pkg/campsite/types/admin.go:239 +#: pkg/campsite/types/admin.go:279 msgctxt "action" msgid "Set campsite type cover" msgstr "Establecer la portada del tipo de alojamiento" -#: pkg/campsite/types/admin.go:287 +#: pkg/campsite/types/admin.go:388 msgid "Name must have at least one letter." msgstr "El nombre tiene que tener como mínimo una letra." -#: pkg/campsite/types/admin.go:289 +#: pkg/campsite/types/admin.go:390 msgid "Cover image can not be empty." msgstr "No podéis dejar la imagen de portada en blanco." -#: pkg/campsite/types/admin.go:290 +#: pkg/campsite/types/admin.go:391 msgid "Cover image must be an image media type." msgstr "La imagen de portada tiene que ser un medio de tipo imagen." -#: pkg/campsite/types/admin.go:294 +#: pkg/campsite/types/admin.go:395 msgid "Maximum number of campers can not be empty." msgstr "No podéis dejar el número máximo de personas en blanco." -#: pkg/campsite/types/admin.go:295 +#: pkg/campsite/types/admin.go:396 msgid "Maximum number of campers must be an integer number." msgstr "El número máximo de personas tiene que ser entero." -#: pkg/campsite/types/admin.go:296 +#: pkg/campsite/types/admin.go:397 msgid "Maximum number of campers must be one or greater." msgstr "El número máximo de personas no puede ser cero." +#: pkg/campsite/types/admin.go:401 +msgid "Price per night can not be empty." +msgstr "No podéis dejar el precio por noche en blanco." + +#: pkg/campsite/types/admin.go:402 +msgid "Price per night must be a decimal number." +msgstr "El precio por noche tien que ser un número decimal." + +#: pkg/campsite/types/admin.go:403 +msgid "Price per night must be zero or greater." +msgstr "El precio por noche tiene que ser como mínimo cero." + +#: pkg/campsite/types/admin.go:406 +msgid "Minimum number of nights can not be empty." +msgstr "No podéis dejar el número mínimo de noches en blanco." + +#: pkg/campsite/types/admin.go:407 +msgid "Minimum number of nights must be an integer." +msgstr "El número mínimo de noches tiene que ser entero." + +#: pkg/campsite/types/admin.go:408 +msgid "Minimum number of nights must be one or greater." +msgstr "El número mínimo de noches no puede ser cero." + #: pkg/campsite/admin.go:226 msgid "Selected campsite type is not valid." msgstr "El tipo de alojamiento escogido no es válido." diff --git a/revert/campsite_type_cost.sql b/revert/campsite_type_cost.sql new file mode 100644 index 0000000..a2fd686 --- /dev/null +++ b/revert/campsite_type_cost.sql @@ -0,0 +1,7 @@ +-- Revert camper:campsite_type_cost from pg + +begin; + +drop table if exists camper.campsite_type_cost; + +commit; diff --git a/revert/parse_price.sql b/revert/parse_price.sql new file mode 100644 index 0000000..ae151a0 --- /dev/null +++ b/revert/parse_price.sql @@ -0,0 +1,7 @@ +-- Revert camper:parse_price from pg + +begin; + +drop function if exists camper.parse_price(text, integer); + +commit; diff --git a/revert/set_campsite_type_cost.sql b/revert/set_campsite_type_cost.sql new file mode 100644 index 0000000..a27a107 --- /dev/null +++ b/revert/set_campsite_type_cost.sql @@ -0,0 +1,7 @@ +-- Revert camper:set_campsite_type_cost from pg + +begin; + +drop function if exists camper.set_campsite_type_cost(uuid, integer, integer, text, integer); + +commit; diff --git a/revert/to_price.sql b/revert/to_price.sql new file mode 100644 index 0000000..7a03c97 --- /dev/null +++ b/revert/to_price.sql @@ -0,0 +1,7 @@ +-- Revert camper:to_price from pg + +begin; + +drop function if exists camper.to_price(integer, integer); + +commit; diff --git a/sqitch.plan b/sqitch.plan index 2a5e8ed..5d5b6c4 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -84,3 +84,7 @@ extension_btree_gist [schema_public] 2023-09-26T17:49:30Z jordi fita mas # Add the relation of date ranges for seasons unset_season_range [roles schema_camper season_calendar] 2023-09-26T21:56:38Z jordi fita mas # Add function to unset a date range from the seasons’ calendar set_season_range [roles schema_camper season_calendar unset_season_range] 2023-09-26T18:37:29Z jordi fita mas # Add function to set a season’s date range +campsite_type_cost [roles schema_camper campsite_type season user_profile] 2023-10-01T15:45:37Z jordi fita mas # Add relation of costs for campsite types +parse_price [roles schema_camper] 2023-10-01T16:27:50Z jordi fita mas # Add function to format cents to prices +to_price [roles schema_camper] 2023-10-01T16:30:40Z jordi fita mas # Add function to format cents to prices +set_campsite_type_cost [roles schema_camper campsite_type_cost parse_price] 2023-10-01T17:51:23Z jordi fita mas # Add function to set the cost of a campsite type for a given season diff --git a/test/campsite_type_cost.sql b/test/campsite_type_cost.sql new file mode 100644 index 0000000..444e47e --- /dev/null +++ b/test/campsite_type_cost.sql @@ -0,0 +1,246 @@ +-- Test campsite_type_cost +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(47); + +set search_path to camper, public; + +select has_table('campsite_type_cost'); +select has_pk('campsite_type_cost'); +select col_is_pk('campsite_type_cost', array['campsite_type_id', 'season_id']); +select table_privs_are('campsite_type_cost', 'guest', array['SELECT']); +select table_privs_are('campsite_type_cost', 'employee', array['SELECT']); +select table_privs_are('campsite_type_cost', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('campsite_type_cost', 'authenticator', array[]::text[]); + +select has_column('campsite_type_cost', 'campsite_type_id'); +select col_is_fk('campsite_type_cost', 'campsite_type_id'); +select fk_ok('campsite_type_cost', 'campsite_type_id', 'campsite_type', 'campsite_type_id'); +select col_type_is('campsite_type_cost', 'campsite_type_id', 'integer'); +select col_not_null('campsite_type_cost', 'campsite_type_id'); +select col_hasnt_default('campsite_type_cost', 'campsite_type_id'); + +select has_column('campsite_type_cost', 'season_id'); +select col_is_fk('campsite_type_cost', 'season_id'); +select fk_ok('campsite_type_cost', 'season_id', 'season', 'season_id'); +select col_type_is('campsite_type_cost', 'season_id', 'integer'); +select col_not_null('campsite_type_cost', 'season_id'); +select col_hasnt_default('campsite_type_cost', 'season_id'); + +select has_column('campsite_type_cost', 'cost_per_night'); +select col_type_is('campsite_type_cost', 'cost_per_night', 'integer'); +select col_not_null('campsite_type_cost', 'cost_per_night'); +select col_hasnt_default('campsite_type_cost', 'cost_per_night'); + +select has_column('campsite_type_cost', 'min_nights'); +select col_type_is('campsite_type_cost', 'min_nights', 'integer'); +select col_not_null('campsite_type_cost', 'min_nights'); +select col_hasnt_default('campsite_type_cost', 'min_nights'); + + +set client_min_messages to warning; +truncate campsite_type_cost cascade; +truncate season cascade; +truncate campsite_type cascade; +truncate media cascade; +truncate media_content cascade; +truncate company_host cascade; +truncate company_user cascade; +truncate company cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca') + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 'ca') +; + +insert into company_user (company_id, user_id, role) +values (2, 1, 'admin') + , (4, 5, 'admin') +; + +insert into company_host (company_id, host) +values (2, 'co2') + , (4, 'co4') +; + +insert into media_content (media_type, bytes) +values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into media (media_id, company_id, original_filename, content_hash) +values (6, 2, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) + , (8, 4, 'cover4.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) +; + +insert into campsite_type (campsite_type_id, company_id, name, media_id, dogs_allowed, max_campers) +values (16, 2, 'Wooden lodge', 6, false, 7) + , (18, 4, 'Bungalow', 8, false, 6) +; + +insert into season (season_id, company_id, name) +values (26, 2, 'Low') + , (27, 2, 'Mid') + , (28, 4, 'Low') + , (29, 4, 'Mid') +; + +insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) +values (16, 26, 2, 2) + , (18, 28, 4, 4) +; + +prepare campsite_season_data as +select campsite_type_id, season_id, cost_per_night +from campsite_type_cost +; + +set role guest; +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (18, 28, 4) + $$, + 'Everyone should be able to list all campsite type costs across all companies' +); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); + +select lives_ok( + $$ insert into campsite_type_cost(campsite_type_id, season_id, cost_per_night, min_nights) values (16, 27, 3, 3) $$, + 'Admin from company 2 should be able to insert a new campsite type season to that company.' +); + +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (16, 27, 3) + , (18, 28, 4) + $$, + 'The new row should have been added' +); + +select lives_ok( + $$ update campsite_type_cost set cost_per_night = 6 where campsite_type_id = 16 and season_id = 27 $$, + 'Admin from company 2 should be able to update campsite type season of that company.' +); + +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (16, 27, 6) + , (18, 28, 4) + $$, + 'The row should have been updated.' +); + +select lives_ok( + $$ delete from campsite_type_cost where campsite_type_id = 16 and season_id = 27 $$, + 'Admin from company 2 should be able to delete campsite type season from that company.' +); + +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (18, 28, 4) + $$, + 'The row should have been deleted.' +); + +select throws_ok( + $$ insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) values (18, 29, 5, 5) $$, + '42501', 'new row violates row-level security policy for table "campsite_type_cost"', + 'Admin from company 2 should NOT be able to insert new campsite type costs to company 4.' +); + +select throws_ok( + $$ insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) values (18, 27, 5, 5) $$, + '42501', 'new row violates row-level security policy for table "campsite_type_cost"', + 'Admin from company 2 should NOT be able to insert new row with a campsite type from company 4.' +); + +select throws_ok( + $$ insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) values (16, 29, 5, 5) $$, + '42501', 'new row violates row-level security policy for table "campsite_type_cost"', + 'Admin from company 2 should NOT be able to insert new row with a season from company 4.' +); + +select lives_ok( + $$ update campsite_type_cost set cost_per_night = 1 where campsite_type_id = 18 $$, + 'Admin from company 2 should not be able to update campsite types of company 4, but no error if campsite_type_id is not changed.' +); + +select lives_ok( + $$ update campsite_type_cost set cost_per_night = 1 where season_id = 28 $$, + 'Admin from company 2 should not be able to update seasons of company 4, but no error if season_id is not changed.' +); + +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (18, 28, 4) + $$, + 'No row should have been changed.' +); + +select throws_ok( + $$ update campsite_type_cost set campsite_type_id = 18 where campsite_type_id = 16 $$, + '42501', 'new row violates row-level security policy for table "campsite_type_cost"', + 'Admin from company 2 should NOT be able to move campsite type to one of company 4' +); + +select throws_ok( + $$ update campsite_type_cost set season_id = 29 where season_id = 26 $$, + '42501', 'new row violates row-level security policy for table "campsite_type_cost"', + 'Admin from company 2 should NOT be able to move season to one of company 4' +); + +select lives_ok( + $$ delete from campsite_type_cost where campsite_type_id = 18 $$, + 'Admin from company 2 should NOT be able to delete campsite type from company 4, but not error is thrown' +); + +select lives_ok( + $$ delete from campsite_type_cost where season_id = 28 $$, + 'Admin from company 2 should NOT be able to delete season from company 4, but not error is thrown' +); + +select bag_eq( + 'campsite_season_data', + $$ values (16, 26, 2) + , (18, 28, 4) + $$, + 'No row should have been changed' +); + +select throws_ok( + $$ insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) values (16, 27, -1, 1) $$, + '23514', 'new row for relation "campsite_type_cost" violates check constraint "cost_not_negative"', + 'Should not be able to insert campsite type costs with negative cost per night.' +); + +select throws_ok( + $$ insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) values (16, 27, 1, 0) $$, + '23514', 'new row for relation "campsite_type_cost" violates check constraint "at_least_one_night"', + 'Should not be able to insert campsite type costs with at a night stay.' +); + +reset role; + + +select * +from finish(); + +rollback; + diff --git a/test/parse_price.sql b/test/parse_price.sql new file mode 100644 index 0000000..ed5090c --- /dev/null +++ b/test/parse_price.sql @@ -0,0 +1,61 @@ +-- Test parse_price +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(44); + +set search_path to auth, camper, public; + +select has_function('camper', 'parse_price', array ['text', 'integer']); +select function_lang_is('camper', 'parse_price', array ['text', 'integer'], 'plpgsql'); +select function_returns('camper', 'parse_price', array ['text', 'integer'], 'integer'); +select isnt_definer('camper', 'parse_price', array ['text', 'integer']); +select volatility_is('camper', 'parse_price', array ['text', 'integer'], 'immutable'); +select function_privs_are('camper', 'parse_price', array ['text', 'integer'], 'guest', array []::text[]); +select function_privs_are('camper', 'parse_price', array ['text', 'integer'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'parse_price', array ['text', 'integer'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'parse_price', array ['text', 'integer'], 'authenticator', array []::text[]); + +select is( parse_price('1.1', 2), 110 ); +select is( parse_price('1.1', 3), 1100 ); +select is( parse_price('0', 2), 0 ); +select is( parse_price('0', 3), 0 ); +select is( parse_price('-0', 2), 0 ); +select is( parse_price('-0', 3), 0 ); +select is( parse_price('0.01', 2), 1 ); +select is( parse_price('0.001', 3), 1 ); +select is( parse_price('-0.01', 2), -1 ); +select is( parse_price('-0.001', 3), -1 ); +select is( parse_price('0.1', 2), 10 ); +select is( parse_price('0.01', 3), 10 ); +select is( parse_price('1', 2), 100 ); +select is( parse_price('0.1', 3), 100 ); +select is( parse_price('10', 2), 1000 ); +select is( parse_price('1', 3), 1000 ); +select is( parse_price('23.23', 2), 2323 ); +select is( parse_price('23.23', 3), 23230 ); +select is( parse_price('-23.23', 2), -2323 ); +select is( parse_price('-23.23', 3), -23230 ); +select throws_ok( $$ select parse_price('234.234', 2) $$ ); +select is( parse_price('234.234', 3), 234234 ); +select throws_ok( $$ select parse_price('2345.2345', 2) $$ ); +select throws_ok( $$ select parse_price('2345.2345', 3) $$ ); +select is( parse_price('00000000000000001.100000000000000000000', 2), 110 ); +select is( parse_price('00000000000000001.100000000000000000000', 3), 1100 ); +select is( parse_price('00000000000000000.100000000000000000000', 2), 10 ); +select is( parse_price('00000000000000000.100000000000000000000', 3), 100 ); +select is( parse_price('00000000000123456.780000000000000000000', 2), 12345678 ); +select is( parse_price('00000000000123456.789000000000000000000', 3), 123456789 ); +select throws_ok( $$ select parse_price('1,1', 2) $$ ); +select throws_ok( $$ select parse_price('1.1.1', 2) $$ ); +select throws_ok( $$ select parse_price('a.b', 2) $$ ); +select throws_ok( $$ select parse_price('', 1) $$); +select throws_ok( $$ select parse_price(' ', 3) $$); + +select * +from finish(); + +rollback; diff --git a/test/set_campsite_type_cost.sql b/test/set_campsite_type_cost.sql new file mode 100644 index 0000000..d02065d --- /dev/null +++ b/test/set_campsite_type_cost.sql @@ -0,0 +1,86 @@ +-- Test set_campsite_type_cost +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(13); + +set search_path to camper, public; + +select has_function('camper', 'set_campsite_type_cost', array['uuid', 'integer', 'integer', 'text', 'integer']); +select function_lang_is('camper', 'set_campsite_type_cost', array['uuid', 'integer', 'integer', 'text', 'integer'], 'sql'); +select function_returns('camper', 'set_campsite_type_cost', array['uuid', 'integer', 'integer', 'text', 'integer'], 'void'); +select isnt_definer('camper', 'set_campsite_type_cost', array['uuid', 'integer', 'integer', 'text', 'integer']); +select volatility_is('camper', 'set_campsite_type_cost', array['uuid', 'integer', 'integer', 'text', 'integer'], 'volatile'); +select function_privs_are('camper', 'set_campsite_type_cost', array ['uuid', 'integer', 'integer', 'text', 'integer'], 'guest', array[]::text[]); +select function_privs_are('camper', 'set_campsite_type_cost', array ['uuid', 'integer', 'integer', 'text', 'integer'], 'employee', array[]::text[]); +select function_privs_are('camper', 'set_campsite_type_cost', array ['uuid', 'integer', 'integer', 'text', 'integer'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'set_campsite_type_cost', array ['uuid', 'integer', 'integer', 'text', 'integer'], 'authenticator', array[]::text[]); + +set client_min_messages to warning; +truncate campsite_type_cost cascade; +truncate season cascade; +truncate campsite_type cascade; +truncate media cascade; +truncate media_content cascade; +truncate company cascade; +reset client_min_messages; + + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_lang_tag) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 'ca') +; + +insert into media_content (media_type, bytes) +values ('image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into media (media_id, company_id, original_filename, content_hash) +values (2, 1, 'cover2.xpm', sha256('static char *s[]={"1 1 1 1","a c #ffffff","a"};')) +; + +insert into campsite_type (campsite_type_id, company_id, slug, media_id, name, description, max_campers, dogs_allowed, active) +values (3, 1, '87452b88-b48f-48d3-bb6c-0296de64164e', 2, 'Type A', '', 5, false, true) +; + +insert into season (season_id, company_id, name) +values (4, 1, 'High') + , (5, 1, 'Mid') + , (6, 1, 'Low') +; + +insert into campsite_type_cost (campsite_type_id, season_id, cost_per_night, min_nights) +values (3, 4, 44, 4) + , (3, 5, 55, 5) +; + +select lives_ok( + $$ select set_campsite_type_cost('87452b88-b48f-48d3-bb6c-0296de64164e', 4, 2, '12.34') $$, + 'Should be able to edit the cost for high season' +); + +select lives_ok( + $$ select set_campsite_type_cost('87452b88-b48f-48d3-bb6c-0296de64164e', 5, 6, '0.0') $$, + 'Should be able to set the cost for mid season to zero' +); + +select lives_ok( + $$ select set_campsite_type_cost('87452b88-b48f-48d3-bb6c-0296de64164e', 6, 1, '3.21') $$, + 'Should be able to set the cost for low season, adding it.' +); + +select bag_eq( + $$ select campsite_type_id, season_id, cost_per_night, min_nights from campsite_type_cost $$, + $$ values (3, 4, 1234, 2) + , (3, 5, 0, 6) + , (3, 6, 321, 1) + $$, + 'Should have updated all campsite type costs.' +); + +select * +from finish(); + +rollback; diff --git a/test/to_price.sql b/test/to_price.sql new file mode 100644 index 0000000..1d1d701 --- /dev/null +++ b/test/to_price.sql @@ -0,0 +1,46 @@ +-- Test to_price +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(29); + +set search_path to camper, public; + +select has_function('camper', 'to_price', array ['integer', 'integer']); +select function_lang_is('camper', 'to_price', array ['integer', 'integer'], 'plpgsql'); +select function_returns('camper', 'to_price', array ['integer', 'integer'], 'text'); +select isnt_definer('camper', 'to_price', array ['integer', 'integer']); +select volatility_is('camper', 'to_price', array ['integer', 'integer'], 'immutable'); +select function_privs_are('camper', 'to_price', array ['integer', 'integer'], 'guest', array []::text[]); +select function_privs_are('camper', 'to_price', array ['integer', 'integer'], 'employee', array ['EXECUTE']); +select function_privs_are('camper', 'to_price', array ['integer', 'integer'], 'admin', array ['EXECUTE']); +select function_privs_are('camper', 'to_price', array ['integer', 'integer'], 'authenticator', array []::text[]); + +select is( to_price(0, 2), '0.00' ); +select is( to_price(0, 3), '0.000' ); +select is( to_price(1, 2), '0.01' ); +select is( to_price(1, 3), '0.001' ); +select is( to_price(-1, 2), '-0.01' ); +select is( to_price(-1, 3), '-0.001' ); +select is( to_price(10, 2), '0.10' ); +select is( to_price(10, 3), '0.010' ); +select is( to_price(100, 2), '1.00' ); +select is( to_price(100, 3), '0.100' ); +select is( to_price(110, 2), '1.10' ); +select is( to_price(1100, 3), '1.100' ); +select is( to_price(12345678, 2), '123456.78' ); +select is( to_price(12345678, 3), '12345.678' ); +select is( to_price(12345678, 4), '1234.5678' ); +select is( to_price(12345678, 5), '123.45678' ); +select is( to_price(-12345678, 2), '-123456.78' ); +select is( to_price(-12345678, 3), '-12345.678' ); +select is( to_price(-12345678, 4), '-1234.5678' ); +select is( to_price(-12345678, 5), '-123.45678' ); + +select * +from finish(); + +rollback; diff --git a/verify/campsite_type_cost.sql b/verify/campsite_type_cost.sql new file mode 100644 index 0000000..e0820ad --- /dev/null +++ b/verify/campsite_type_cost.sql @@ -0,0 +1,18 @@ +-- Verify camper:campsite_type_cost on pg + +begin; + +select campsite_type_id + , season_id + , cost_per_night + , min_nights +from camper.campsite_type_cost +where false; + +select 1 / count(*) from pg_class where oid = 'camper.campsite_type_cost'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'guest_ok' and polrelid = 'camper.campsite_type_cost'::regclass; +select 1 / count(*) from pg_policy where polname = 'insert_to_company' and polrelid = 'camper.campsite_type_cost'::regclass; +select 1 / count(*) from pg_policy where polname = 'update_company' and polrelid = 'camper.campsite_type_cost'::regclass; +select 1 / count(*) from pg_policy where polname = 'delete_from_company' and polrelid = 'camper.campsite_type_cost'::regclass; + +rollback; diff --git a/verify/parse_price.sql b/verify/parse_price.sql new file mode 100644 index 0000000..8a964dc --- /dev/null +++ b/verify/parse_price.sql @@ -0,0 +1,7 @@ +-- Verify camper:parse_price on pg + +begin; + +select has_function_privilege('camper.parse_price(text, integer)', 'execute'); + +rollback; diff --git a/verify/set_campsite_type_cost.sql b/verify/set_campsite_type_cost.sql new file mode 100644 index 0000000..df882b3 --- /dev/null +++ b/verify/set_campsite_type_cost.sql @@ -0,0 +1,7 @@ +-- Verify camper:set_campsite_type_cost on pg + +begin; + +select has_function_privilege('camper.set_campsite_type_cost(uuid, integer, integer, text, integer)', 'execute'); + +rollback; diff --git a/verify/to_price.sql b/verify/to_price.sql new file mode 100644 index 0000000..ccb86e4 --- /dev/null +++ b/verify/to_price.sql @@ -0,0 +1,7 @@ +-- Verify camper:to_price on pg + +begin; + +select has_function_privilege('camper.to_price(integer, integer)', 'execute'); + +rollback; diff --git a/web/static/public.css b/web/static/public.css index c0eaad1..ef94e64 100644 --- a/web/static/public.css +++ b/web/static/public.css @@ -558,6 +558,24 @@ dt { } } +.price-list { + flex-direction: column; + gap: 0; +} + +.price-list dt { + display: flex; + align-items: center; + gap: .5rem; + border: none; + padding: 0; + margin-bottom: 0; +} + +.price-list dd + dt { + margin-top: 2rem; +} + .outside_activities { margin-top: 2rem; } diff --git a/web/templates/admin/campsite/type/form.gohtml b/web/templates/admin/campsite/type/form.gohtml index e6ee908..8cdf500 100644 --- a/web/templates/admin/campsite/type/form.gohtml +++ b/web/templates/admin/campsite/type/form.gohtml @@ -68,6 +68,32 @@ {{ template "error-message" . }} {{- end }} + {{ with .Prices }} +
+ {{( pgettext "Prices" "title" )}} + {{ range . }} +
+ {{ .SeasonName }} + {{ with .PricePerNight -}} + + {{- end }} + {{ with .MinNights -}} + + {{- end }} +
+ {{- end }} +
+ {{- end }} {{ with .Description -}}