camper/pkg/campsite/admin.go
jordi fita mas 7eb718dfd9 Allow many campsites for each reservation
This is actually only used for plots, but, of course, it means that
every booking now can potentially have many booked campsites, and have
to create a relation for it.

I now have a conundrum regarding stay dates: i need them to be in the
same table as the campsite_id, because constraints only work on a single
relation and without the dates i can not make sure that i am not
overbooking a given campsite; but, on the other hand, all campsites
under the same booking must be for the same dates.

Where does stay belong, then? In booking or booking_campsite? If in
booking then i can not have a constraint that most assuredly will bite
me in the back, but if in booking_campsite then each campsite could
potentially have different dates.

As far as i can see, i can not use a exclude constraint with <> for
dates in booking_campsite to ensure that all rows with the same
booking_id have the same stay (i.e., exclude those that have a different
stay for the same booking_id).

For now, the say is in **both** relations: in booking, because i need it
when it is a prebooking, at least, and in booking_campsite for the
aforementioned constraint requirements.

Will this come back and bite me? Yes, it will. But what can i do?
2024-04-21 21:28:41 +02:00

427 lines
12 KiB
Go

/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package campsite
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v4"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/season"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
types *types.AdminHandler
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{
types: types.NewAdminHandler(),
}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "new":
switch r.Method {
case http.MethodGet:
f := newCampsiteForm(r.Context(), conn, company)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "types":
h.types.Handler(user, company, conn).ServeHTTP(w, r)
case "":
switch r.Method {
case http.MethodGet:
serveCampsiteIndex(w, r, user, company, conn)
case http.MethodPost:
addCampsite(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
default:
f := newCampsiteForm(r.Context(), conn, company)
if err := f.FillFromDatabase(r.Context(), conn, company, head); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editCampsite(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "slides":
h.carouselHandler(user, company, conn, f.Label.Val).ServeHTTP(w, r)
case "features":
h.featuresHandler(user, company, conn, f.Label.Val).ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
}
}
}
func serveCampsiteIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
page := newCampsiteIndex()
if err := page.Parse(r); err != nil {
panic(err)
}
var err error
page.Campsites, err = collectCampsiteEntries(r.Context(), company, conn, page.From.Date(), page.To.Date())
if err != nil {
panic(err)
}
page.Months = collectMonths(page.From.Date(), page.To.Date())
page.MustRender(w, r, user, company)
}
func collectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time) ([]*campsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite.label
, campsite_type.name
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
order by label`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
byLabel := make(map[string]*campsiteEntry)
var campsites []*campsiteEntry
for rows.Next() {
entry := &campsiteEntry{}
if err = rows.Scan(&entry.Label, &entry.Type, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
byLabel[entry.Label] = entry
}
if err := collectBookingEntries(ctx, company, conn, from, to, byLabel); err != nil {
return nil, err
}
return campsites, nil
}
func collectBookingEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsites map[string]*campsiteEntry) error {
lastDay := to.AddDate(0, 1, 0)
rows, err := conn.Query(ctx, `
select campsite.label
, lower(booking_campsite.stay * daterange($2::date, $3::date))
, holder_name
, booking_status
, upper(booking_campsite.stay * daterange($2::date, $3::date)) - lower(booking_campsite.stay * daterange($2::date, $3::date))
, booking_campsite.stay &> daterange($2::date, $3::date)
, booking_campsite.stay &< daterange($2::date, $3::date)
from booking_campsite
join booking using (booking_id)
join campsite using (campsite_id)
where booking.company_id = $1
and booking_campsite.stay && daterange($2::date, $3::date)
order by label`, company.ID, from, lastDay)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
entry := &bookingEntry{}
var label string
var date time.Time
if err = rows.Scan(&label, &date, &entry.Holder, &entry.Status, &entry.Nights, &entry.Begin, &entry.End); err != nil {
return err
}
campsite := campsites[label]
if campsite != nil {
if campsite.Bookings == nil {
campsite.Bookings = make(map[time.Time]*bookingEntry)
}
campsite.Bookings[date] = entry
}
}
return nil
}
type campsiteEntry struct {
Label string
Type string
Active bool
Bookings map[time.Time]*bookingEntry
}
type bookingEntry struct {
Holder string
Status string
Nights int
Begin bool
End bool
}
type campsiteIndex struct {
From *form.Month
To *form.Month
Campsites []*campsiteEntry
Months []*Month
}
func newCampsiteIndex() *campsiteIndex {
now := time.Now()
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
to := from.AddDate(0, 3, 0)
return &campsiteIndex{
From: &form.Month{
Name: "from",
Year: from.Year(),
Month: from.Month(),
},
To: &form.Month{
Name: "to",
Year: to.Year(),
Month: to.Month(),
},
}
}
func (page *campsiteIndex) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
page.From.FillValue(r)
page.To.FillValue(r)
return nil
}
type Month struct {
Year int
Month time.Month
Name string
Days []time.Time
Spans []*Span
}
type Span struct {
Weekend bool
Count int
}
func isWeekend(t time.Time) bool {
switch t.Weekday() {
case time.Saturday, time.Sunday:
return true
default:
return false
}
}
func collectMonths(from time.Time, to time.Time) []*Month {
current := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC)
numMonths := (to.Year()-from.Year())*12 + int(to.Month()) - int(from.Month()) + 1
var months []*Month
for i := 0; i < numMonths; i++ {
span := &Span{
Weekend: isWeekend(current),
}
month := &Month{
Year: current.Year(),
Month: current.Month(),
Name: season.LongMonthNames[current.Month()-1],
Days: make([]time.Time, 0, 31),
Spans: make([]*Span, 0, 10),
}
month.Spans = append(month.Spans, span)
for current.Month() == month.Month {
month.Days = append(month.Days, current)
if span.Weekend != isWeekend(current) {
span = &Span{
Weekend: !span.Weekend,
}
month.Spans = append(month.Spans, span)
}
span.Count = span.Count + 1
current = current.AddDate(0, 0, 1)
}
months = append(months, month)
}
return months
}
func (page *campsiteIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "web/templates/campground_map.svg")
}
func addCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newCampsiteForm(r.Context(), conn, company)
processCampsiteForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
var err error
f.ID, err = tx.AddCampsite(ctx, f.CampsiteType.Int(), f.Label.Val, f.Info1[f.DefaultLang].Val, f.Info2[f.DefaultLang].Val)
if err != nil {
return err
}
return translateCampsite(ctx, tx, company, f)
})
httplib.Redirect(w, r, "/admin/campsites", http.StatusSeeOther)
}
func translateCampsite(ctx context.Context, tx *database.Tx, company *auth.Company, f *campsiteForm) error {
for lang := range company.Locales {
l := lang.String()
if l == f.DefaultLang {
continue
}
if err := tx.TranslateCampsite(ctx, f.ID, lang, f.Info1[l].Val, f.Info2[l].Val); err != nil {
return err
}
}
return nil
}
func editCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *campsiteForm) {
processCampsiteForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
if err := tx.EditCampsite(ctx, f.ID, f.CampsiteType.Int(), f.Label.Val, f.Info1[f.DefaultLang].Val, f.Info2[f.DefaultLang].Val, f.Active.Checked); err != nil {
return err
}
return translateCampsite(ctx, tx, company, f)
})
httplib.Redirect(w, r, "/admin/campsites", http.StatusSeeOther)
}
func processCampsiteForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *campsiteForm, act func(ctx context.Context, tx *database.Tx) error) {
if ok, err := form.Handle(f, w, r, user); err != nil {
return
} else if !ok {
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
defer tx.Rollback(r.Context())
if err := act(r.Context(), tx); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
httplib.Redirect(w, r, "/admin/campsites", http.StatusSeeOther)
}
type campsiteForm struct {
DefaultLang string
ID int
CurrentLabel string
Active *form.Checkbox
CampsiteType *form.Select
Label *form.Input
Info1 form.I18nInput
Info2 form.I18nInput
}
func newCampsiteForm(ctx context.Context, conn *database.Conn, company *auth.Company) *campsiteForm {
campsiteTypes := form.MustGetOptions(ctx, conn, "select campsite_type_id::text, name from campsite_type where active")
return &campsiteForm{
DefaultLang: company.DefaultLanguage.String(),
Active: &form.Checkbox{
Name: "active",
Checked: true,
},
CampsiteType: &form.Select{
Name: "description",
Options: campsiteTypes,
},
Label: &form.Input{
Name: "label",
},
Info1: form.NewI18nInput(company.Locales, "info1"),
Info2: form.NewI18nInput(company.Locales, "info2"),
}
}
func (f *campsiteForm) FillFromDatabase(ctx context.Context, conn *database.Conn, company *auth.Company, label string) error {
f.CurrentLabel = label
var info1 database.RecordArray
var info2 database.RecordArray
row := conn.QueryRow(ctx, `
select campsite_id
, array[campsite_type_id::text]
, label
, campsite.info1::text
, campsite.info2::text
, active
, array_agg((lang_tag, i18n.info1::text))
, array_agg((lang_tag, i18n.info2::text))
from campsite
left join campsite_i18n as i18n using (campsite_id)
where company_id = $1
and label = $2
group by campsite_id
, campsite_type_id
, label
, campsite.info1::text
, campsite.info2::text
, active
`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID, label)
if err := row.Scan(&f.ID, &f.CampsiteType.Selected, &f.Label.Val, &f.Info1[f.DefaultLang].Val, &f.Info2[f.DefaultLang].Val, &f.Active.Checked, &info1, &info2); err != nil {
return err
}
if err := f.Info1.FillArray(info1); err != nil {
return err
}
if err := f.Info2.FillArray(info2); err != nil {
return err
}
return nil
}
func (f *campsiteForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Active.FillValue(r)
f.CampsiteType.FillValue(r)
f.Label.FillValue(r)
f.Info1.FillValue(r)
f.Info2.FillValue(r)
return nil
}
func (f *campsiteForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l)
v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid."))
v.CheckRequired(f.Label, l.GettextNoop("Label can not be empty."))
return v.AllOK
}
func (f *campsiteForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "campsite/form.gohtml", f)
}