From f746c82b46bbb8473b92ea3cdf1937c63b1f03c3 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Fri, 15 Sep 2023 01:05:38 +0200 Subject: [PATCH] =?UTF-8?q?Make=20home=20page=E2=80=99s=20carousel=20manag?= =?UTF-8?q?eable=20via=20the=20database?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I debated with myself whether to create the home_carousel relation or rather if it would be better to have a single carousel relation for all pages. However, i thought that it would be actually harder to maintain a single relation because i would need an additional column to tell one carrousel from another, and what would that column be? An enum? A foreign key to another relation? home_carousel carries no such issues. I was starting to duplicate logic all over the packages, such as the way to encode media paths or “localization” (l10n) input fields. Therefore, i refactorized them. In the case of media path, i added a function that accepts rows of media, because always need the same columns from the row, and it was yet another repetition if i needed to pass them all the time. Plus, these kind of functions can be called as `table.function`, that make them look like columns from the table; if PostgreSQL implemented virtual generated columns, i would have used that instead. I am not sure whether that media_path function can be immutable. An immutable function is “guaranteed to return the same results given the same arguments forever”, which would be true if the inputs where the hash and the original_filename columns, instead of the whole rows, but i left it as static because i did not know whether PostgreSQL interprets the “same row but with different values” as a different input. That is, whether PostgreSQL’s concept of row is the actual tuple or the space that has a rowid, irrespective of contents; in the latter case, the function can not be immutable. Just to be in the safe side, i left it stable. The home page was starting to grow a bit too much inside the app package, new that it has its own admin handler, and moved it all to a separate package. --- demo/demo.sql | 28 ++ .../home_carousel0.jpg | Bin .../home_carousel1.jpg | Bin .../home_carousel2.jpg | Bin .../besalu.jpg => demo/home_carousel3.jpg | Bin .../santa_pau.jpg => demo/home_carousel4.jpg | Bin .../banyoles.jpg => demo/home_carousel5.jpg | Bin .../girn-a.jpg => demo/home_carousel6.jpg | Bin .../home_carousel7.jpg | Bin .../home_carousel8.jpg | Bin deploy/add_home_carousel_slide.sql | 25 ++ deploy/home_carousel.sql | 53 +++ deploy/home_carousel_i18n.sql | 22 ++ deploy/media_path.sql | 23 ++ deploy/remove_home_carousel_slide.sql | 22 ++ deploy/translate_home_carousel_slide.sql | 23 ++ pkg/app/admin.go | 5 + pkg/app/public.go | 64 +--- pkg/campsite/types/admin.go | 16 +- pkg/campsite/types/l10n.go | 27 +- pkg/form/input.go | 14 + pkg/home/admin.go | 64 ++++ pkg/home/carousel.go | 311 ++++++++++++++++++ pkg/home/l10n.go | 79 +++++ pkg/home/public.go | 93 ++++++ pkg/locale/locale.go | 9 + po/ca.po | 103 ++++-- po/es.po | 103 ++++-- revert/add_home_carousel_slide.sql | 7 + revert/home_carousel.sql | 7 + revert/home_carousel_i18n.sql | 7 + revert/media_path.sql | 7 + revert/remove_home_carousel_slide.sql | 7 + revert/translate_home_carousel_slide.sql | 7 + sqitch.plan | 6 + test/add_home_carousel_slide.sql | 75 +++++ test/home_carousel.sql | 178 ++++++++++ test/home_carousel_i18n.sql | 44 +++ test/media_path.sql | 53 +++ test/remove_home_carousel_slide.sql | 80 +++++ test/translate_home_carousel_slide.sql | 78 +++++ verify/add_home_carousel_slide.sql | 7 + verify/home_carousel.sql | 16 + verify/home_carousel_i18n.sql | 11 + verify/media_path.sql | 7 + verify/remove_home_carousel_slide.sql | 7 + verify/translate_home_carousel_slide.sql | 7 + web/static/public.css | 4 +- web/templates/admin/campsite/type/form.gohtml | 2 +- web/templates/admin/home/carousel/form.gohtml | 65 ++++ web/templates/admin/home/carousel/l10n.gohtml | 36 ++ web/templates/admin/home/index.gohtml | 50 +++ web/templates/admin/layout.gohtml | 3 + web/templates/public/home.gohtml | 46 +-- 54 files changed, 1732 insertions(+), 169 deletions(-) rename web/static/images/Volca_de_Santa_Margarida.jpg => demo/home_carousel0.jpg (100%) rename web/static/images/Gorga_fosca_Sadernes.jpg => demo/home_carousel1.jpg (100%) rename web/static/images/castellfolit_de_la_roca.jpg => demo/home_carousel2.jpg (100%) rename web/static/images/besalu.jpg => demo/home_carousel3.jpg (100%) rename web/static/images/santa_pau.jpg => demo/home_carousel4.jpg (100%) rename web/static/images/banyoles.jpg => demo/home_carousel5.jpg (100%) rename web/static/images/girn-a.jpg => demo/home_carousel6.jpg (100%) rename web/static/images/costa_brava.jpg => demo/home_carousel7.jpg (100%) rename web/static/images/barcelona-1.jpg => demo/home_carousel8.jpg (100%) create mode 100644 deploy/add_home_carousel_slide.sql create mode 100644 deploy/home_carousel.sql create mode 100644 deploy/home_carousel_i18n.sql create mode 100644 deploy/media_path.sql create mode 100644 deploy/remove_home_carousel_slide.sql create mode 100644 deploy/translate_home_carousel_slide.sql create mode 100644 pkg/home/admin.go create mode 100644 pkg/home/carousel.go create mode 100644 pkg/home/l10n.go create mode 100644 pkg/home/public.go create mode 100644 revert/add_home_carousel_slide.sql create mode 100644 revert/home_carousel.sql create mode 100644 revert/home_carousel_i18n.sql create mode 100644 revert/media_path.sql create mode 100644 revert/remove_home_carousel_slide.sql create mode 100644 revert/translate_home_carousel_slide.sql create mode 100644 test/add_home_carousel_slide.sql create mode 100644 test/home_carousel.sql create mode 100644 test/home_carousel_i18n.sql create mode 100644 test/media_path.sql create mode 100644 test/remove_home_carousel_slide.sql create mode 100644 test/translate_home_carousel_slide.sql create mode 100644 verify/add_home_carousel_slide.sql create mode 100644 verify/home_carousel.sql create mode 100644 verify/home_carousel_i18n.sql create mode 100644 verify/media_path.sql create mode 100644 verify/remove_home_carousel_slide.sql create mode 100644 verify/translate_home_carousel_slide.sql create mode 100644 web/templates/admin/home/carousel/form.gohtml create mode 100644 web/templates/admin/home/carousel/l10n.gohtml create mode 100644 web/templates/admin/home/index.gohtml diff --git a/demo/demo.sql b/demo/demo.sql index f24db0f..39dd4ad 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -29,6 +29,34 @@ values (52, 'plots.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/plo , (52, 'safari_tents.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/safari_tents.avif]])', 'base64')) , (52, 'bungalows.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/bungalows.avif]])', 'base64')) , (52, 'wooden_lodges.avif', 'image/avif', decode('m4_esyscmd([[base64 -w0 demo/wooden_lodges.avif]])', 'base64')) + , (52, 'home_carousel0.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel0.jpg]])', 'base64')) + , (52, 'home_carousel1.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel1.jpg]])', 'base64')) + , (52, 'home_carousel2.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel2.jpg]])', 'base64')) + , (52, 'home_carousel3.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel3.jpg]])', 'base64')) + , (52, 'home_carousel4.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel4.jpg]])', 'base64')) + , (52, 'home_carousel5.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel5.jpg]])', 'base64')) + , (52, 'home_carousel6.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel6.jpg]])', 'base64')) + , (52, 'home_carousel7.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel7.jpg]])', 'base64')) + , (52, 'home_carousel8.jpg', 'image/jpeg', decode('m4_esyscmd([[base64 -w0 demo/home_carousel8.jpg]])', 'base64')) +; + +insert into home_carousel (media_id, caption) +values (66, 'Volcà de Santa Margarida') + , (67, 'Gorga fosca Sadernes') + , (68, 'Castellfollit de la Roca') + , (69, 'Besalú') + , (70, 'Santa Pau') + , (71, 'Banyoles') + , (72, 'Girona') + , (73, 'Costa Brava') + , (74, 'Barcelona') +; + +insert into home_carousel_i18n (media_id, lang_tag, caption) +values (66, 'en', 'Santa Margarida volcano') + , (66, 'es', 'Volcán de Santa Margarida') + , (67, 'en', 'Sadernes dark gorge') + , (67, 'es', 'Piletón oscuro Sadernes') ; alter sequence campsite_type_campsite_type_id_seq restart with 72; diff --git a/web/static/images/Volca_de_Santa_Margarida.jpg b/demo/home_carousel0.jpg similarity index 100% rename from web/static/images/Volca_de_Santa_Margarida.jpg rename to demo/home_carousel0.jpg diff --git a/web/static/images/Gorga_fosca_Sadernes.jpg b/demo/home_carousel1.jpg similarity index 100% rename from web/static/images/Gorga_fosca_Sadernes.jpg rename to demo/home_carousel1.jpg diff --git a/web/static/images/castellfolit_de_la_roca.jpg b/demo/home_carousel2.jpg similarity index 100% rename from web/static/images/castellfolit_de_la_roca.jpg rename to demo/home_carousel2.jpg diff --git a/web/static/images/besalu.jpg b/demo/home_carousel3.jpg similarity index 100% rename from web/static/images/besalu.jpg rename to demo/home_carousel3.jpg diff --git a/web/static/images/santa_pau.jpg b/demo/home_carousel4.jpg similarity index 100% rename from web/static/images/santa_pau.jpg rename to demo/home_carousel4.jpg diff --git a/web/static/images/banyoles.jpg b/demo/home_carousel5.jpg similarity index 100% rename from web/static/images/banyoles.jpg rename to demo/home_carousel5.jpg diff --git a/web/static/images/girn-a.jpg b/demo/home_carousel6.jpg similarity index 100% rename from web/static/images/girn-a.jpg rename to demo/home_carousel6.jpg diff --git a/web/static/images/costa_brava.jpg b/demo/home_carousel7.jpg similarity index 100% rename from web/static/images/costa_brava.jpg rename to demo/home_carousel7.jpg diff --git a/web/static/images/barcelona-1.jpg b/demo/home_carousel8.jpg similarity index 100% rename from web/static/images/barcelona-1.jpg rename to demo/home_carousel8.jpg diff --git a/deploy/add_home_carousel_slide.sql b/deploy/add_home_carousel_slide.sql new file mode 100644 index 0000000..d371489 --- /dev/null +++ b/deploy/add_home_carousel_slide.sql @@ -0,0 +1,25 @@ +-- Deploy camper:add_home_carousel_slide to pg +-- requires: roles +-- requires: schema_camper +-- requires: home_carousel + +begin; + +set search_path to camper, public; + +create or replace function add_home_carousel_slide(media_id integer, caption text) returns integer as +$$ + insert into home_carousel (media_id, caption) + values (media_id, coalesce(caption, '')) + on conflict (media_id) do update + set caption = excluded.caption + returning media_id + ; +$$ + language sql +; + +revoke execute on function add_home_carousel_slide(integer, text) from public; +grant execute on function add_home_carousel_slide(integer, text) to admin; + +commit; diff --git a/deploy/home_carousel.sql b/deploy/home_carousel.sql new file mode 100644 index 0000000..24bc6c5 --- /dev/null +++ b/deploy/home_carousel.sql @@ -0,0 +1,53 @@ +-- Deploy camper:home_carousel to pg +-- requires: roles +-- requires: schema_public +-- requires: company +-- requires: media +-- requires: user_profile + +begin; + +set search_path to camper, public; + +create table home_carousel ( + media_id integer not null references media primary key, + caption text not null +); + +grant select on table home_carousel to guest; +grant select on table home_carousel to employee; +grant select, insert, update, delete on table home_carousel to admin; + +alter table home_carousel enable row level security; + +create policy guest_ok +on home_carousel +for select +using (true) +; + +create policy insert_to_company +on home_carousel +for insert +with check ( + exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id) +) +; + +create policy update_company +on home_carousel +for update +using ( + exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id) +) +; + +create policy delete_from_company +on home_carousel +for delete +using ( + exists (select 1 from media join user_profile using (company_id) where media.media_id = home_carousel.media_id) +) +; + +commit; diff --git a/deploy/home_carousel_i18n.sql b/deploy/home_carousel_i18n.sql new file mode 100644 index 0000000..ca1eb57 --- /dev/null +++ b/deploy/home_carousel_i18n.sql @@ -0,0 +1,22 @@ +-- Deploy camper:home_carousel_i18n to pg +-- requires: roles +-- requires: schema_camper +-- requires: home_carousel +-- requires: language + +begin; + +set search_path to camper, public; + +create table home_carousel_i18n ( + media_id integer not null references home_carousel, + lang_tag text not null references language, + caption text not null, + primary key (media_id, lang_tag) +); + +grant select on table home_carousel_i18n to guest; +grant select on table home_carousel_i18n to employee; +grant select, insert, update, delete on table home_carousel_i18n to admin; + +commit; diff --git a/deploy/media_path.sql b/deploy/media_path.sql new file mode 100644 index 0000000..d506d4c --- /dev/null +++ b/deploy/media_path.sql @@ -0,0 +1,23 @@ +-- Deploy camper:media_path to pg +-- requires: roles +-- requires: schema_camper +-- requires: media + +begin; + +set search_path to camper, public; + +create or replace function path(media) returns text as +$$ + select '/media/' || encode($1.hash, 'hex') || '/' || $1.original_filename; +$$ + language sql + stable +; + +revoke execute on function path(media) from public; +grant execute on function path(media) to guest; +grant execute on function path(media) to employee; +grant execute on function path(media) to admin; + +commit; diff --git a/deploy/remove_home_carousel_slide.sql b/deploy/remove_home_carousel_slide.sql new file mode 100644 index 0000000..6557add --- /dev/null +++ b/deploy/remove_home_carousel_slide.sql @@ -0,0 +1,22 @@ +-- Deploy camper:remove_home_carousel_slide to pg +-- requires: roles +-- requires: schema_camper +-- requires: home_carousel +-- requires: home_carousel_i18n + +begin; + +set search_path to camper, public; + +create or replace function remove_home_carousel_slide (media_id integer) returns void as +$$ + delete from home_carousel_i18n where media_id = $1; + delete from home_carousel where media_id = $1; +$$ + language sql +; + +revoke execute on function remove_home_carousel_slide (integer) from public; +grant execute on function remove_home_carousel_slide (integer) to admin; + +commit; diff --git a/deploy/translate_home_carousel_slide.sql b/deploy/translate_home_carousel_slide.sql new file mode 100644 index 0000000..6476366 --- /dev/null +++ b/deploy/translate_home_carousel_slide.sql @@ -0,0 +1,23 @@ +-- Deploy camper:translate_home_carousel_slide to pg +-- requires: roles +-- requires: schema_camper +-- requires: home_carousel_i18n + +begin; + +set search_path to camper, public; + +create or replace function translate_home_carousel_slide (media_id integer, lang_tag text, caption text) returns void as +$$ + insert into home_carousel_i18n (media_id, lang_tag, caption) + values (media_id, lang_tag, coalesce(caption, '')) + on conflict (media_id, lang_tag) do update + set caption = excluded.caption +$$ + language sql +; + +revoke execute on function translate_home_carousel_slide(integer, text, text) from public; +grant execute on function translate_home_carousel_slide(integer, text, text) to admin; + +commit; diff --git a/pkg/app/admin.go b/pkg/app/admin.go index 6847549..0485027 100644 --- a/pkg/app/admin.go +++ b/pkg/app/admin.go @@ -12,6 +12,7 @@ import ( "dev.tandem.ws/tandem/camper/pkg/campsite" "dev.tandem.ws/tandem/camper/pkg/company" "dev.tandem.ws/tandem/camper/pkg/database" + "dev.tandem.ws/tandem/camper/pkg/home" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/season" @@ -21,6 +22,7 @@ import ( type adminHandler struct { campsite *campsite.AdminHandler company *company.AdminHandler + home *home.AdminHandler season *season.AdminHandler } @@ -28,6 +30,7 @@ func newAdminHandler(locales locale.Locales) *adminHandler { return &adminHandler{ campsite: campsite.NewAdminHandler(locales), company: company.NewAdminHandler(), + home: home.NewAdminHandler(locales), season: season.NewAdminHandler(), } } @@ -52,6 +55,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data h.campsite.Handler(user, company, conn).ServeHTTP(w, r) case "company": h.company.Handler(user, company, conn).ServeHTTP(w, r) + case "home": + h.home.Handler(user, company, conn).ServeHTTP(w, r) case "seasons": h.season.Handler(user, company, conn).ServeHTTP(w, r) case "": diff --git a/pkg/app/public.go b/pkg/app/public.go index 25ed24a..89d61a2 100644 --- a/pkg/app/public.go +++ b/pkg/app/public.go @@ -6,23 +6,23 @@ package app import ( - "context" "net/http" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/campsite" "dev.tandem.ws/tandem/camper/pkg/database" + "dev.tandem.ws/tandem/camper/pkg/home" httplib "dev.tandem.ws/tandem/camper/pkg/http" - "dev.tandem.ws/tandem/camper/pkg/locale" - "dev.tandem.ws/tandem/camper/pkg/template" ) type publicHandler struct { + home *home.PublicHandler campsite *campsite.PublicHandler } func newPublicHandler() *publicHandler { return &publicHandler{ + home: home.NewPublicHandler(), campsite: campsite.NewPublicHandler(), } } @@ -33,8 +33,7 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": - home := newHomePage() - home.MustRender(w, r, user, company, conn) + h.home.Handler(user, company, conn).ServeHTTP(w, r) case "campsites": h.campsite.Handler(user, company, conn).ServeHTTP(w, r) default: @@ -42,58 +41,3 @@ func (h *publicHandler) Handler(user *auth.User, company *auth.Company, conn *da } }) } - -type homePage struct { - *template.PublicPage - CampsiteTypes []*campsiteType -} - -func newHomePage() *homePage { - return &homePage{template.NewPublicPage(), nil} -} - -func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { - p.Setup(r, user, company, conn) - p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale) - template.MustRenderPublic(w, r, user, company, "home.gohtml", p) -} - -type campsiteType struct { - Label string - HRef string - Media string -} - -func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType { - rows, err := conn.Query(ctx, ` - select coalesce(i18n.name, campsite_type.name) as l10_name - , '/campsites/types/' || slug - , '/media/' || encode(hash, 'hex') || '/' || original_filename - from campsite_type - left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and lang_tag = $1 - join media using (media_id) - where campsite_type.company_id = $2 - and campsite_type.active - `, loc.Language, company.ID) - if err != nil { - panic(err) - } - defer rows.Close() - - localePath := "/" + loc.Language.String() - var items []*campsiteType - for rows.Next() { - item := &campsiteType{} - err = rows.Scan(&item.Label, &item.HRef, &item.Media) - if err != nil { - panic(err) - } - item.HRef = localePath + item.HRef - items = append(items, item) - } - if rows.Err() != nil { - panic(rows.Err()) - } - - return items -} diff --git a/pkg/campsite/types/admin.go b/pkg/campsite/types/admin.go index e1a64da..d2c2eb7 100644 --- a/pkg/campsite/types/admin.go +++ b/pkg/campsite/types/admin.go @@ -11,7 +11,6 @@ import ( "net/http" "github.com/jackc/pgx/v4" - "golang.org/x/text/language" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/database" @@ -66,12 +65,12 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat } panic(err) } - h.TypeHandler(user, company, conn, f).ServeHTTP(w, r) + h.typeHandler(user, company, conn, f).ServeHTTP(w, r) } }) } -func (h *AdminHandler) TypeHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) http.Handler { +func (h *AdminHandler) typeHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *typeForm) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) @@ -87,18 +86,13 @@ func (h *AdminHandler) TypeHandler(user *auth.User, company *auth.Company, conn httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } default: - tag, err := language.Parse(head) - if err != nil { - http.NotFound(w, r) - return - } - loc, ok := h.locales[tag] + loc, ok := h.locales.Get(head) if !ok { http.NotFound(w, r) return } l10n := newTypeL10nForm(f, loc) - if err = l10n.FillFromDatabase(r.Context(), conn); err != nil { + if err := l10n.FillFromDatabase(r.Context(), conn); err != nil { panic(err) } switch r.Method { @@ -267,7 +261,7 @@ func (f *typeForm) FillFromDatabase(ctx context.Context, conn *database.Conn, sl row := conn.QueryRow(ctx, ` select name , description - , encode(hash, 'hex') + , media.path , active from campsite_type join media using (media_id) diff --git a/pkg/campsite/types/l10n.go b/pkg/campsite/types/l10n.go index 5c43ea4..1528da8 100644 --- a/pkg/campsite/types/l10n.go +++ b/pkg/campsite/types/l10n.go @@ -17,34 +17,19 @@ import ( "dev.tandem.ws/tandem/camper/pkg/template" ) -type L10nInput struct { - form.Input - Source string -} - type typeL10nForm struct { Locale *locale.Locale Slug string - Name *L10nInput - Description *L10nInput + Name *form.L10nInput + Description *form.L10nInput } func newTypeL10nForm(f *typeForm, loc *locale.Locale) *typeL10nForm { return &typeL10nForm{ - Locale: loc, - Slug: f.Slug, - Name: &L10nInput{ - Input: form.Input{ - Name: f.Name.Name, - }, - Source: f.Name.Val, - }, - Description: &L10nInput{ - Input: form.Input{ - Name: f.Description.Name, - }, - Source: f.Description.Val, - }, + Locale: loc, + Slug: f.Slug, + Name: f.Name.L10nInput(), + Description: f.Description.L10nInput(), } } diff --git a/pkg/form/input.go b/pkg/form/input.go index e0713bc..3de6bf3 100644 --- a/pkg/form/input.go +++ b/pkg/form/input.go @@ -27,3 +27,17 @@ func (input *Input) FillValue(r *http.Request) { func (input *Input) Value() (driver.Value, error) { return input.Val, nil } + +func (input *Input) L10nInput() *L10nInput { + return &L10nInput{ + Input: Input{ + Name: input.Name, + }, + Source: input.Val, + } +} + +type L10nInput struct { + Input + Source string +} diff --git a/pkg/home/admin.go b/pkg/home/admin.go new file mode 100644 index 0000000..211472a --- /dev/null +++ b/pkg/home/admin.go @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package home + +import ( + "net/http" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/database" + httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/template" +) + +type AdminHandler struct { + locales locale.Locales +} + +func NewAdminHandler(locales locale.Locales) *AdminHandler { + return &AdminHandler{locales} +} + +func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) 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: + serveHomeIndex(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + case "slides": + h.carouselHandler(user, company, conn).ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +func serveHomeIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + slides, err := collectSlideEntries(r.Context(), company, conn) + if err != nil { + panic(err) + } + page := &homeIndex{ + Slides: slides, + } + page.MustRender(w, r, user, company) +} + +type homeIndex struct { + Slides []*slideEntry +} + +func (page *homeIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "home/index.gohtml", page) +} diff --git a/pkg/home/carousel.go b/pkg/home/carousel.go new file mode 100644 index 0000000..c558a58 --- /dev/null +++ b/pkg/home/carousel.go @@ -0,0 +1,311 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package home + +import ( + "context" + "io" + "net/http" + "strconv" + + "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" +) + +func (h *AdminHandler) carouselHandler(user *auth.User, company *auth.Company, conn *database.Conn) 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.MethodPost: + addSlide(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + case "new": + switch r.Method { + case http.MethodGet: + f := newSlideForm() + f.MustRender(w, r, user, company) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + default: + id, err := strconv.Atoi(head) + if err != nil { + http.NotFound(w, r) + } + f := newSlideForm() + if err := f.FillFromDatabase(r.Context(), conn, id); 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: + editSlide(w, r, user, company, conn, f) + case http.MethodDelete: + deleteSlide(w, r, user, conn, id) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) + } + default: + loc, ok := h.locales.Get(langTag) + if !ok { + http.NotFound(w, r) + return + } + l10n := newSlideL10nForm(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: + editSlideL10n(w, r, user, company, conn, l10n) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) + } + } + } + }) +} + +type carouselSlide struct { + Media string + Caption string +} + +func mustCollectCarouselSlides(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*carouselSlide { + rows, err := conn.Query(ctx, ` + select coalesce(i18n.caption, slide.caption) as l10_caption + , media.path + from home_carousel as slide + join media using (media_id) + left join home_carousel_i18n as i18n on i18n.media_id = slide.media_id and lang_tag = $1 + where media.company_id = $2 + `, loc.Language, company.ID) + if err != nil { + panic(err) + } + defer rows.Close() + + var carousel []*carouselSlide + for rows.Next() { + slide := &carouselSlide{} + err = rows.Scan(&slide.Caption, &slide.Media) + if err != nil { + panic(err) + } + carousel = append(carousel, slide) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return carousel +} + +type slideEntry struct { + carouselSlide + ID int + Translations []*translation +} + +type translation struct { + Language string + Endonym string + Missing bool +} + +func collectSlideEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*slideEntry, error) { + rows, err := conn.Query(ctx, ` + select media_id + , media.path + , caption + , array_agg((lang_tag, endonym, not exists (select 1 from home_carousel_i18n as i18n where i18n.media_id = home_carousel.media_id and i18n.lang_tag = language.lang_tag)) order by endonym) + from home_carousel + join media using (media_id) + join company using (company_id) + , language + where lang_tag <> default_lang_tag + and language.selectable + and media.company_id = $1 + group by media_id + , media.path + , caption + order by caption + `, pgx.QueryResultFormats{pgx.BinaryFormatCode}, company.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + var slides []*slideEntry + for rows.Next() { + slide := &slideEntry{} + var translations database.RecordArray + if err = rows.Scan(&slide.ID, &slide.Media, &slide.Caption, &translations); err != nil { + return nil, err + } + for _, el := range translations.Elements { + slide.Translations = append(slide.Translations, &translation{ + el.Fields[0].Get().(string), + el.Fields[1].Get().(string), + el.Fields[2].Get().(bool), + }) + } + slides = append(slides, slide) + } + + return slides, nil +} + +func addSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + f := newSlideForm() + editSlide(w, r, user, company, conn, f) +} + +func editSlide(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *slideForm) { + f.process(w, r, user, company, false, func(ctx context.Context) { + bytes := f.MustReadAllMedia() + if bytes == nil { + conn.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption) + } else { + tx := conn.MustBegin(ctx) + defer tx.Rollback(ctx) + f.ID = tx.MustGetInt(ctx, "select add_media($1, $2, $3, $4)", company.ID, f.Media.Filename(), f.Media.ContentType, bytes) + tx.MustExec(ctx, "select add_home_carousel_slide($1, $2)", f.ID, f.Caption) + tx.MustCommit(ctx) + } + }) +} + +func deleteSlide(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn, id int) { + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + conn.MustExec(r.Context(), "select remove_home_carousel_slide($1)", id) + httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) +} + +type slideForm struct { + ID int + Media *form.File + Caption *form.Input +} + +func newSlideForm() *slideForm { + return &slideForm{ + Media: &form.File{ + Name: "media", + MaxSize: 1 << 20, + }, + Caption: &form.Input{ + Name: "caption", + }, + } +} + +func (f *slideForm) FillFromDatabase(ctx context.Context, conn *database.Conn, id int) error { + f.ID = id + row := conn.QueryRow(ctx, ` + select caption + , media.path + from home_carousel + join media using (media_id) + where media_id = $1 + `, id) + return row.Scan(&f.Caption.Val, &f.Media.Val) +} + +func (f *slideForm) process(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, mediaRequired bool, act func(ctx context.Context)) { + if err := f.Parse(w, r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer f.Close() + if err := user.VerifyCSRFToken(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + if !f.Valid(user.Locale, mediaRequired) { + if !httplib.IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + f.MustRender(w, r, user, company) + return + } + act(r.Context()) + httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) +} + +func (f *slideForm) Parse(w http.ResponseWriter, r *http.Request) error { + maxSize := f.Media.MaxSize + 1024 + r.Body = http.MaxBytesReader(w, r.Body, maxSize) + if err := r.ParseMultipartForm(maxSize); err != nil { + return err + } + f.Caption.FillValue(r) + if err := f.Media.FillValue(r); err != nil { + return err + } + return nil +} + +func (f *slideForm) Close() error { + return f.Media.Close() +} + +func (f *slideForm) Valid(l *locale.Locale, mediaRequired bool) bool { + v := form.NewValidator(l) + if f.HasMediaFile() { + v.CheckImageFile(f.Media, l.GettextNoop("File must be a valid PNG or JPEG image.")) + } else { + v.Check(f.Media, !mediaRequired, l.GettextNoop("Slide image can not be empty.")) + } + return v.AllOK +} + +func (f *slideForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "home/carousel/form.gohtml", f) +} + +func (f *slideForm) HasMediaFile() bool { + return f.Media.HasData() +} + +func (f *slideForm) MustReadAllMedia() []byte { + if !f.HasMediaFile() { + return nil + } + bytes, err := io.ReadAll(f.Media) + if err != nil { + panic(err) + } + return bytes +} diff --git a/pkg/home/l10n.go b/pkg/home/l10n.go new file mode 100644 index 0000000..efe1a43 --- /dev/null +++ b/pkg/home/l10n.go @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package home + +import ( + "context" + "net/http" + + "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" +) + +type slideL10nForm struct { + Locale *locale.Locale + ID int + Caption *form.L10nInput +} + +func newSlideL10nForm(f *slideForm, loc *locale.Locale) *slideL10nForm { + return &slideL10nForm{ + Locale: loc, + ID: f.ID, + Caption: f.Caption.L10nInput(), + } +} + +func (l10n *slideL10nForm) FillFromDatabase(ctx context.Context, conn *database.Conn) error { + row := conn.QueryRow(ctx, ` + select coalesce(i18n.caption, '') as l10n_caption + from home_carousel + left join home_carousel_i18n as i18n on home_carousel.media_id = i18n.media_id and i18n.lang_tag = $1 + where home_carousel.media_id = $2 + `, l10n.Locale.Language, l10n.ID) + return row.Scan(&l10n.Caption.Val) +} + +func (l10n *slideL10nForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { + template.MustRenderAdmin(w, r, user, company, "home/carousel/l10n.gohtml", l10n) +} + +func editSlideL10n(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, l10n *slideL10nForm) { + if err := l10n.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 !l10n.Valid(user.Locale) { + if !httplib.IsHTMxRequest(r) { + w.WriteHeader(http.StatusUnprocessableEntity) + } + l10n.MustRender(w, r, user, company) + return + } + conn.MustExec(r.Context(), "select translate_home_carousel_slide($1, $2, $3)", l10n.ID, l10n.Locale.Language, l10n.Caption) + httplib.Redirect(w, r, "/admin/home", http.StatusSeeOther) +} + +func (l10n *slideL10nForm) Parse(r *http.Request) error { + if err := r.ParseForm(); err != nil { + return err + } + l10n.Caption.FillValue(r) + return nil +} + +func (l10n *slideL10nForm) Valid(l *locale.Locale) bool { + v := form.NewValidator(l) + return v.AllOK +} diff --git a/pkg/home/public.go b/pkg/home/public.go new file mode 100644 index 0000000..434ccd9 --- /dev/null +++ b/pkg/home/public.go @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package home + +import ( + "context" + "net/http" + + "dev.tandem.ws/tandem/camper/pkg/auth" + "dev.tandem.ws/tandem/camper/pkg/database" + httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/template" +) + +type PublicHandler struct { +} + +func NewPublicHandler() *PublicHandler { + return &PublicHandler{} +} + +func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + home := newHomePage() + home.MustRender(w, r, user, company, conn) + default: + httplib.MethodNotAllowed(w, r, http.MethodGet) + } + }) +} + +type homePage struct { + *template.PublicPage + CampsiteTypes []*campsiteType + Carousel []*carouselSlide +} + +func newHomePage() *homePage { + return &homePage{PublicPage: template.NewPublicPage()} +} + +func (p *homePage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { + p.Setup(r, user, company, conn) + p.CampsiteTypes = mustCollectCampsiteTypes(r.Context(), company, conn, user.Locale) + p.Carousel = mustCollectCarouselSlides(r.Context(), company, conn, user.Locale) + template.MustRenderPublic(w, r, user, company, "home.gohtml", p) +} + +type campsiteType struct { + Label string + HRef string + Media string +} + +func mustCollectCampsiteTypes(ctx context.Context, company *auth.Company, conn *database.Conn, loc *locale.Locale) []*campsiteType { + rows, err := conn.Query(ctx, ` + select coalesce(i18n.name, campsite_type.name) as l10_name + , '/campsites/types/' || slug + , media.path + from campsite_type + left join campsite_type_i18n as i18n on campsite_type.campsite_type_id = i18n.campsite_type_id and lang_tag = $1 + join media using (media_id) + where campsite_type.company_id = $2 + and campsite_type.active + `, loc.Language, company.ID) + if err != nil { + panic(err) + } + defer rows.Close() + + localePath := "/" + loc.Language.String() + var items []*campsiteType + for rows.Next() { + item := &campsiteType{} + err = rows.Scan(&item.Label, &item.HRef, &item.Media) + if err != nil { + panic(err) + } + item.HRef = localePath + item.HRef + items = append(items, item) + } + if rows.Err() != nil { + panic(rows.Err()) + } + + return items +} diff --git a/pkg/locale/locale.go b/pkg/locale/locale.go index 8a3fc92..cb7d576 100644 --- a/pkg/locale/locale.go +++ b/pkg/locale/locale.go @@ -33,6 +33,15 @@ func (m Locales) Tags() []language.Tag { return keys } +func (m Locales) Get(lang string) (loc *Locale, ok bool) { + tag, err := language.Parse(lang) + if err != nil { + return + } + loc, ok = m[tag] + return +} + func GetAll(ctx context.Context, db *database.DB) (Locales, error) { availableLanguages, err := getAvailableLanguages(ctx, db) if err != nil { diff --git a/po/ca.po b/po/ca.po index a33a3ae..b72d552 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-09-12 20:18+0200\n" +"POT-Creation-Date: 2023-09-15 00:05+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -56,15 +56,7 @@ msgstr "A menys d’una hora de Girona, a una de La Bis msgid "Discover the surroundings" msgstr "Descobreix l’entorn" -#: web/templates/public/home.gohtml:44 web/templates/public/home.gohtml:48 -#: web/templates/public/home.gohtml:52 web/templates/public/home.gohtml:56 -#: web/templates/public/home.gohtml:60 web/templates/public/home.gohtml:64 -#: web/templates/public/home.gohtml:68 web/templates/public/home.gohtml:72 -#: web/templates/public/home.gohtml:76 -msgid "Legend" -msgstr "Llegenda" - -#: web/templates/public/home.gohtml:80 +#: web/templates/public/home.gohtml:54 msgid "Come and enjoy!" msgstr "Vine a gaudir!" @@ -116,6 +108,7 @@ msgstr "Etiqueta" #: web/templates/admin/campsite/form.gohtml:71 #: web/templates/admin/campsite/type/form.gohtml:77 #: web/templates/admin/season/form.gohtml:65 +#: web/templates/admin/home/carousel/form.gohtml:58 msgctxt "action" msgid "Update" msgstr "Actualitza" @@ -123,6 +116,7 @@ msgstr "Actualitza" #: web/templates/admin/campsite/form.gohtml:73 #: web/templates/admin/campsite/type/form.gohtml:79 #: web/templates/admin/season/form.gohtml:67 +#: web/templates/admin/home/carousel/form.gohtml:60 msgctxt "action" msgid "Add" msgstr "Afegeix" @@ -192,6 +186,7 @@ msgid "Name" msgstr "Nom" #: web/templates/admin/campsite/type/form.gohtml:59 +#: web/templates/admin/home/carousel/form.gohtml:39 msgctxt "input" msgid "Cover image" msgstr "Imatge de portada" @@ -221,6 +216,7 @@ msgid "Name" msgstr "Nom" #: web/templates/admin/campsite/type/index.gohtml:19 +#: web/templates/admin/home/index.gohtml:20 msgctxt "campsite type" msgid "Translations" msgstr "Traduccions" @@ -237,16 +233,19 @@ msgstr "Traducció del tipus d’allotjament a %s" #: web/templates/admin/campsite/type/l10n.gohtml:22 #: web/templates/admin/campsite/type/l10n.gohtml:34 +#: web/templates/admin/home/carousel/l10n.gohtml:22 msgid "Source:" msgstr "Origen:" #: web/templates/admin/campsite/type/l10n.gohtml:24 #: web/templates/admin/campsite/type/l10n.gohtml:37 +#: web/templates/admin/home/carousel/l10n.gohtml:24 msgctxt "input" msgid "Translation:" msgstr "Traducció:" #: web/templates/admin/campsite/type/l10n.gohtml:46 +#: web/templates/admin/home/carousel/l10n.gohtml:33 msgctxt "action" msgid "Translate" msgstr "Tradueix" @@ -442,6 +441,69 @@ msgctxt "action" msgid "Logout" msgstr "Surt" +#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 +msgctxt "title" +msgid "Home Page" +msgstr "Pàgina d’inici" + +#: web/templates/admin/home/carousel/form.gohtml:8 +#: web/templates/admin/home/carousel/form.gohtml:27 +msgctxt "title" +msgid "Edit Carousel Slide" +msgstr "Edició de la diapositiva del carrusel" + +#: web/templates/admin/home/carousel/form.gohtml:10 +#: web/templates/admin/home/carousel/form.gohtml:29 +msgctxt "title" +msgid "New Carousel Slide" +msgstr "Nova diapositiva del carrusel" + +#: web/templates/admin/home/carousel/form.gohtml:48 +#: web/templates/admin/home/carousel/l10n.gohtml:21 +msgctxt "input" +msgid "Caption" +msgstr "Llegenda" + +#: web/templates/admin/home/carousel/l10n.gohtml:7 +#: web/templates/admin/home/carousel/l10n.gohtml:15 +msgctxt "title" +msgid "Translate Carousel Slide to %s" +msgstr "Traducció de la diapositiva del carrusel a %s" + +#: web/templates/admin/home/index.gohtml:12 +msgctxt "title" +msgid "Carousel" +msgstr "Carrusel" + +#: web/templates/admin/home/index.gohtml:13 +msgctxt "action" +msgid "Add slide" +msgstr "Afegeix diapositiva" + +#: web/templates/admin/home/index.gohtml:18 +msgctxt "header" +msgid "Image" +msgstr "Imatge" + +#: web/templates/admin/home/index.gohtml:19 +msgctxt "header" +msgid "Caption" +msgstr "Llegenda" + +#: web/templates/admin/home/index.gohtml:21 +msgctxt "campsite type" +msgid "Actions" +msgstr "Accions" + +#: web/templates/admin/home/index.gohtml:40 +msgctxt "action" +msgid "Delete" +msgstr "Esborra" + +#: web/templates/admin/home/index.gohtml:48 +msgid "No slides added yet." +msgstr "No s’ha afegit cap diapositiva encara." + #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 msgid "Email can not be empty." msgstr "No podeu deixar el correu-e en blanc." @@ -463,8 +525,8 @@ msgctxt "language option" msgid "Automatic" msgstr "Automàtic" -#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105 -#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203 +#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:90 +#: pkg/campsite/types/admin.go:293 pkg/season/admin.go:203 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." @@ -476,15 +538,15 @@ msgstr "La confirmació no es correspon amb la contrasenya." msgid "Selected language is not valid." msgstr "L’idioma escollit no és vàlid." -#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303 +#: pkg/app/user.go:253 pkg/campsite/types/admin.go:295 pkg/home/carousel.go:286 msgid "File must be a valid PNG or JPEG image." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." -#: pkg/app/admin.go:44 +#: pkg/app/admin.go:47 msgid "Access forbidden" msgstr "Accés prohibit" -#: pkg/campsite/types/admin.go:305 +#: pkg/campsite/types/admin.go:297 msgid "Cover image can not be empty." msgstr "No podeu deixar la imatge de portada en blanc." @@ -568,13 +630,16 @@ msgstr "No podeu deixar el format del número de factura en blanc." msgid "Cross-site request forgery detected." msgstr "S’ha detectat un intent de falsificació de petició a llocs creuats." +#: pkg/home/carousel.go:288 +msgid "Slide image can not be empty." +msgstr "No podeu deixar la imatge de la diapositiva en blanc." + +#~ msgid "Legend" +#~ msgstr "Llegenda" + #~ msgid "Environment" #~ msgstr "Entorn" -#~ msgctxt "title" -#~ msgid "New Page" -#~ msgstr "Nova pàgina" - #~ msgctxt "input" #~ msgid "Title" #~ msgstr "Títol" diff --git a/po/es.po b/po/es.po index 1dfec60..0bf06d6 100644 --- a/po/es.po +++ b/po/es.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-09-12 20:18+0200\n" +"POT-Creation-Date: 2023-09-15 00:05+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -56,15 +56,7 @@ msgstr "A menos de una hora de Girona, a una de La Bisb msgid "Discover the surroundings" msgstr "Descubre el entorno" -#: web/templates/public/home.gohtml:44 web/templates/public/home.gohtml:48 -#: web/templates/public/home.gohtml:52 web/templates/public/home.gohtml:56 -#: web/templates/public/home.gohtml:60 web/templates/public/home.gohtml:64 -#: web/templates/public/home.gohtml:68 web/templates/public/home.gohtml:72 -#: web/templates/public/home.gohtml:76 -msgid "Legend" -msgstr "Leyenda" - -#: web/templates/public/home.gohtml:80 +#: web/templates/public/home.gohtml:54 msgid "Come and enjoy!" msgstr "¡Ven a disfrutar!" @@ -116,6 +108,7 @@ msgstr "Etiqueta" #: web/templates/admin/campsite/form.gohtml:71 #: web/templates/admin/campsite/type/form.gohtml:77 #: web/templates/admin/season/form.gohtml:65 +#: web/templates/admin/home/carousel/form.gohtml:58 msgctxt "action" msgid "Update" msgstr "Actualizar" @@ -123,6 +116,7 @@ msgstr "Actualizar" #: web/templates/admin/campsite/form.gohtml:73 #: web/templates/admin/campsite/type/form.gohtml:79 #: web/templates/admin/season/form.gohtml:67 +#: web/templates/admin/home/carousel/form.gohtml:60 msgctxt "action" msgid "Add" msgstr "Añadir" @@ -192,6 +186,7 @@ msgid "Name" msgstr "Nombre" #: web/templates/admin/campsite/type/form.gohtml:59 +#: web/templates/admin/home/carousel/form.gohtml:39 msgctxt "input" msgid "Cover image" msgstr "Imagen de portada" @@ -221,6 +216,7 @@ msgid "Name" msgstr "Nombre" #: web/templates/admin/campsite/type/index.gohtml:19 +#: web/templates/admin/home/index.gohtml:20 msgctxt "campsite type" msgid "Translations" msgstr "Traducciones" @@ -237,16 +233,19 @@ msgstr "Traducción de tipo de alojamiento a %s" #: web/templates/admin/campsite/type/l10n.gohtml:22 #: web/templates/admin/campsite/type/l10n.gohtml:34 +#: web/templates/admin/home/carousel/l10n.gohtml:22 msgid "Source:" msgstr "Origen:" #: web/templates/admin/campsite/type/l10n.gohtml:24 #: web/templates/admin/campsite/type/l10n.gohtml:37 +#: web/templates/admin/home/carousel/l10n.gohtml:24 msgctxt "input" msgid "Translation:" msgstr "Traducción" #: web/templates/admin/campsite/type/l10n.gohtml:46 +#: web/templates/admin/home/carousel/l10n.gohtml:33 msgctxt "action" msgid "Translate" msgstr "Traducir" @@ -442,6 +441,69 @@ msgctxt "action" msgid "Logout" msgstr "Salir" +#: web/templates/admin/layout.gohtml:76 web/templates/admin/home/index.gohtml:6 +msgctxt "title" +msgid "Home Page" +msgstr "Página de inicio" + +#: web/templates/admin/home/carousel/form.gohtml:8 +#: web/templates/admin/home/carousel/form.gohtml:27 +msgctxt "title" +msgid "Edit Carousel Slide" +msgstr "Edición de la diapositiva del carrusel" + +#: web/templates/admin/home/carousel/form.gohtml:10 +#: web/templates/admin/home/carousel/form.gohtml:29 +msgctxt "title" +msgid "New Carousel Slide" +msgstr "Nueva diapositiva del carrusel" + +#: web/templates/admin/home/carousel/form.gohtml:48 +#: web/templates/admin/home/carousel/l10n.gohtml:21 +msgctxt "input" +msgid "Caption" +msgstr "Leyenda" + +#: web/templates/admin/home/carousel/l10n.gohtml:7 +#: web/templates/admin/home/carousel/l10n.gohtml:15 +msgctxt "title" +msgid "Translate Carousel Slide to %s" +msgstr "Traducción de la diapositiva de carrusel a %s" + +#: web/templates/admin/home/index.gohtml:12 +msgctxt "title" +msgid "Carousel" +msgstr "Carrusel" + +#: web/templates/admin/home/index.gohtml:13 +msgctxt "action" +msgid "Add slide" +msgstr "Añadir diapositiva" + +#: web/templates/admin/home/index.gohtml:18 +msgctxt "header" +msgid "Image" +msgstr "Imagen" + +#: web/templates/admin/home/index.gohtml:19 +msgctxt "header" +msgid "Caption" +msgstr "Leyenda" + +#: web/templates/admin/home/index.gohtml:21 +msgctxt "campsite type" +msgid "Actions" +msgstr "Acciones" + +#: web/templates/admin/home/index.gohtml:40 +msgctxt "action" +msgid "Delete" +msgstr "Borrar" + +#: web/templates/admin/home/index.gohtml:48 +msgid "No slides added yet." +msgstr "No se ha añadido ninguna diapositiva todavía." + #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203 msgid "Email can not be empty." msgstr "No podéis dejar el correo-e en blanco." @@ -463,8 +525,8 @@ msgctxt "language option" msgid "Automatic" msgstr "Automático" -#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:105 -#: pkg/campsite/types/admin.go:301 pkg/season/admin.go:203 +#: pkg/app/user.go:249 pkg/campsite/types/l10n.go:90 +#: pkg/campsite/types/admin.go:293 pkg/season/admin.go:203 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." @@ -476,15 +538,15 @@ msgstr "La confirmación no se corresponde con la contraseña." msgid "Selected language is not valid." msgstr "El idioma escogido no es válido." -#: pkg/app/user.go:253 pkg/campsite/types/admin.go:303 +#: pkg/app/user.go:253 pkg/campsite/types/admin.go:295 pkg/home/carousel.go:286 msgid "File must be a valid PNG or JPEG image." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." -#: pkg/app/admin.go:44 +#: pkg/app/admin.go:47 msgid "Access forbidden" msgstr "Acceso prohibido" -#: pkg/campsite/types/admin.go:305 +#: pkg/campsite/types/admin.go:297 msgid "Cover image can not be empty." msgstr "No podéis dejar la imagen de portada en blanco." @@ -568,13 +630,16 @@ msgstr "No podéis dejar el formato de número de factura en blanco." msgid "Cross-site request forgery detected." msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados." +#: pkg/home/carousel.go:288 +msgid "Slide image can not be empty." +msgstr "No podéis dejar la imagen de la diapositiva en blanco." + +#~ msgid "Legend" +#~ msgstr "Leyenda" + #~ msgid "Environment" #~ msgstr "Entorno" -#~ msgctxt "title" -#~ msgid "New Page" -#~ msgstr "Nueva página" - #~ msgctxt "input" #~ msgid "Title" #~ msgstr "Título" diff --git a/revert/add_home_carousel_slide.sql b/revert/add_home_carousel_slide.sql new file mode 100644 index 0000000..2075319 --- /dev/null +++ b/revert/add_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Revert camper:add_home_carousel_slide from pg + +begin; + +drop function if exists camper.add_home_carousel_slide(integer, text); + +commit; diff --git a/revert/home_carousel.sql b/revert/home_carousel.sql new file mode 100644 index 0000000..185d62e --- /dev/null +++ b/revert/home_carousel.sql @@ -0,0 +1,7 @@ +-- Revert camper:home_carousel from pg + +begin; + +drop table if exists camper.home_carousel; + +commit; diff --git a/revert/home_carousel_i18n.sql b/revert/home_carousel_i18n.sql new file mode 100644 index 0000000..67407aa --- /dev/null +++ b/revert/home_carousel_i18n.sql @@ -0,0 +1,7 @@ +-- Revert camper:home_carousel_i18n from pg + +begin; + +drop table if exists camper.home_carousel_i18n; + +commit; diff --git a/revert/media_path.sql b/revert/media_path.sql new file mode 100644 index 0000000..bea5dca --- /dev/null +++ b/revert/media_path.sql @@ -0,0 +1,7 @@ +-- Revert camper:media_path from pg + +begin; + +drop function if exists camper.path(camper.media); + +commit; diff --git a/revert/remove_home_carousel_slide.sql b/revert/remove_home_carousel_slide.sql new file mode 100644 index 0000000..5b1e063 --- /dev/null +++ b/revert/remove_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Revert camper:remove_home_carousel_slide from pg + +begin; + +drop function if exists camper.remove_home_carousel_slide(integer); + +commit; diff --git a/revert/translate_home_carousel_slide.sql b/revert/translate_home_carousel_slide.sql new file mode 100644 index 0000000..b9b252d --- /dev/null +++ b/revert/translate_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Revert camper:translate_home_carousel_slide from pg + +begin; + +drop function if exists camper.translate_home_carousel_slide(integer, text, text); + +commit; diff --git a/sqitch.plan b/sqitch.plan index c127b6b..7894401 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -42,6 +42,7 @@ change_password [roles schema_auth schema_camper user] 2023-07-21T23:54:52Z jord media_type [schema_camper] 2023-09-08T17:17:02Z jordi fita mas # Add domain for media type media [roles schema_camper company user_profile media_type] 2023-09-08T16:50:55Z jordi fita mas # Add relation of uploaded media add_media [roles schema_camper media media_type] 2023-09-08T17:40:28Z jordi fita mas # Add function to create media +media_path [roles schema_camper media] 2023-09-13T22:50:14Z jordi fita mas # Add function to get the URL path of a media campsite_type [roles schema_camper company media user_profile] 2023-07-31T11:20:29Z jordi fita mas # Add relation of campsite type campsite_type_i18n [roles schema_camper campsite_type language] 2023-09-12T10:31:29Z jordi fita mas # Add relation for campsite_type translations add_campsite_type [roles schema_camper campsite_type company] 2023-08-04T16:14:48Z jordi fita mas # Add function to create campsite types @@ -57,3 +58,8 @@ to_color [roles schema_camper color] 2023-08-16T13:11:32Z jordi fita mas # Add relation of (tourist) season add_season [roles schema_camper season color to_integer] 2023-08-16T16:59:17Z jordi fita mas # Add function to create seasons edit_season [roles schema_camper season color to_integer] 2023-08-16T17:09:02Z jordi fita mas # Add function to update seasons +home_carousel [roles schema_public company media user_profile] 2023-09-13T17:16:34Z jordi fita mas # Add relation for home page’s image carousel +home_carousel_i18n [roles schema_camper home_carousel language] 2023-09-13T23:22:42Z jordi fita mas # Add relation for home carousel translations +add_home_carousel_slide [roles schema_camper home_carousel] 2023-09-14T17:49:21Z jordi fita mas # Add function to create slides for the home carousel +translate_home_carousel_slide [roles schema_camper home_carousel_i18n] 2023-09-14T18:17:36Z jordi fita mas # Add function to translate a home carousel slider +remove_home_carousel_slide [roles schema_camper home_carousel home_carousel_i18n] 2023-09-14T21:57:48Z jordi fita mas # Add function to remove sliders from the home carousel diff --git a/test/add_home_carousel_slide.sql b/test/add_home_carousel_slide.sql new file mode 100644 index 0000000..fac1178 --- /dev/null +++ b/test/add_home_carousel_slide.sql @@ -0,0 +1,75 @@ +-- Test add_home_carousel_slide +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +set search_path to camper, public; + +select plan(14); + +select has_function('camper', 'add_home_carousel_slide', array['integer', 'text']); +select function_lang_is('camper', 'add_home_carousel_slide', array['integer', 'text'], 'sql'); +select function_returns('camper', 'add_home_carousel_slide', array['integer', 'text'], 'integer'); +select isnt_definer('camper', 'add_home_carousel_slide', array['integer', 'text']); +select volatility_is('camper', 'add_home_carousel_slide', array['integer', 'text'], 'volatile'); +select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'guest', array[]::text[]); +select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'employee', array[]::text[]); +select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'add_home_carousel_slide', array['integer', 'text'], 'authenticator', array[]::text[]); + +set client_min_messages to warning; +truncate home_carousel_i18n cascade; +truncate home_carousel cascade; +truncate media 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 (media_id, company_id, original_filename, media_type, content) +values (5, 1, 'text.txt', 'text/plain', 'hello, world!') + , (6, 1, 'image.svg', 'image/svg+xml', '') + , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into home_carousel (media_id, caption) +values (5, 'Previous caption') +; + +select lives_ok( + $$ select add_home_carousel_slide(6, 'A caption') $$, + 'Should be able to add a carousel slide with a caption' +); + +select lives_ok( + $$ select add_home_carousel_slide(7, null) $$, + 'Should be able to add a carousel slide without caption' +); + +select lives_ok( + $$ select add_home_carousel_slide(5, 'New caption') $$, + 'Should be able to overwrite a slide with a new caption' +); + +select bag_eq( + $$ select media_id, caption from home_carousel $$, + $$ values (5, 'New caption') + , (6, 'A caption') + , (7, '') + $$, + 'Should have all three slides' +); + +select is_empty( + $$ select * from home_carousel_i18n $$, + 'Should not have added any translation' +); + +select * +from finish(); + +rollback; diff --git a/test/home_carousel.sql b/test/home_carousel.sql new file mode 100644 index 0000000..5d3f9f1 --- /dev/null +++ b/test/home_carousel.sql @@ -0,0 +1,178 @@ +-- Test home_carousel +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(30); + +set search_path to camper, public; + +select has_table('home_carousel'); +select has_pk('home_carousel'); +select table_privs_are('home_carousel', 'guest', array['SELECT']); +select table_privs_are('home_carousel', 'employee', array['SELECT']); +select table_privs_are('home_carousel', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('home_carousel', 'authenticator', array[]::text[]); + +select has_column('home_carousel', 'media_id'); +select col_is_pk('home_carousel', 'media_id'); +select col_is_fk('home_carousel', 'media_id'); +select fk_ok('home_carousel', 'media_id', 'media', 'media_id'); +select col_type_is('home_carousel', 'media_id', 'integer'); +select col_not_null('home_carousel', 'media_id'); +select col_hasnt_default('home_carousel', 'media_id'); + +select has_column('home_carousel', 'caption'); +select col_type_is('home_carousel', 'caption', 'text'); +select col_not_null('home_carousel', 'caption'); +select col_hasnt_default('home_carousel', 'caption'); + +set client_min_messages to warning; +truncate home_carousel cascade; +truncate media 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 (media_id, company_id, original_filename, media_type, content) +values ( 7, 2, 'text2.txt', 'text/plain', 'content2') + , ( 8, 2, 'text3.txt', 'text/plain', 'content3') + , ( 9, 4, 'text4.txt', 'text/plain', 'content4') + , (10, 4, 'text5.txt', 'text/plain', 'content5') +; + +insert into home_carousel (media_id, caption) +values (7, 'Caption 7') + , (9, 'Caption 9') +; + +prepare carousel_data as +select media_id, caption +from home_carousel +order by media_id, caption; + +set role guest; +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (9, 'Caption 9') + $$, + 'Everyone should be able to list all carousel media across all companies' +); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog', 'co2'); + +select lives_ok( + $$ insert into home_carousel(media_id, caption) + values (8, 'Caption 8') $$, + 'Admin from company 2 should be able to insert a new carousel media to that company.' +); + +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (8, 'Caption 8') + , (9, 'Caption 9') + $$, + 'The new row should have been added' +); + +select lives_ok( + $$ update home_carousel set caption = 'Caption 8.8' where media_id = 8 $$, + 'Admin from company 2 should be able to update carousel media of that company.' +); + +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (8, 'Caption 8.8') + , (9, 'Caption 9') + $$, + 'The row should have been updated.' +); + +select lives_ok( + $$ delete from home_carousel where media_id = 8 $$, + 'Admin from company 2 should be able to delete carousel media from that company.' +); + +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (9, 'Caption 9') + $$, + 'The row should have been deleted.' +); + +select throws_ok( + $$ insert into home_carousel (media_id, caption) + values (10, 'Caption 10') $$, + '42501', 'new row violates row-level security policy for table "home_carousel"', + 'Admin from company 2 should NOT be able to insert new media to company 4.' +); + +select lives_ok( + $$ update home_carousel set caption = 'Nope' where media_id = 9 $$, + 'Admin from company 2 should not be able to update new carousel media of company 4, but no error if media_id is not changed.' +); + +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (9, 'Caption 9') + $$, + 'No row should have been changed.' +); + +select throws_ok( + $$ update home_carousel set media_id = 10 where media_id = 7 $$, + '42501', 'new row violates row-level security policy for table "home_carousel"', + 'Admin from company 2 should NOT be able to move carousel media to company 4' +); + +select lives_ok( + $$ delete from home_carousel where media_id = 9 $$, + 'Admin from company 2 should NOT be able to delete carousel media from company 4, but no error is thrown' +); + +select bag_eq( + 'carousel_data', + $$ values (7, 'Caption 7') + , (9, 'Caption 9') + $$, + 'No row should have been changed' +); + +reset role; + +select * +from finish(); + +rollback; + diff --git a/test/home_carousel_i18n.sql b/test/home_carousel_i18n.sql new file mode 100644 index 0000000..c76d367 --- /dev/null +++ b/test/home_carousel_i18n.sql @@ -0,0 +1,44 @@ +-- Test home_carousel_i18n +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(23); + +set search_path to camper, public; + +select has_table('home_carousel_i18n'); +select has_pk('home_carousel_i18n'); +select col_is_pk('home_carousel_i18n', array['media_id', 'lang_tag']); +select table_privs_are('home_carousel_i18n', 'guest', array['SELECT']); +select table_privs_are('home_carousel_i18n', 'employee', array['SELECT']); +select table_privs_are('home_carousel_i18n', 'admin', array['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('home_carousel_i18n', 'authenticator', array[]::text[]); + +select has_column('home_carousel_i18n', 'media_id'); +select col_is_fk('home_carousel_i18n', 'media_id'); +select fk_ok('home_carousel_i18n', 'media_id', 'home_carousel', 'media_id'); +select col_type_is('home_carousel_i18n', 'media_id', 'integer'); +select col_not_null('home_carousel_i18n', 'media_id'); +select col_hasnt_default('home_carousel_i18n', 'media_id'); + +select has_column('home_carousel_i18n', 'lang_tag'); +select col_is_fk('home_carousel_i18n', 'lang_tag'); +select fk_ok('home_carousel_i18n', 'lang_tag', 'language', 'lang_tag'); +select col_type_is('home_carousel_i18n', 'lang_tag', 'text'); +select col_not_null('home_carousel_i18n', 'lang_tag'); +select col_hasnt_default('home_carousel_i18n', 'lang_tag'); + +select has_column('home_carousel_i18n', 'caption'); +select col_type_is('home_carousel_i18n', 'caption', 'text'); +select col_not_null('home_carousel_i18n', 'caption'); +select col_hasnt_default('home_carousel_i18n', 'caption'); + + +select * +from finish(); + +rollback; + diff --git a/test/media_path.sql b/test/media_path.sql new file mode 100644 index 0000000..60c31ee --- /dev/null +++ b/test/media_path.sql @@ -0,0 +1,53 @@ +-- Test media_path +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(10); + +set search_path to camper, public; + +select has_function('camper', 'path', array['media']); +select function_lang_is('camper', 'path', array['media'], 'sql'); +select function_returns('camper', 'path', array['media'], 'text'); +select isnt_definer('camper', 'path', array['media']); +select volatility_is('camper', 'path', array['media'], 'stable'); +select function_privs_are('camper', 'path', array['media'], 'guest', array['EXECUTE']); +select function_privs_are('camper', 'path', array['media'], 'employee', array['EXECUTE']); +select function_privs_are('camper', 'path', array['media'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'path', array['media'], 'authenticator', array[]::text[]); + +set client_min_messages to warning; +truncate media 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 (media_id, company_id, original_filename, media_type, content) +values (3, 1, 'cover1.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ff0000","a"};') + , (4, 1, 'cover2.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","b c #00ff00","b"};') + , (5, 1, 'cover3.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","c c #0000ff","c"};') + , (6, 1, 'image.svg', 'image/svg+xml', '') + , (7, 1, 'text.txt', 'text/plain', 'hello, world!') +; + +select bag_eq( + $$ select media_id, media.path from media order by media_id $$, + $$ values (3, '/media/fbbac389f80d12ef13a3dd6e5dec9541622b7f870f22cfa94c3ca374d6dbc6b4/cover1.xpm') + , (4, '/media/b1ac118723104963266ceff35cc5170802885412d1bc6ea247981c15eb4b00b1/cover2.xpm') + , (5, '/media/72ccd5c651416d714740715035e0e099c18785ce9f9a8f4222bfb72f663b1c18/cover3.xpm') + , (6, '/media/ffc9f5e4fdeea83920c171e2bd17577127c5d1a2c3c76f07440e10d387132280/image.svg') + , (7, '/media/68e656b251e67e8358bef8483ab0d51c6619f3e7a1a9f0e75838d41ff368f728/text.txt') + $$, + 'Should give out the URL path for each media' +); + +select * +from finish(); + +rollback; diff --git a/test/remove_home_carousel_slide.sql b/test/remove_home_carousel_slide.sql new file mode 100644 index 0000000..90fe059 --- /dev/null +++ b/test/remove_home_carousel_slide.sql @@ -0,0 +1,80 @@ +-- Test remove_home_carousel_slide +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', 'remove_home_carousel_slide', array['integer']); +select function_lang_is('camper', 'remove_home_carousel_slide', array['integer'], 'sql'); +select function_returns('camper', 'remove_home_carousel_slide', array['integer'], 'void'); +select isnt_definer('camper', 'remove_home_carousel_slide', array['integer']); +select volatility_is('camper', 'remove_home_carousel_slide', array['integer'], 'volatile'); +select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'guest', array[]::text[]); +select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'employee', array[]::text[]); +select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'remove_home_carousel_slide', array['integer'], 'authenticator', array[]::text[]); + +set client_min_messages to warning; +truncate home_carousel_i18n cascade; +truncate home_carousel cascade; +truncate media 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 (media_id, company_id, original_filename, media_type, content) +values (5, 1, 'text.txt', 'text/plain', 'hello, world!') + , (6, 1, 'image.svg', 'image/svg+xml', '') + , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into home_carousel (media_id, caption) +values (5, 'Source caption') + , (6, 'Another caption') + , (7, 'N/A') +; + +insert into home_carousel_i18n (media_id, lang_tag, caption) +values (5, 'en', 'Target caption') + , (5, 'es', 'Target caption (spanish)') + , (6, 'en', 'Target caption') + , (6, 'es', 'Target caption (spanish)') + , (7, 'en', 'Target caption') + , (7, 'es', 'Target caption (spanish)') +; + +select lives_ok( + $$ select remove_home_carousel_slide(6) $$, + 'Should be able to delete a slide' +); + +select bag_eq( + $$ select media_id, caption from home_carousel $$, + $$ values (5, 'Source caption') + , (7, 'N/A') + $$, + 'Should have removed the slide' +); + +select bag_eq( + $$ select media_id, lang_tag, caption from home_carousel_i18n $$, + $$ values (5, 'en', 'Target caption') + , (5, 'es', 'Target caption (spanish)') + , (7, 'en', 'Target caption') + , (7, 'es', 'Target caption (spanish)') + $$, + 'Should have removed the slide’s translations' +); + +select * +from finish(); + +rollback; diff --git a/test/translate_home_carousel_slide.sql b/test/translate_home_carousel_slide.sql new file mode 100644 index 0000000..6ab4be0 --- /dev/null +++ b/test/translate_home_carousel_slide.sql @@ -0,0 +1,78 @@ +-- Test translate_home_carousel_slide +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', 'translate_home_carousel_slide', array['integer', 'text', 'text']); +select function_lang_is('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'sql'); +select function_returns('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'void'); +select isnt_definer('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text']); +select volatility_is('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'volatile'); +select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'guest', array[]::text[]); +select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'employee', array[]::text[]); +select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'admin', array['EXECUTE']); +select function_privs_are('camper', 'translate_home_carousel_slide', array['integer', 'text', 'text'], 'authenticator', array[]::text[]); + + +set client_min_messages to warning; +truncate home_carousel_i18n cascade; +truncate home_carousel cascade; +truncate media 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 (media_id, company_id, original_filename, media_type, content) +values (5, 1, 'text.txt', 'text/plain', 'hello, world!') + , (6, 1, 'image.svg', 'image/svg+xml', '') + , (7, 1, 'cover4.xpm', 'image/x-xpixmap', 'static char *s[]={"1 1 1 1","a c #ffffff","a"};') +; + +insert into home_carousel (media_id, caption) +values (5, 'Source caption') + , (6, 'Another caption') + , (7, 'N/A') +; + +insert into home_carousel_i18n (media_id, lang_tag, caption) +values (5, 'en', 'Target caption') +; + +select lives_ok( + $$ select translate_home_carousel_slide(5, 'ca', 'Traducció') $$, + 'Should be able to translate a carousel slide' +); + +select lives_ok( + $$ select translate_home_carousel_slide(6, 'es', null) $$, + 'Should be able to “translate” a carousel slide to the empty string' +); + +select lives_ok( + $$ select translate_home_carousel_slide(5, 'en', 'Not anymore') $$, + 'Should be able to overwrite a slide’s translation' +); + +select bag_eq( + $$ select media_id, lang_tag, caption from home_carousel_i18n $$, + $$ values (5, 'ca', 'Traducció') + , (5, 'en', 'Not anymore') + , (6, 'es', '') + $$, + 'Should have all three slides' +); + + +select * +from finish(); + +rollback; diff --git a/verify/add_home_carousel_slide.sql b/verify/add_home_carousel_slide.sql new file mode 100644 index 0000000..4fb6d5f --- /dev/null +++ b/verify/add_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Verify camper:add_home_carousel_slide on pg + +begin; + +select has_function_privilege('camper.add_home_carousel_slide(integer, text)', 'execute'); + +rollback; diff --git a/verify/home_carousel.sql b/verify/home_carousel.sql new file mode 100644 index 0000000..2a8efc4 --- /dev/null +++ b/verify/home_carousel.sql @@ -0,0 +1,16 @@ +-- Verify camper:home_carousel on pg + +begin; + +select media_id + , caption +from camper.home_carousel +where false; + +select 1 / count(*) from pg_class where oid = 'camper.home_carousel'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'guest_ok' and polrelid = 'camper.home_carousel'::regclass; +select 1 / count(*) from pg_policy where polname = 'insert_to_company' and polrelid = 'camper.home_carousel'::regclass; +select 1 / count(*) from pg_policy where polname = 'update_company' and polrelid = 'camper.home_carousel'::regclass; +select 1 / count(*) from pg_policy where polname = 'delete_from_company' and polrelid = 'camper.home_carousel'::regclass; + +rollback; diff --git a/verify/home_carousel_i18n.sql b/verify/home_carousel_i18n.sql new file mode 100644 index 0000000..0388e50 --- /dev/null +++ b/verify/home_carousel_i18n.sql @@ -0,0 +1,11 @@ +-- Verify camper:home_carousel_i18n on pg + +begin; + +select media_id + , lang_tag + , caption +from camper.home_carousel_i18n +where false; + +rollback; diff --git a/verify/media_path.sql b/verify/media_path.sql new file mode 100644 index 0000000..13ad59b --- /dev/null +++ b/verify/media_path.sql @@ -0,0 +1,7 @@ +-- Verify camper:media_path on pg + +begin; + +select has_function_privilege('camper.path(camper.media)', 'execute'); + +rollback; diff --git a/verify/remove_home_carousel_slide.sql b/verify/remove_home_carousel_slide.sql new file mode 100644 index 0000000..6698a43 --- /dev/null +++ b/verify/remove_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Verify camper:remove_home_carousel_slide on pg + +begin; + +select has_function_privilege('camper.remove_home_carousel_slide(integer)', 'execute'); + +rollback; diff --git a/verify/translate_home_carousel_slide.sql b/verify/translate_home_carousel_slide.sql new file mode 100644 index 0000000..62b4f54 --- /dev/null +++ b/verify/translate_home_carousel_slide.sql @@ -0,0 +1,7 @@ +-- Verify camper:translate_home_carousel_slide on pg + +begin; + +select has_function_privilege('camper.translate_home_carousel_slide(integer, text, text)', 'execute'); + +rollback; diff --git a/web/static/public.css b/web/static/public.css index db0305a..0aaf625 100644 --- a/web/static/public.css +++ b/web/static/public.css @@ -432,12 +432,12 @@ nav .has-submenu:hover ul, nav .has-submenu:focus-within ul { margin-bottom: 2rem; } -.surroundings figure { +.surroundings figure, .surroundings .slick-track > img { margin-right: 5rem; position: relative; } -.surroundings figure img { +.surroundings img { height: 40rem; width: 100%; border-radius: 5px; diff --git a/web/templates/admin/campsite/type/form.gohtml b/web/templates/admin/campsite/type/form.gohtml index 5894b77..221a2ab 100644 --- a/web/templates/admin/campsite/type/form.gohtml +++ b/web/templates/admin/campsite/type/form.gohtml @@ -53,7 +53,7 @@ {{- end }} {{ with .Media -}} {{ if .Val -}} - + {{- end }}