Add the campsite type cost per season

This commit is contained in:
jordi fita mas 2023-10-01 21:14:39 +02:00
parent 680d51e704
commit ef6a8f5aee
27 changed files with 1059 additions and 38 deletions

View File

@ -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(93, '[2023-09-29, 2023-09-30]');
select set_season_range(94, '[2023-10-01, 2023-10-12]'); 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; commit;

View File

@ -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;

57
deploy/parse_price.sql Normal file
View File

@ -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;

View File

@ -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;

34
deploy/to_price.sql Normal file
View File

@ -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;

View File

@ -7,6 +7,7 @@ package types
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -37,7 +38,10 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
case "new": case "new":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
f := newTypeForm() f, err := newTypeForm(r.Context(), company, conn)
if err != nil {
panic(err)
}
f.MustRender(w, r, user, company) f.MustRender(w, r, user, company)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) 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) http.NotFound(w, r)
return 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 err := f.FillFromDatabase(r.Context(), conn, head); err != nil {
if database.ErrorIsNotFound(err) { if database.ErrorIsNotFound(err) {
http.NotFound(w, r) 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) { func addType(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newTypeForm() f, err := newTypeForm(r.Context(), company, conn)
processTypeForm(w, r, user, company, conn, f, func(ctx context.Context) { if err != nil {
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) 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) { 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) { processTypeForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
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) 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 { if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@ -211,7 +237,14 @@ func processTypeForm(w http.ResponseWriter, r *http.Request, user *auth.User, co
f.MustRender(w, r, user, company) f.MustRender(w, r, user, company)
return 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) httplib.Redirect(w, r, "/admin/campsites/types", http.StatusSeeOther)
} }
@ -223,10 +256,17 @@ type typeForm struct {
MaxCampers *form.Input MaxCampers *form.Input
DogsAllowed *form.Checkbox DogsAllowed *form.Checkbox
Description *form.Input Description *form.Input
Prices map[int]*typePriceForm
} }
func newTypeForm() *typeForm { type typePriceForm struct {
return &typeForm{ 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{ Active: &form.Checkbox{
Name: "active", Name: "active",
Checked: true, Checked: true,
@ -251,6 +291,34 @@ func newTypeForm() *typeForm {
Name: "description", 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 { 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 from campsite_type
where slug = $1 where slug = $1
`, slug) `, 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 { 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.DogsAllowed.FillValue(r)
f.Description.FillValue(r) f.Description.FillValue(r)
f.Media.FillValue(r) f.Media.FillValue(r)
for _, p := range f.Prices {
p.PricePerNight.FillValue(r)
p.MinNights.FillValue(r)
}
return nil 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.")) 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 return v.AllOK, nil
} }

View File

@ -48,9 +48,17 @@ func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *da
type publicPage struct { type publicPage struct {
*template.PublicPage *template.PublicPage
Name string Name string
Prices []*typePrice
Description gotemplate.HTML 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) { func newPublicPage(ctx context.Context, user *auth.User, conn *database.Conn, slug string) (*publicPage, error) {
page := &publicPage{ page := &publicPage{
PublicPage: template.NewPublicPage(), PublicPage: template.NewPublicPage(),
@ -67,6 +75,30 @@ func newPublicPage(ctx context.Context, user *auth.User, conn *database.Conn, sl
return nil, err 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 return page, nil
} }

View File

@ -29,6 +29,12 @@ func (tx *Tx) MustExec(ctx context.Context, sql string, args ...interface{}) pgc
return tag 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) { func (tx *Tx) GetInt(ctx context.Context, sql string, args ...interface{}) (int, error) {
var result int var result int
err := tx.QueryRow(ctx, sql, args...).Scan(&result) err := tx.QueryRow(ctx, sql, args...).Scan(&result)

View File

@ -43,11 +43,21 @@ func (v *Validator) CheckMinInteger(input *Input, min int, message string) bool
return v.Check(input, i >= min, message) 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 { func (v *Validator) CheckValidInteger(input *Input, message string) bool {
_, err := strconv.Atoi(input.Val) _, err := strconv.Atoi(input.Val)
return v.Check(input, err == nil, message) 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 { func (v *Validator) CheckValidEmail(input *Input, message string) bool {
_, err := mail.ParseAddress(input.Val) _, err := mail.ParseAddress(input.Val)
return v.Check(input, err == nil, message) return v.Check(input, err == nil, message)

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -74,6 +74,20 @@ msgstr "Descobreix lentorn"
msgid "Come and enjoy!" msgid "Come and enjoy!"
msgstr "Vine a gaudir!" 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 #: web/templates/public/surroundings.gohtml:13
msgctxt "title" msgctxt "title"
msgid "What to Do Outside the Campsite?" msgid "What to Do Outside the Campsite?"
@ -165,7 +179,7 @@ msgstr "Llegenda"
#: web/templates/admin/carousel/form.gohtml:47 #: web/templates/admin/carousel/form.gohtml:47
#: web/templates/admin/campsite/form.gohtml:70 #: 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/season/form.gohtml:64
#: web/templates/admin/services/form.gohtml:69 #: web/templates/admin/services/form.gohtml:69
#: web/templates/admin/media/form.gohtml:35 #: web/templates/admin/media/form.gohtml:35
@ -175,7 +189,7 @@ msgstr "Actualitza"
#: web/templates/admin/carousel/form.gohtml:49 #: web/templates/admin/carousel/form.gohtml:49
#: web/templates/admin/campsite/form.gohtml:72 #: 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/season/form.gohtml:66
#: web/templates/admin/services/form.gohtml:71 #: web/templates/admin/services/form.gohtml:71
msgctxt "action" msgctxt "action"
@ -320,7 +334,17 @@ msgctxt "input"
msgid "Dogs allowed" msgid "Dogs allowed"
msgstr "Es permeten gossos" 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/campsite/type/l10n.gohtml:32
#: web/templates/admin/services/form.gohtml:60 #: web/templates/admin/services/form.gohtml:60
#: web/templates/admin/services/l10n.gohtml:32 #: web/templates/admin/services/l10n.gohtml:32
@ -812,7 +836,7 @@ msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 #: 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 #: pkg/services/l10n.go:73 pkg/services/admin.go:266
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." 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" msgid "Access forbidden"
msgstr "Accés prohibit" msgstr "Accés prohibit"
#: pkg/campsite/types/admin.go:238 #: pkg/campsite/types/admin.go:278
msgctxt "input" msgctxt "input"
msgid "Cover image" msgid "Cover image"
msgstr "Imatge de portada" msgstr "Imatge de portada"
#: pkg/campsite/types/admin.go:239 #: pkg/campsite/types/admin.go:279
msgctxt "action" msgctxt "action"
msgid "Set campsite type cover" msgid "Set campsite type cover"
msgstr "Estableix la portada del tipus dallotjament" msgstr "Estableix la portada del tipus dallotjament"
#: pkg/campsite/types/admin.go:287 #: pkg/campsite/types/admin.go:388
msgid "Name must have at least one letter." msgid "Name must have at least one letter."
msgstr "El nom ha de tenir com a mínim una lletra." 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." msgid "Cover image can not be empty."
msgstr "No podeu deixar la imatge de portada en blanc." 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." msgid "Cover image must be an image media type."
msgstr "La imatge de portada ha de ser un mèdia de tipus imatge." 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." msgid "Maximum number of campers can not be empty."
msgstr "No podeu deixar el número màxim de persones en blanc." 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." msgid "Maximum number of campers must be an integer number."
msgstr "El número màxim de persones ha de ser enter." 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." msgid "Maximum number of campers must be one or greater."
msgstr "El número màxim de persones no pot ser zero." 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 #: pkg/campsite/admin.go:226
msgid "Selected campsite type is not valid." msgid "Selected campsite type is not valid."
msgstr "El tipus dallotjament escollit no és vàlid." msgstr "El tipus dallotjament escollit no és vàlid."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -74,6 +74,20 @@ msgstr "Descubre el entorno"
msgid "Come and enjoy!" msgid "Come and enjoy!"
msgstr "¡Ven a disfrutar!" 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 #: web/templates/public/surroundings.gohtml:13
msgctxt "title" msgctxt "title"
msgid "What to Do Outside the Campsite?" msgid "What to Do Outside the Campsite?"
@ -165,7 +179,7 @@ msgstr "Leyenda"
#: web/templates/admin/carousel/form.gohtml:47 #: web/templates/admin/carousel/form.gohtml:47
#: web/templates/admin/campsite/form.gohtml:70 #: 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/season/form.gohtml:64
#: web/templates/admin/services/form.gohtml:69 #: web/templates/admin/services/form.gohtml:69
#: web/templates/admin/media/form.gohtml:35 #: web/templates/admin/media/form.gohtml:35
@ -175,7 +189,7 @@ msgstr "Actualizar"
#: web/templates/admin/carousel/form.gohtml:49 #: web/templates/admin/carousel/form.gohtml:49
#: web/templates/admin/campsite/form.gohtml:72 #: 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/season/form.gohtml:66
#: web/templates/admin/services/form.gohtml:71 #: web/templates/admin/services/form.gohtml:71
msgctxt "action" msgctxt "action"
@ -320,7 +334,17 @@ msgctxt "input"
msgid "Dogs allowed" msgid "Dogs allowed"
msgstr "Se permiten perros" 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/campsite/type/l10n.gohtml:32
#: web/templates/admin/services/form.gohtml:60 #: web/templates/admin/services/form.gohtml:60
#: web/templates/admin/services/l10n.gohtml:32 #: web/templates/admin/services/l10n.gohtml:32
@ -812,7 +836,7 @@ msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:82 #: 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 #: pkg/services/l10n.go:73 pkg/services/admin.go:266
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." 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" msgid "Access forbidden"
msgstr "Acceso prohibido" msgstr "Acceso prohibido"
#: pkg/campsite/types/admin.go:238 #: pkg/campsite/types/admin.go:278
msgctxt "input" msgctxt "input"
msgid "Cover image" msgid "Cover image"
msgstr "Imagen de portada" msgstr "Imagen de portada"
#: pkg/campsite/types/admin.go:239 #: pkg/campsite/types/admin.go:279
msgctxt "action" msgctxt "action"
msgid "Set campsite type cover" msgid "Set campsite type cover"
msgstr "Establecer la portada del tipo de alojamiento" 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." msgid "Name must have at least one letter."
msgstr "El nombre tiene que tener como mínimo una letra." 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." msgid "Cover image can not be empty."
msgstr "No podéis dejar la imagen de portada en blanco." 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." msgid "Cover image must be an image media type."
msgstr "La imagen de portada tiene que ser un medio de tipo imagen." 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." msgid "Maximum number of campers can not be empty."
msgstr "No podéis dejar el número máximo de personas en blanco." 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." msgid "Maximum number of campers must be an integer number."
msgstr "El número máximo de personas tiene que ser entero." 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." msgid "Maximum number of campers must be one or greater."
msgstr "El número máximo de personas no puede ser cero." 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 #: pkg/campsite/admin.go:226
msgid "Selected campsite type is not valid." msgid "Selected campsite type is not valid."
msgstr "El tipo de alojamiento escogido no es válido." msgstr "El tipo de alojamiento escogido no es válido."

View File

@ -0,0 +1,7 @@
-- Revert camper:campsite_type_cost from pg
begin;
drop table if exists camper.campsite_type_cost;
commit;

7
revert/parse_price.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:parse_price from pg
begin;
drop function if exists camper.parse_price(text, integer);
commit;

View File

@ -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;

7
revert/to_price.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert camper:to_price from pg
begin;
drop function if exists camper.to_price(integer, integer);
commit;

View File

@ -84,3 +84,7 @@ extension_btree_gist [schema_public] 2023-09-26T17:49:30Z jordi fita mas <jordi@
season_calendar [roles schema_camper season extension_btree_gist user_profile] 2023-09-26T18:07:21Z jordi fita mas <jordi@tandem.blog> # Add the relation of date ranges for seasons season_calendar [roles schema_camper season extension_btree_gist user_profile] 2023-09-26T18:07:21Z jordi fita mas <jordi@tandem.blog> # Add the relation of date ranges for seasons
unset_season_range [roles schema_camper season_calendar] 2023-09-26T21:56:38Z jordi fita mas <jordi@tandem.blog> # Add function to unset a date range from the seasons calendar unset_season_range [roles schema_camper season_calendar] 2023-09-26T21:56:38Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to set a seasons date range set_season_range [roles schema_camper season_calendar unset_season_range] 2023-09-26T18:37:29Z jordi fita mas <jordi@tandem.blog> # Add function to set a seasons date range
campsite_type_cost [roles schema_camper campsite_type season user_profile] 2023-10-01T15:45:37Z jordi fita mas <jordi@tandem.blog> # Add relation of costs for campsite types
parse_price [roles schema_camper] 2023-10-01T16:27:50Z jordi fita mas <jordi@tandem.blog> # Add function to format cents to prices
to_price [roles schema_camper] 2023-10-01T16:30:40Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to set the cost of a campsite type for a given season

246
test/campsite_type_cost.sql Normal file
View File

@ -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;

61
test/parse_price.sql Normal file
View File

@ -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;

View File

@ -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;

46
test/to_price.sql Normal file
View File

@ -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;

View File

@ -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;

7
verify/parse_price.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify camper:parse_price on pg
begin;
select has_function_privilege('camper.parse_price(text, integer)', 'execute');
rollback;

View File

@ -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;

7
verify/to_price.sql Normal file
View File

@ -0,0 +1,7 @@
-- Verify camper:to_price on pg
begin;
select has_function_privilege('camper.to_price(integer, integer)', 'execute');
rollback;

View File

@ -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 { .outside_activities {
margin-top: 2rem; margin-top: 2rem;
} }

View File

@ -68,6 +68,32 @@
</label> </label>
{{ template "error-message" . }} {{ template "error-message" . }}
{{- end }} {{- end }}
{{ with .Prices }}
<fieldset>
<legend>{{( pgettext "Prices" "title" )}}</legend>
{{ range . }}
<fieldset>
<legend>{{ .SeasonName }}</legend>
{{ with .PricePerNight -}}
<label>
{{( pgettext "Price per night" "input")}}<br>
<input type="number" name="{{ .Name }}" value="{{ .Val }}" min="0" step="0.01"
required {{ template "error-attrs" . }}><br>
{{ template "error-message" . }}
</label>
{{- end }}
{{ with .MinNights -}}
<label>
{{( pgettext "Minimum number of nights" "input")}}<br>
<input type="number" name="{{ .Name }}" value="{{ .Val }}" min="1"
required {{ template "error-attrs" . }}><br>
{{ template "error-message" . }}
</label>
{{- end }}
</fieldset>
{{- end }}
</fieldset>
{{- end }}
{{ with .Description -}} {{ with .Description -}}
<label> <label>
{{( pgettext "Description" "input")}}<br> {{( pgettext "Description" "input")}}<br>

View File

@ -11,4 +11,24 @@
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.publicPage*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/campsite/types.publicPage*/ -}}
<h2>{{ .Name }}</h2> <h2>{{ .Name }}</h2>
{{ .Description }} {{ .Description }}
{{ with .Prices -}}
<article>
<h3>{{( pgettext "Prices" "title" )}}</h3>
<dl class="price-list">
{{ range . -}}
<dt>
<svg width="20px" height="20px">
<circle cx="50%" cy="50%" r="49%" fill="{{ .SeasonColor }}" stroke="#000" stroke-width=".5"/>
</svg>
{{ .SeasonName }}
</dt>
<dd>{{ printf (gettext "%s €/night") .PricePerNight }}</dd>
{{ if gt .MinNights 1 -}}
<dd>{{ printf (gettext "*Minimum %d nights per stay") .MinNights }}</dd>
{{- end }}
{{- end }}
</dl>
</article>
{{- end }}
{{- end }} {{- end }}