camper/pkg/campsite/admin.go

427 lines
12 KiB
Go
Raw Normal View History

/*
* 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":
Add the company’s slug in the URL before company-dependent handlers I really doubt that they are going to use more than a single company, but the application is based on Numerus, that **does** have multiple company, and followed the same architecture and philosophy: use the URL to choose the company to manage, even if the user has a single company. The reason i use the slug instead of the ID is because i do not want to make the ID public in case the application is really used by employees of many unrelated companies: they need not need to guess how many companies there are based on the ID. I validate this slug to be a valid UUID instead of relaying on the query’s empty result because casting a string with a malformed value to UUID results in an error other than data not found. Not with that select, but it would fail with a function parameter, and i want to add that UUID check to all functions that do use slugs. I based uuid.Valid function on Parse() from Google’s uuid package[0] instead of using regular expression, as it was my first idea, because that function is an order of magnitude faster in benchmarks: goos: linux goarch: amd64 pkg: dev.tandem.ws/tandem/numerus/pkg cpu: Intel(R) Core(TM) i5-6200U CPU @ 2.30GHz BenchmarkValidUuid-4 36946050 29.37 ns/op BenchmarkValidUuid_Re-4 3633169 306.70 ns/op The regular expression used for the benchmark was: var re = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") And the input parameter for both functions was the following valid UUID, because most of the time the passed UUID will be valid: "f47ac10b-58cc-0372-8567-0e02b2c3d479" I did not use the uuid package as is, even though it is in Debian’s repository, because i only need to check whether the value is valid, not convert it to a byte array. As far as i know, that package can not do that. Adding the Company struct into auth was not my intention, as it makes little sense name-wise, but i need to have the Company when rendering templates and the company package has templates to render, thus using the company package for the Company struct would create a dependency loop between template and company. I’ve chosen the auth package only because User is also there; User and Company are very much related in this application, but not enough to include the company inside the user, or vice versa, as the User comes from the cookie while the company from the URL. Finally, had to move methodNotAllowed to the http package, as an exported function, because it is used now from other packages, namely campsite. [0]: https://github.com/google/uuid
2023-07-31 16:51:50 +00:00
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)
}