Allow updating bookings

I need to retrieve the values from the database and put them in the
form, like all other forms, but in this case the processing is done as
if it were a new form, because everything comes from the query string
and there is no need to do any extra work then.

Had to move the <footer> from the fields.gohtml to form.gohtml because
then it could not know that it was editing an existing booking.  Had to
move the <fieldset> out too, in order to give it an ID and make it
htmx’s target, or it would replace the form, causing even more problems
—the button would disappear then—.  The target **must** be in <form>
because it is needed for tis children’s hx-get and for its own hx-put.
This commit is contained in:
jordi fita mas 2024-04-25 20:27:08 +02:00
parent 30e87c309e
commit c9e8165f83
11 changed files with 849 additions and 324 deletions

View File

@ -0,0 +1,92 @@
-- Deploy camper:edit_booking_from_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_option
-- requires: payment
-- requires: payment__acsi_card
-- requires: payment_option
begin;
set search_path to camper, public;
create or replace function edit_booking_from_payment(booking_slug uuid, payment_slug uuid) returns integer as
$$
declare
bid integer;
begin
with p as (
select company_id
, campsite_type_id
, daterange(arrival_date, departure_date) as stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
from payment
where payment.slug = payment_slug
)
update booking
set company_id = p.company_id
, campsite_type_id = p.campsite_type_id
, stay = p.stay
, subtotal_nights = p.subtotal_nights
, number_adults = p.number_adults
, subtotal_adults = p.subtotal_adults
, number_teenagers = p.number_teenagers
, subtotal_teenagers = p.subtotal_teenagers
, number_children = p.number_children
, subtotal_children = p.subtotal_children
, number_dogs = p.number_dogs
, subtotal_dogs = p.subtotal_dogs
, subtotal_tourist_tax = p.subtotal_tourist_tax
, total = p.total
, currency_code = p.currency_code
, zone_preferences = p.zone_preferences
, acsi_card = p.acsi_card
from p
where slug = booking_slug
returning booking_id into bid;
delete from booking_option
where booking_id = bid;
insert into booking_option
( booking_id
, campsite_type_option_id
, units
, subtotal
)
select bid
, campsite_type_option_id
, units
, subtotal
from payment_option
join payment using (payment_id)
where payment.slug = payment_slug
;
return bid;
end;
$$
language plpgsql
;
revoke execute on function edit_booking_from_payment(uuid, uuid) from public;
grant execute on function edit_booking_from_payment(uuid, uuid) to employee;
grant execute on function edit_booking_from_payment(uuid, uuid) to admin;
commit;

View File

@ -21,6 +21,7 @@ import (
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
) )
type AdminHandler struct { type AdminHandler struct {
@ -48,13 +49,69 @@ 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:
serveAdminBookingForm(w, r, user, company, conn, 0, "/admin/bookings/new")
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
if !uuid.Valid(head) {
http.NotFound(w, r)
return
}
h.bookingHandler(user, company, conn, head).ServeHTTP(w, r)
}
})
}
func serveAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, id int, url string) {
f, err := newAdminBookingForm(r, conn, company, user.Locale) f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil { if err != nil {
panic(err) panic(err)
} }
f.ID = id
f.URL = url
f.MustRender(w, r, user, company) f.MustRender(w, r, user, company)
}
func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
if err := r.ParseForm(); err != nil {
panic(err)
}
if len(r.Form) > 0 {
// Act as if it was a new form, because everything needed to render form fields is
// already passed as in request query.
id, err := conn.GetInt(r.Context(), "select booking_id from booking where slug = $1", slug)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
serveAdminBookingForm(w, r, user, company, conn, id, "/admin/bookings/"+slug)
return
}
f := newEmptyAdminBookingForm(r.Context(), conn, company, user.Locale)
if err := f.FillFromDatabase(r.Context(), conn, company, slug, user.Locale); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
f.MustRender(w, r, user, company)
case http.MethodPut:
updateBooking(w, r, user, company, conn, slug)
default: default:
httplib.MethodNotAllowed(w, r, http.MethodGet) httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
} }
default: default:
http.NotFound(w, r) http.NotFound(w, r)
@ -150,12 +207,19 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
type adminBookingForm struct { type adminBookingForm struct {
*bookingForm *bookingForm
ID int ID int
URL string
Campsites []*CampsiteEntry Campsites []*CampsiteEntry
selected []int selected []int
Months []*Month Months []*Month
Error error Error error
} }
func newEmptyAdminBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *adminBookingForm {
return &adminBookingForm{
bookingForm: newEmptyBookingForm(ctx, conn, company, l),
}
}
func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) { func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) {
inner, err := newBookingForm(r, company, conn, l) inner, err := newBookingForm(r, company, conn, l)
if err != nil { if err != nil {
@ -171,17 +235,26 @@ func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Com
} }
// Dates and Campsite are valid // Dates and Campsite are valid
if inner.Guests != nil { if inner.Guests != nil {
arrivalDate, _ := time.Parse(database.ISODateFormat, inner.Dates.ArrivalDate.Val) selected := r.Form["campsite"]
from := arrivalDate.AddDate(0, 0, -1) if err = f.FetchCampsites(r.Context(), conn, company, selected); err != nil {
departureDate, _ := time.Parse(database.ISODateFormat, inner.Dates.DepartureDate.Val)
to := departureDate.AddDate(0, 0, 2)
f.Months = CollectMonths(from, to)
f.Campsites, err = CollectCampsiteEntries(r.Context(), company, conn, from, to, inner.CampsiteType.String())
if err != nil {
return nil, err return nil, err
} }
}
return f, nil
}
func (f *adminBookingForm) FetchCampsites(ctx context.Context, conn *database.Conn, company *auth.Company, selected []string) error {
arrivalDate, _ := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
from := arrivalDate.AddDate(0, 0, -1)
departureDate, _ := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
to := departureDate.AddDate(0, 0, 2)
f.Months = CollectMonths(from, to)
var err error
f.Campsites, err = CollectCampsiteEntries(ctx, company, conn, from, to, f.CampsiteType.String())
if err != nil {
return err
}
selected := r.Form["campsite"]
for _, s := range selected { for _, s := range selected {
ID, _ := strconv.Atoi(s) ID, _ := strconv.Atoi(s)
for _, c := range f.Campsites { for _, c := range f.Campsites {
@ -192,8 +265,8 @@ func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Com
} }
} }
} }
}
return f, nil return nil
} }
func findSubtotal(ID int, cart *bookingCart) string { func findSubtotal(ID int, cart *bookingCart) string {
@ -218,48 +291,15 @@ func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, us
} }
func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f, err := newAdminBookingForm(r, conn, company, user.Locale) processAdminBookingForm(w, r, user, company, conn, 0, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
if err != nil {
panic(err)
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
if err := performAddBooking(r.Context(), tx, f); err == nil {
if err := tx.Commit(r.Context()); err != nil {
panic(err)
}
} else {
if err := tx.Rollback(r.Context()); err != nil {
panic(err)
}
panic(err)
}
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
func performAddBooking(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var bookingID int
var err error var err error
bookingID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val) f.ID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val)
if err != nil { if err != nil {
return err return err
} }
return tx.EditBooking( return tx.EditBooking(
ctx, ctx,
bookingID, f.ID,
f.Customer.FullName.Val, f.Customer.FullName.Val,
f.Customer.Address.Val, f.Customer.Address.Val,
f.Customer.PostalCode.Val, f.Customer.PostalCode.Val,
@ -271,6 +311,71 @@ func performAddBooking(ctx context.Context, tx *database.Tx, f *adminBookingForm
"confirmed", "confirmed",
f.selected, f.selected,
) )
})
}
func updateBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
var bookingID int
var bookingStatus string
var langTag string
row := conn.QueryRow(r.Context(), "select booking_id, booking_status, lang_tag from booking where slug = $1", slug)
if err := row.Scan(&bookingID, &bookingStatus, &langTag); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
processAdminBookingForm(w, r, user, company, conn, bookingID, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var err error
_, err = tx.EditBookingFromPayment(ctx, slug, f.PaymentSlug.Val)
if err != nil {
return err
}
return tx.EditBooking(
ctx,
f.ID,
f.Customer.FullName.Val,
f.Customer.Address.Val,
f.Customer.PostalCode.Val,
f.Customer.City.Val,
f.Customer.Country.String(),
f.Customer.Email.Val,
f.Customer.Phone.Val,
language.Make(langTag),
bookingStatus,
f.selected,
)
})
}
func processAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, bookingID int, act func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error) {
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
f.ID = bookingID
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
defer tx.Rollback(r.Context())
if err := act(r.Context(), tx, f); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
} }
func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
@ -327,7 +432,7 @@ func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *lo
f.Error = errors.New(l.Gettext("You must select at least one accommodation.")) f.Error = errors.New(l.Gettext("You must select at least one accommodation."))
v.AllOK = false v.AllOK = false
} else if f.Dates.ArrivalDate.Error == nil && f.Dates.DepartureDate.Error == nil { } else if f.Dates.ArrivalDate.Error == nil && f.Dates.DepartureDate.Error == nil {
if available, err := datesAvailable(ctx, conn, f.Dates, f.selected); err != nil { if available, err := datesAvailable(ctx, conn, f.ID, f.Dates, f.selected); err != nil {
return false, err return false, err
} else if !available { } else if !available {
f.Error = errors.New(l.Gettext("The selected accommodations have no available openings in the requested dates.")) f.Error = errors.New(l.Gettext("The selected accommodations have no available openings in the requested dates."))
@ -338,6 +443,162 @@ func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *lo
return v.AllOK, nil return v.AllOK, nil
} }
func datesAvailable(ctx context.Context, conn *database.Conn, dates *DateFields, selectedCampsites []int) (bool, error) { func datesAvailable(ctx context.Context, conn *database.Conn, bookingID int, dates *DateFields, selectedCampsites []int) (bool, error) {
return conn.GetBool(ctx, "select not exists (select 1 from camper.booking_campsite where campsite_id = any ($3) and stay && daterange($1::date, $2::date))", dates.ArrivalDate, dates.DepartureDate, selectedCampsites) return conn.GetBool(ctx, `
select not exists (
select 1
from camper.booking_campsite
where booking_id <> $1
and campsite_id = any ($4)
and stay && daterange($2::date, $3::date)
)
`,
bookingID,
dates.ArrivalDate,
dates.DepartureDate,
selectedCampsites,
)
}
func (f *adminBookingForm) FillFromDatabase(ctx context.Context, conn *database.Conn, company *auth.Company, slug string, l *locale.Locale) error {
f.Cart = &bookingCart{Draft: &paymentDraft{}}
f.Customer = newBookingCustomerFields(ctx, conn, l)
var arrivalDate string
var departureDate string
var acsiCard bool
var zonePreferences string
var selected []string
row := conn.QueryRow(ctx, `
select booking_id
, '/admin/bookings/' || booking.slug
, array[campsite_type.slug::text]
, lower(booking.stay)::text
, upper(booking.stay)::text
, upper(booking.stay) - lower(booking.stay)
, to_price(subtotal_nights, decimal_digits)
, number_adults
, to_price(subtotal_adults, decimal_digits)
, number_teenagers
, to_price(subtotal_teenagers, decimal_digits)
, number_children
, to_price(subtotal_children, decimal_digits)
, number_dogs
, to_price(subtotal_dogs, decimal_digits)
, to_price(subtotal_tourist_tax, decimal_digits)
, to_price(total, decimal_digits)
, acsi_card
, holder_name
, coalesce(address, '')
, coalesce(postal_code, '')
, coalesce(city, '')
, array[coalesce(country_code::text, '')]
, coalesce(email::text, '')
, coalesce(phone::text, '')
, zone_preferences
, array_agg(coalesce(campsite_id::text, ''))
from booking
join campsite_type using (campsite_type_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join booking_campsite using (booking_id)
join currency using (currency_code)
where booking.slug = $1
group by booking_id
, campsite_type.slug
, booking.stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, zone_preferences
, decimal_digits
`, slug)
if err := row.Scan(
&f.ID,
&f.URL,
&f.CampsiteType.Selected,
&arrivalDate,
&departureDate,
&f.Cart.Draft.NumNights,
&f.Cart.Draft.Nights,
&f.Cart.Draft.NumAdults,
&f.Cart.Draft.Adults,
&f.Cart.Draft.NumTeenagers,
&f.Cart.Draft.Teenagers,
&f.Cart.Draft.NumChildren,
&f.Cart.Draft.Children,
&f.Cart.Draft.NumDogs,
&f.Cart.Draft.Dogs,
&f.Cart.Draft.TouristTax,
&f.Cart.Draft.Total,
&acsiCard,
&f.Customer.FullName.Val,
&f.Customer.Address.Val,
&f.Customer.PostalCode.Val,
&f.Customer.City.Val,
&f.Customer.Country.Selected,
&f.Customer.Email.Val,
&f.Customer.Phone.Val,
&zonePreferences,
&selected,
); err != nil {
return err
}
var err error
f.Dates, err = NewDateFields(ctx, conn, f.CampsiteType.String())
if err != nil {
return err
}
f.Dates.ArrivalDate.Val = arrivalDate
f.Dates.DepartureDate.Val = departureDate
f.Dates.AdjustValues(l)
f.Guests, err = newBookingGuestFields(ctx, conn, f.CampsiteType.String(), arrivalDate, departureDate)
if err != nil {
return err
}
f.Guests.NumberAdults.Val = strconv.Itoa(f.Cart.Draft.NumAdults)
f.Guests.NumberTeenagers.Val = strconv.Itoa(f.Cart.Draft.NumTeenagers)
f.Guests.NumberChildren.Val = strconv.Itoa(f.Cart.Draft.NumChildren)
if f.Guests.NumberDogs != nil {
f.Guests.NumberDogs.Val = strconv.Itoa(f.Cart.Draft.NumDogs)
}
if f.Guests.ACSICard != nil {
f.Guests.ACSICard.Checked = acsiCard
}
f.Guests.AdjustValues(f.Cart.Draft.NumAdults+f.Cart.Draft.NumTeenagers+f.Cart.Draft.NumChildren, l)
f.Options, err = newBookingOptionFields(ctx, conn, f.CampsiteType.String(), l)
if err != nil {
return err
}
if f.Options != nil {
if f.Options.ZonePreferences != nil {
f.Options.ZonePreferences.Val = zonePreferences
}
if err = f.Options.FillFromDatabase(ctx, conn, f.ID); err != nil {
return err
}
}
if err = f.FetchCampsites(ctx, conn, company, selected); err != nil {
return err
}
return nil
} }

View File

@ -149,20 +149,24 @@ type bookingCustomerFields struct {
Agreement *form.Checkbox Agreement *form.Checkbox
} }
func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) { func newEmptyBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *bookingForm {
if err := r.ParseForm(); err != nil { return &bookingForm{
return nil, err
}
f := &bookingForm{
CampsiteType: &form.Select{ CampsiteType: &form.Select{
Name: "campsite_type", Name: "campsite_type",
Options: form.MustGetOptions(r.Context(), conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID), Options: form.MustGetOptions(ctx, conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
}, },
PaymentSlug: &form.Input{ PaymentSlug: &form.Input{
Name: "payment_slug", Name: "payment_slug",
}, },
} }
}
func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
f := newEmptyBookingForm(r.Context(), conn, company, l)
f.CampsiteType.FillValue(r) f.CampsiteType.FillValue(r)
f.PaymentSlug.FillValue(r) f.PaymentSlug.FillValue(r)
campsiteType := f.CampsiteType.String() campsiteType := f.CampsiteType.String()
@ -270,7 +274,10 @@ func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string
func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) { func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) {
f.ArrivalDate.FillValue(r) f.ArrivalDate.FillValue(r)
f.DepartureDate.FillValue(r) f.DepartureDate.FillValue(r)
f.AdjustValues(l)
}
func (f *DateFields) AdjustValues(l *locale.Locale) {
if f.ArrivalDate.Val != "" { if f.ArrivalDate.Val != "" {
arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val) arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val)
if err != nil { if err != nil {
@ -362,6 +369,10 @@ func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) {
if f.ACSICard != nil { if f.ACSICard != nil {
f.ACSICard.FillValue(r) f.ACSICard.FillValue(r)
} }
f.AdjustValues(numGuests, l)
}
func (f *bookingGuestFields) AdjustValues(numGuests int, l *locale.Locale) {
if numGuests > f.MaxGuests { if numGuests > f.MaxGuests {
if f.OverflowAllowed { if f.OverflowAllowed {
f.Overflow = true f.Overflow = true
@ -472,6 +483,42 @@ func (f *bookingOptionFields) FillValues(r *http.Request) {
} }
} }
func (f *bookingOptionFields) FillFromDatabase(ctx context.Context, conn *database.Conn, bookingID int) error {
rows, err := conn.Query(ctx, `
select campsite_type_option.campsite_type_option_id
, coalesce(units, lower(range))::text
, to_price(coalesce(subtotal, 0), decimal_digits)
from booking
join campsite_type_option using (campsite_type_id)
left join booking_option
on booking.booking_id = booking_option.booking_id
and booking_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id
join currency using (currency_code)
where booking.booking_id = $1
`, bookingID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int
var units string
var subtotal string
if err = rows.Scan(&id, &units, &subtotal); err != nil {
return err
}
for _, option := range f.Options {
if option.ID == id {
option.Input.Val = units
option.Subtotal = subtotal
break
}
}
}
return nil
}
func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) { func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) {
for _, option := range f.Options { for _, option := range f.Options {
if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) { if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) {

View File

@ -357,6 +357,10 @@ func (tx *Tx) AddBookingFromPayment(ctx context.Context, paymentSlug string) (in
return tx.GetInt(ctx, "select add_booking_from_payment($1)", paymentSlug) return tx.GetInt(ctx, "select add_booking_from_payment($1)", paymentSlug)
} }
func (tx *Tx) EditBookingFromPayment(ctx context.Context, bookingSlug string, paymentSlug string) (int, error) {
return tx.GetInt(ctx, "select edit_booking_from_payment($1, $2)", bookingSlug, paymentSlug)
}
func (tx *Tx) EditBooking(ctx context.Context, bookingID int, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, bookingStatus string, campsiteIDs []int) error { func (tx *Tx) EditBooking(ctx context.Context, bookingID int, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, bookingStatus string, campsiteIDs []int) error {
_, err := tx.Exec(ctx, "select edit_booking($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", bookingID, customerName, zeronull.Text(customerAddress), zeronull.Text(customerPostCode), zeronull.Text(customerCity), zeronull.Text(customerCountryCode), zeronull.Text(customerEmail), zeronull.Text(customerPhone), customerLangTag, bookingStatus, campsiteIDs) _, err := tx.Exec(ctx, "select edit_booking($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", bookingID, customerName, zeronull.Text(customerAddress), zeronull.Text(customerPostCode), zeronull.Text(customerCity), zeronull.Text(customerCountryCode), zeronull.Text(customerEmail), zeronull.Text(customerPhone), customerLangTag, bookingStatus, campsiteIDs)
return err return err

View File

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

View File

@ -289,3 +289,4 @@ booking__payment_fields [booking positive_integer nonnegative_integer] 2024-04-2
booking_option [roles schema_camper booking campsite_type_option positive_integer nonnegative_integer] 2024-04-24T11:14:03Z jordi fita mas <jordi@tandem.blog> # Add booking campsite option relation booking_option [roles schema_camper booking campsite_type_option positive_integer nonnegative_integer] 2024-04-24T11:14:03Z jordi fita mas <jordi@tandem.blog> # Add booking campsite option relation
add_booking_from_payment [roles schema_camper booking booking__payment_fields booking__stay booking_option payment payment__acsi_card payment_customer payment_option] 2024-04-24T11:23:22Z jordi fita mas <jordi@tandem.blog> # Add function to create a pre-booking from a payment add_booking_from_payment [roles schema_camper booking booking__payment_fields booking__stay booking_option payment payment__acsi_card payment_customer payment_option] 2024-04-24T11:23:22Z jordi fita mas <jordi@tandem.blog> # Add function to create a pre-booking from a payment
edit_booking [roles schema_camper booking booking__payment_fields booking__stay booking_campsite] 2024-04-24T16:27:10Z jordi fita mas <jordi@tandem.blog> # Add function to update a booking edit_booking [roles schema_camper booking booking__payment_fields booking__stay booking_campsite] 2024-04-24T16:27:10Z jordi fita mas <jordi@tandem.blog> # Add function to update a booking
edit_booking_from_payment [roles schema_camper booking booking__payment_fields booking__stay booking_option payment payment__acsi_card payment_option] 2024-04-25T17:18:41Z jordi fita mas <jordi@tandem.blog> # Add function to edit a booking from a payment

View File

@ -0,0 +1,99 @@
-- Test edit_booking_from_payment
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(12);
set search_path to camper, public;
select has_function('camper', 'edit_booking_from_payment', array['uuid', 'uuid']);
select function_lang_is('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'plpgsql');
select function_returns('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'integer');
select isnt_definer('camper', 'edit_booking_from_payment', array['uuid', 'uuid']);
select volatility_is('camper', 'edit_booking_from_payment', array['uuid', 'uuid'], 'volatile');
select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'guest', array[]::text[]);
select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'employee', array['EXECUTE']);
select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'admin', array['EXECUTE']);
select function_privs_are('camper', 'edit_booking_from_payment', array ['uuid', 'uuid'], 'authenticator', array[]::text[]);
set client_min_messages to warning;
truncate booking_option cascade;
truncate booking cascade;
truncate payment_option cascade;
truncate payment cascade;
truncate campsite_type_option 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, rtc_number, tourist_tax, tourist_tax_max_days, country_code, currency_code, default_lang_tag)
values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', '', 60, 7, '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 (6, 2, 'cover2.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, max_campers, bookable_nights)
values (12, 2, 'Wooden lodge', 6, 7, '[1, 7]')
, (14, 2, 'Bungalow', 6, 7, '[1, 7]')
, (16, 2, 'Plot', 6, 7, '[1, 7]')
;
insert into campsite_type_option (campsite_type_option_id, campsite_type_id, name, range, per_night)
values (18, 12, 'Big tent', '[0, 4)', true)
, (20, 12, 'Small tent', '[0, 4)', true)
, (22, 14, 'Electricity', '[0, 5)', false)
, (24, 14, 'Car', '[0, 4)', true)
, (26, 16, 'Autocaravan', '[0, 4)', true)
;
insert into payment (payment_id, slug, company_id, campsite_type_id, arrival_date, departure_date, subtotal_nights, number_adults, subtotal_adults, number_teenagers, subtotal_teenagers, number_children, subtotal_children, number_dogs, subtotal_dogs, subtotal_tourist_tax, total, currency_code, zone_preferences, acsi_card, payment_status)
values (28, '4ef35e2f-ef98-42d6-a724-913bd761ca8c', 2, 12, '2024-08-28', '2024-09-04', 3200, 2, 10420, 4, 20840, 6, 25080, 3, 2450, 4900, 79160, 'EUR', 'pref I before E', true, 'draft')
;
insert into payment_option (payment_id, campsite_type_option_id, units, subtotal)
values (28, 18, 1, 1500)
;
insert into booking (booking_id, slug, company_id, campsite_type_id, stay, holder_name, address, postal_code, city, country_code, email, phone, lang_tag, zone_preferences, subtotal_nights, number_adults, subtotal_adults, number_teenagers, subtotal_teenagers, number_children, subtotal_children, number_dogs, subtotal_dogs, subtotal_tourist_tax, total, acsi_card, currency_code, booking_status)
values (30, 'e3c478f1-8895-4cc6-b644-b19b5a553bb9', 2, 14, daterange('2024-08-29', '2024-09-03'), 'First', 'Fake St., 123', '17800', 'Girona', 'ES', 'customer@example.com', '+34 977 97 79 77', 'ca', '', 71000, 1, 0, 2, 0, 3, 0, 0, 0, 1750, 72750, false, 'EUR', 'confirmed');
insert into booking_option (booking_id, campsite_type_option_id, units, subtotal)
values (30, 22, 2, 111)
, (30, 24, 3, 2222)
;
select lives_ok(
$$ select edit_booking_from_payment('e3c478f1-8895-4cc6-b644-b19b5a553bb9', '4ef35e2f-ef98-42d6-a724-913bd761ca8c') $$,
'Should be able to update a booking from a payment'
);
select bag_eq(
$$ select booking_id, company_id, campsite_type_id, stay, holder_name, address, postal_code, city, country_code::text, email::text, phone::text, lang_tag, zone_preferences, subtotal_nights::integer, number_adults::integer, subtotal_adults::integer, number_teenagers::integer, subtotal_teenagers::integer, number_children::integer, subtotal_children::integer, number_dogs::integer, subtotal_dogs::integer, subtotal_tourist_tax::integer, total::integer, acsi_card, currency_code::text, booking_status from booking $$,
$$ values (30, 2, 12, daterange('2024-08-28', '2024-09-04'), 'First', 'Fake St., 123', '17800', 'Girona', 'ES', 'customer@example.com', '+34 977 97 79 77', 'ca', 'pref I before E', 3200, 2, 10420, 4, 20840, 6, 25080, 3, 2450, 4900, 79160, true, 'EUR', 'confirmed')
$$,
'Should have updated the booking'
);
select bag_eq (
$$ select booking_id, campsite_type_option_id, units, subtotal from booking_option $$,
$$ values (30, 18, 1, 1500)
$$ ,
'Should have updated the booking options too'
);
select *
from finish();
rollback;

View File

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

View File

@ -882,7 +882,7 @@ label[x-show] > span, label[x-show] > br {
grid-column: span 2; grid-column: span 2;
} }
#booking-form #campsites-booking { #booking-form #campsites-booking, #booking-form .error {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

View File

@ -4,15 +4,16 @@
SPDX-License-Identifier: AGPL-3.0-only SPDX-License-Identifier: AGPL-3.0-only
--> -->
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}
{{ CSRFInput }} <input type="hidden" name="{{ .PaymentSlug.Name }}" value="{{ .PaymentSlug.Val }}">
<fieldset> {{ if .Error -}}
<input type="hidden" name="{{ .PaymentSlug.Name }}" value="{{ .PaymentSlug.Val }}"> <p class="error">{{ .Error }}</p>
{{ with .CampsiteType -}} {{- end }}
{{ with .CampsiteType -}}
<label> <label>
{{( pgettext "Accommodation" "title" )}}<br> {{( pgettext "Accommodation" "title" )}}<br>
<select name="{{ .Name }}" <select name="{{ .Name }}"
required required
data-hx-get="/admin/bookings/new" data-hx-trigger="change" data-hx-get="{{ $.URL }}" data-hx-trigger="change"
{{ template "error-attrs" . }} {{ template "error-attrs" . }}
> >
<option value="">{{( gettext "Choose an accommodation" )}}</option> <option value="">{{( gettext "Choose an accommodation" )}}</option>
@ -20,10 +21,10 @@
</select><br> </select><br>
{{ template "error-message" . }} {{ template "error-message" . }}
</label> </label>
{{- end }} {{- end }}
{{ with .Dates -}} {{ with .Dates -}}
<fieldset class="booking-period" <fieldset class="booking-period"
data-hx-get="/admin/bookings/new" data-hx-get="{{ $.URL }}"
data-hx-trigger="change delay:500ms" data-hx-trigger="change delay:500ms"
> >
{{ with .ArrivalDate -}} {{ with .ArrivalDate -}}
@ -49,8 +50,8 @@
</label> </label>
{{- end }} {{- end }}
</fieldset> </fieldset>
{{- end }} {{- end }}
{{ with .Options -}} {{ with .Options -}}
{{ with .ZonePreferences -}} {{ with .ZonePreferences -}}
<label>{{( pgettext "Area preferences (optional)" "input" )}}<br> <label>{{( pgettext "Area preferences (optional)" "input" )}}<br>
<input type="text" <input type="text"
@ -60,11 +61,11 @@
{{ template "error-message" . }} {{ template "error-message" . }}
</label> </label>
{{- end }} {{- end }}
{{- end }} {{- end }}
{{ with $guests := .Guests -}} {{ with $guests := .Guests -}}
{{ $draft := $.Cart.Draft }} {{ $draft := $.Cart.Draft }}
<fieldset class="booking-items" <fieldset class="booking-items"
data-hx-get="/admin/bookings/new" data-hx-trigger="change" data-hx-get="{{ $.URL }}" data-hx-trigger="change"
> >
<table> <table>
<thead> <thead>
@ -181,8 +182,8 @@
<p class="error">{{ .Error }}</p> <p class="error">{{ .Error }}</p>
{{- end }} {{- end }}
</fieldset> </fieldset>
{{- end }} {{- end }}
{{ with .Customer -}} {{ with .Customer -}}
<fieldset class="customer-details"> <fieldset class="customer-details">
<legend>{{( pgettext "Customer Details" "title" )}}</legend> <legend>{{( pgettext "Customer Details" "title" )}}</legend>
{{ with .FullName -}} {{ with .FullName -}}
@ -252,7 +253,7 @@
</label> </label>
{{- end }} {{- end }}
{{ with $.Guests.ACSICard -}} {{ with $.Guests.ACSICard -}}
<label class="colspan" data-hx-get="/admin/bookings/new" data-hx-trigger="change"> <label class="colspan" data-hx-get="{{ $.URL }}" data-hx-trigger="change">
<input type="checkbox" name="{{ .Name }}" {{ if .Checked}}checked{{ end }} <input type="checkbox" name="{{ .Name }}" {{ if .Checked}}checked{{ end }}
{{ template "error-attrs" . }} {{ template "error-attrs" . }}
> {{( pgettext "ACSI card? (optional)" "input" )}}<br> > {{( pgettext "ACSI card? (optional)" "input" )}}<br>
@ -260,21 +261,14 @@
</label> </label>
{{- end }} {{- end }}
</fieldset> </fieldset>
{{- end }} {{- end }}
{{ if .Campsites -}} {{ if .Campsites -}}
<h3>{{( pgettext "Campsites" "title" )}}</h3> <h3>{{( pgettext "Campsites" "title" )}}</h3>
{{ template "grid.gohtml" . }} {{ template "grid.gohtml" . }}
{{ if .Error -}}
<p class="error">{{ .Error }}</p>
{{- end }} {{- end }}
</fieldset> {{- end }}
<footer>
<button type="submit">
{{- if .ID -}}
{{( pgettext "Update" "action" )}}
{{- else -}}
{{( pgettext "Add" "action" )}}
{{- end -}}
</button>
</footer>
{{ define "campsite-heading" -}} {{ define "campsite-heading" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.CampsiteEntry*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.CampsiteEntry*/ -}}

View File

@ -11,6 +11,10 @@
{{ end }} {{ end }}
{{- end }} {{- end }}
{{ define "head" -}}
<script src="/static/idiomorph-ext@0.3.0.min.js"></script>
{{- end }}
{{ define "breadcrumb" -}} {{ define "breadcrumb" -}}
<li><a href="./">{{( pgettext "Bookings" "title" )}}</a></li> <li><a href="./">{{( pgettext "Bookings" "title" )}}</a></li>
{{- end }} {{- end }}
@ -18,19 +22,28 @@
{{ define "content" -}} {{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}} {{- /*gotype: dev.tandem.ws/tandem/camper/pkg/booking.adminBookingForm*/ -}}
<h2>{{ template "title" .}}</h2> <h2>{{ template "title" .}}</h2>
{{ if .Error -}}
<p class="error">{{ .Error }}</p>
{{- end }}
<form id="booking-form" <form id="booking-form"
data-hx-ext="morph" data-hx-ext="morph"
data-hx-swap="morph:innerHTML" data-hx-swap="morph:innerHTML"
data-hx-include="this" data-hx-include="this"
data-hx-target="this" data-hx-target="#booking-form-fields"
data-hx-replace-url="true" data-hx-replace-url="true"
{{- if .ID }} data-hx-put="/admin/bookings/{{ .CurrentLabel }}" {{- if .URL }} data-hx-put="{{ .URL }}"
{{- else }} action="/admin/bookings" method="post" {{- else }} action="/admin/bookings" method="post"
{{- end -}} {{- end -}}
> >
{{ CSRFInput }}
<fieldset id="booking-form-fields">
{{ template "fields.gohtml" . }} {{ template "fields.gohtml" . }}
</fieldset>
<footer>
<button type="submit">
{{- if .ID -}}
{{( pgettext "Update" "action" )}}
{{- else -}}
{{( pgettext "Add" "action" )}}
{{- end -}}
</button>
</footer>
</form> </form>
{{- end }} {{- end }}