/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package season import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "dev.tandem.ws/tandem/camper/pkg/auth" "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/template" "dev.tandem.ws/tandem/camper/pkg/uuid" ) const unsetColor = 13750495 type AdminHandler struct { locales locale.Locales } func NewAdminHandler(locales locale.Locales) *AdminHandler { return &AdminHandler{ locales: locales, } } 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 "": switch r.Method { case http.MethodGet: serveSeasonIndex(w, r, user, company, conn) case http.MethodPost: addSeason(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "new": switch r.Method { case http.MethodGet: f := newSeasonForm() f.MustRender(w, r, user, company) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } case "range": switch r.Method { case http.MethodGet: serveSeasonCalendar(w, r, user, company, conn) case http.MethodPut: updateSeasonCalendar(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: if !uuid.Valid(head) { http.NotFound(w, r) return } f := newSeasonForm() if err := f.FillFromDatabase(r.Context(), conn, head); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } var langTag string langTag, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch langTag { case "": switch r.Method { case http.MethodGet: f.MustRender(w, r, user, company) case http.MethodPut: editSeason(w, r, user, company, conn, f) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: loc, ok := h.locales.Get(langTag) if !ok { http.NotFound(w, r) return } l10n := newSeasonL10nForm(f, loc) if err := l10n.FillFromDatabase(r.Context(), conn); err != nil { panic(err) } switch r.Method { case http.MethodGet: l10n.MustRender(w, r, user, company) case http.MethodPut: editSeasonL10n(w, r, user, company, conn, l10n) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } } } } } func serveSeasonIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { seasons, err := collectSeasonEntries(r.Context(), company, conn) if err != nil { panic(err) } calendar, err := collectSeasonCalendar(r.Context(), company, conn, getCalendarYear(r.URL.Query())) if err != nil { panic(err) } calendar.Form = newCalendarForm(r.Context(), company, conn) page := &seasonIndex{ Seasons: seasons, Calendar: calendar, } page.MustRender(w, r, user, company) } func getCalendarYear(query url.Values) int { yearStr := strings.TrimSpace(query.Get("year")) if yearStr != "" { if year, err := strconv.Atoi(yearStr); err == nil { return year } } return time.Now().Year() } func collectSeasonEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*seasonEntry, error) { rows, err := conn.Query(ctx, ` select '/admin/seasons/' || season.slug , season.name , to_color(color)::text , active , array_agg((lang_tag, endonym, not exists (select 1 from season_i18n as i18n where i18n.season_id = season.season_id and i18n.lang_tag = language.lang_tag)) order by endonym) from season join company using (company_id) , language where lang_tag <> default_lang_tag and language.selectable and season.company_id = $1 group by season.slug , season.name , to_color(color)::text , active order by name`, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID) if err != nil { return nil, err } defer rows.Close() var seasons []*seasonEntry for rows.Next() { entry := &seasonEntry{} var translations database.RecordArray if err = rows.Scan(&entry.URL, &entry.Name, &entry.Color, &entry.Active, &translations); err != nil { return nil, err } for _, el := range translations.Elements { entry.Translations = append(entry.Translations, &locale.Translation{ URL: entry.URL + "/" + el.Fields[0].Get().(string), Endonym: el.Fields[1].Get().(string), Missing: el.Fields[2].Get().(bool), }) } seasons = append(seasons, entry) } return seasons, nil } type seasonEntry struct { ID int URL string Name string Color string Active bool Translations []*locale.Translation } var longMonthNames = []string{ locale.PgettextNoop("January", "month"), locale.PgettextNoop("February", "month"), locale.PgettextNoop("March", "month"), locale.PgettextNoop("April", "month"), locale.PgettextNoop("May", "month"), locale.PgettextNoop("June", "month"), locale.PgettextNoop("July", "month"), locale.PgettextNoop("August", "month"), locale.PgettextNoop("September", "month"), locale.PgettextNoop("October", "month"), locale.PgettextNoop("November", "month"), locale.PgettextNoop("December", "month"), } func collectSeasonCalendar(ctx context.Context, company *auth.Company, conn *database.Conn, year int) (*seasonCalendar, error) { firstDay := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) lastDay := time.Date(year, time.December, 31, 23, 59, 59, 0, time.UTC) rows, err := conn.Query(ctx, ` select t.day::date , to_color(coalesce(color, $1)) as color from generate_series($2, $3, interval '1 day') as t(day) left join season_calendar on season_range @> t.day::date left join season on season.season_id = season_calendar.season_id and company_id = $4 `, unsetColor, firstDay, lastDay, company.ID) if err != nil { return nil, err } var month *seasonMonth var week seasonWeek calendar := &seasonCalendar{ Year: year, } weekday := int(time.Monday) for rows.Next() { day := &seasonDay{} if err = rows.Scan(&day.Date, &day.Color); err != nil { return nil, err } dayMonth := day.Date.Month() if month == nil || month.Month != dayMonth { if month != nil { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { week = append(week, &seasonDay{}) } month.Weeks = append(month.Weeks, week) calendar.Months = append(calendar.Months, month) } month = &seasonMonth{ Month: dayMonth, Name: longMonthNames[dayMonth-1], } week = seasonWeek{} weekday = int(time.Monday) dayWeekday := int(day.Date.Weekday()) for ; weekday != dayWeekday; weekday = (weekday + 1) % 7 { week = append(week, &seasonDay{}) } } week = append(week, day) weekday = (weekday + 1) % 7 if weekday == int(time.Monday) { month.Weeks = append(month.Weeks, week) week = seasonWeek{} } } if month != nil { for ; weekday != int(time.Sunday); weekday = (weekday + 1) % 7 { week = append(week, &seasonDay{}) } month.Weeks = append(month.Weeks, week) calendar.Months = append(calendar.Months, month) } return calendar, nil } type seasonCalendar struct { Year int Months []*seasonMonth Form *calendarForm } type seasonMonth struct { Month time.Month Name string Weeks []seasonWeek } type seasonWeek []*seasonDay type seasonDay struct { Date time.Time Color string } type seasonIndex struct { Seasons []*seasonEntry Calendar *seasonCalendar } func (page *seasonIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdminFiles(w, r, user, company, page, "season/index.gohtml", "season/calendar.gohtml") } func processSeasonForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm, act func()) { if err := f.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } 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 } act() httplib.Redirect(w, r, "/admin/seasons", http.StatusSeeOther) } func addSeason(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newSeasonForm() processSeasonForm(w, r, user, company, conn, f, func() { conn.MustExec(r.Context(), "select add_season($1, $2, $3)", company.ID, f.Name, f.Color) }) } func editSeason(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *seasonForm) { processSeasonForm(w, r, user, company, conn, f, func() { conn.MustExec(r.Context(), "select edit_season($1, $2, $3, $4)", f.Slug, f.Name, f.Color, f.Active) }) } type seasonForm struct { Slug string Active *form.Checkbox Name *form.Input Color *form.Input } func newSeasonForm() *seasonForm { return &seasonForm{ Active: &form.Checkbox{ Name: "active", Checked: true, }, Name: &form.Input{ Name: "label", }, Color: &form.Input{ Name: "season", }, } } func (f *seasonForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error { f.Slug = slug row := conn.QueryRow(ctx, "select name, to_color(color)::text, active from season where slug = $1", slug) return row.Scan(&f.Name.Val, &f.Color.Val, &f.Active.Checked) } func (f *seasonForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.Active.FillValue(r) f.Name.FillValue(r) f.Color.FillValue(r) return nil } func (f *seasonForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { v := form.NewValidator(l) v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty.")) if v.CheckRequired(f.Color, l.GettextNoop("Color can not be empty.")) { if _, err := v.CheckValidColor(ctx, conn, f.Color, l.Gettext("This color is not valid. It must be like #123abc.")); err != nil { return false, err } } return v.AllOK, nil } func (f *seasonForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { template.MustRenderAdmin(w, r, user, company, "season/form.gohtml", f) } func serveSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newCalendarForm(r.Context(), company, conn) f.MustRender(w, r, user, company, conn, getCalendarYear(r.URL.Query())) } func updateSeasonCalendar(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { f := newCalendarForm(r.Context(), company, conn) if ok, err := form.Handle(f, w, r, user); err != nil { return } else if ok { var seasonRange pgtype.Daterange if err = seasonRange.Set(fmt.Sprintf("[%s,%s]", f.StartDate.Val, f.EndDate.Val)); err != nil { panic(err) } if f.SeasonID.Val == "" { // Nothing to do } else if f.SeasonID.Val == "0" { conn.MustExec(r.Context(), "select unset_season_range($1)", seasonRange) } else { conn.MustExec(r.Context(), "select set_season_range($1, $2)", f.SeasonID, seasonRange) } f.StartDate.Val = "" f.EndDate.Val = "" } f.MustRender(w, r, user, company, conn, getCalendarYear(r.Form)) } type calendarForm struct { Seasons []*seasonEntry StartDate *form.Input EndDate *form.Input SeasonID *form.Input } func newCalendarForm(ctx context.Context, company *auth.Company, conn *database.Conn) *calendarForm { return &calendarForm{ Seasons: mustCollectCalendarSeasons(ctx, company, conn), StartDate: &form.Input{ Name: "start_date", }, EndDate: &form.Input{ Name: "end_date", }, SeasonID: &form.Input{ Name: "season_id", }, } } func mustCollectCalendarSeasons(ctx context.Context, company *auth.Company, conn *database.Conn) []*seasonEntry { rows, err := conn.Query(ctx, ` select 0 as season_id , $1 as name , to_color($2)::text , true , 0 as sort union all select season_id , name , to_color(color)::text , active , 1 as sort from season where company_id = $3 and active order by sort, name`, locale.PgettextNoop("Unset", "action"), unsetColor, company.ID) if err != nil { panic(err) } defer rows.Close() var seasons []*seasonEntry for rows.Next() { entry := &seasonEntry{} var sort int if err = rows.Scan(&entry.ID, &entry.Name, &entry.Color, &entry.Active, &sort); err != nil { panic(err) } seasons = append(seasons, entry) } return seasons } func (f *calendarForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.StartDate.FillValue(r) f.EndDate.FillValue(r) f.SeasonID.FillValue(r) return nil } func (f *calendarForm) Valid(l *locale.Locale) bool { v := form.NewValidator(l) if v.CheckRequired(f.StartDate, l.GettextNoop("Start date can not be empty.")) { v.CheckValidDate(f.StartDate, l.GettextNoop("Start date must be a valid date.")) } if v.CheckRequired(f.EndDate, l.GettextNoop("End date can not be empty.")) { v.CheckValidDate(f.EndDate, l.GettextNoop("End date must be a valid date.")) } return v.AllOK } func (f *calendarForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, year int) { calendar, err := collectSeasonCalendar(r.Context(), company, conn, year) if err != nil { panic(err) } calendar.Form = f template.MustRenderNoLayout(w, r, user, company, "season/calendar.gohtml", calendar) }