From a1f70ff654aade7c824e62565765745c46225d16 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Sun, 26 Mar 2023 13:51:57 +0200 Subject: [PATCH] Add tags for products too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Oriol we agreed that products should have tags, too, and that the “tag pool”, as it were, should be shared with the one for invoices and contacts. Had to add the `company_id` attribute in the `using` clause for `tag` in `MustFillFromDatabase`, even though it’s not strictly necessary, because then PostgreSQL does not know which `company_id` attribute use for the join with `company`—the one from `product` or the one from `tag`. --- demo/demo.sql | 14 ++-- deploy/add_product.sql | 12 ++- deploy/edit_product.sql | 17 ++-- deploy/product_tag.sql | 31 +++++++ deploy/tag_product.sql | 22 +++++ pkg/products.go | 66 +++++++++++++-- revert/add_product.sql | 2 +- revert/edit_product.sql | 2 +- revert/product_tag.sql | 7 ++ revert/tag_product.sql | 7 ++ sqitch.plan | 12 +-- test/add_contact.sql | 2 + test/add_product.sql | 47 ++++++++--- test/edit_product.sql | 63 ++++++++++---- test/product_tag.sql | 129 +++++++++++++++++++++++++++++ test/tag_product.sql | 124 +++++++++++++++++++++++++++ verify/add_product.sql | 2 +- verify/edit_product.sql | 2 +- verify/product_tag.sql | 13 +++ verify/tag_product.sql | 7 ++ web/template/products/edit.gohtml | 1 + web/template/products/index.gohtml | 7 ++ web/template/products/new.gohtml | 1 + 23 files changed, 529 insertions(+), 61 deletions(-) create mode 100644 deploy/product_tag.sql create mode 100644 deploy/tag_product.sql create mode 100644 revert/product_tag.sql create mode 100644 revert/tag_product.sql create mode 100644 test/product_tag.sql create mode 100644 test/tag_product.sql create mode 100644 verify/product_tag.sql create mode 100644 verify/tag_product.sql diff --git a/demo/demo.sql b/demo/demo.sql index 4e0b066..5296d7e 100644 --- a/demo/demo.sql +++ b/demo/demo.sql @@ -51,13 +51,13 @@ select add_contact (1, 'Bou', '41414142C', '', '333 333 333', 'bou@pesebre.cat', select add_contact (1, 'Rabadà', '41414143K', '', '444 444 444', 'rabada@pesebre.cat', '', 'C/ De les Ovelles, 6', 'Fornells de la Selva', 'Girona', '17458', 'ES', array['pesebre', 'persona']); alter sequence product_product_id_seq restart; -select add_product(1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.', '55.92', array[2]); -select add_product(1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[2]); -select add_product(1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[2]); -select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que s’usa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4]); -select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[2]); -select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3]); -select add_product(1, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.', '7.00', array[2]); +select add_product(1, 'Or', 'Metall de transició tou, brillant, groc, pesant, mal·leable, dúctil i que no reacciona amb la majoria de productes químics, però és sensible al clor i a l’aigua règia.', '55.92', array[2], array['metall']); +select add_product(1, 'Encens', 'Goma resina fragrant que desprèn una olor característica quan es crema.', '2.15', array[2], array['resina']); +select add_product(1, 'Mirra', 'Goma resinosa aromàtica de color gris groguenc i gust amargant.', '6.90', array[2], array['resina']); +select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que s’usa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4], array['necessitat']); +select add_product(1, 'Cavall Fort', 'Revista quinzenal en llengua catalana i de còmic en català, destinada a infants i joves.', '3.64', array[2], array['revista']); +select add_product(1, 'Palla', 'Tija seca dels cereals després que el gra o llavor ha estat separat mitjançant la trilla.', '25.00', array[3], array['necessitat']); +select add_product(1, 'Teia', 'Fusta resinosa de pi i d’altres arbres, provinent sobretot del cor de l’arbre, que crema amb molta facilitat.', '7.00', array[2], array['obsolet']); alter sequence invoice_invoice_id_seq restart; alter sequence invoice_product_invoice_product_id_seq restart; diff --git a/deploy/add_product.sql b/deploy/add_product.sql index c3910af..fdb205b 100644 --- a/deploy/add_product.sql +++ b/deploy/add_product.sql @@ -5,12 +5,14 @@ -- requires: parse_price -- requires: company -- requires: currency +-- requires: tag_name +-- requires: tag_product begin; set search_path to numerus, public; -create or replace function add_product(company_id integer, name text, description text, price text, taxes integer[]) returns uuid +create or replace function add_product(company_id integer, name text, description text, price text, taxes integer[], tags tag_name[]) returns uuid as $$ declare pid integer; @@ -28,12 +30,14 @@ begin select pid, tax_id from unnest(taxes) as tax(tax_id); + perform tag_product(company_id, pid, tags); + return pslug; end; $$ language plpgsql; -revoke execute on function add_product(integer, text, text, text, integer[]) from public; -grant execute on function add_product(integer, text, text, text, integer[]) to invoicer; -grant execute on function add_product(integer, text, text, text, integer[]) to admin; +revoke execute on function add_product(integer, text, text, text, integer[], tag_name[]) from public; +grant execute on function add_product(integer, text, text, text, integer[], tag_name[]) to invoicer; +grant execute on function add_product(integer, text, text, text, integer[], tag_name[]) to admin; commit; diff --git a/deploy/edit_product.sql b/deploy/edit_product.sql index e15b4cd..56f298e 100644 --- a/deploy/edit_product.sql +++ b/deploy/edit_product.sql @@ -5,15 +5,18 @@ -- requires: parse_price -- requires: company -- requires: currency +-- requires: tag_name +-- requires: tag_product begin; set search_path to numerus, public; -create or replace function edit_product(slug uuid, name text, description text, price text, taxes integer[]) returns boolean +create or replace function edit_product(slug uuid, name text, description text, price text, taxes integer[], tags tag_name[]) returns boolean as $$ declare pid integer; + company integer; begin update product set name = edit_product.name @@ -23,8 +26,8 @@ begin join currency using (currency_code) where product.company_id = company.company_id and product.slug = edit_product.slug - returning product_id - into pid; + returning product_id, product.company_id + into pid, company; if pid is null then return false; @@ -36,12 +39,14 @@ begin select pid, tax_id from unnest(taxes) as tax(tax_id); + perform tag_product(company, pid, tags); + return true; end; $$ language plpgsql; -revoke execute on function edit_product(uuid, text, text, text, integer[]) from public; -grant execute on function edit_product(uuid, text, text, text, integer[]) to invoicer; -grant execute on function edit_product(uuid, text, text, text, integer[]) to admin; +revoke execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) from public; +grant execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) to invoicer; +grant execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) to admin; commit; diff --git a/deploy/product_tag.sql b/deploy/product_tag.sql new file mode 100644 index 0000000..1a73cea --- /dev/null +++ b/deploy/product_tag.sql @@ -0,0 +1,31 @@ +-- Deploy numerus:product_tag to pg +-- requires: schema_numerus +-- requires: tag +-- requires: product + +begin; + +set search_path to numerus, public; + +create table product_tag ( + product_id integer not null references product, + tag_id integer not null references tag, + primary key (product_id, tag_id) +); + +grant select, insert, update, delete on table product_tag to invoicer; +grant select, insert, update, delete on table product_tag to admin; + +alter table product_tag enable row level security; + +create policy company_policy +on product_tag +using ( + exists( + select 1 + from product + where product.product_id = product_tag.product_id + ) +); + +commit; diff --git a/deploy/tag_product.sql b/deploy/tag_product.sql new file mode 100644 index 0000000..5b1933d --- /dev/null +++ b/deploy/tag_product.sql @@ -0,0 +1,22 @@ +-- Deploy numerus:tag_product to pg +-- requires: schema_numerus +-- requires: tag_name +-- requires: tag_relation +-- requires: product_tag + +begin; + +set search_path to numerus, public; + +create or replace function tag_product (company_id integer, product_id integer, tags tag_name[]) returns void as +$$ + select tag_relation('product_tag', 'product_id', company_id, product_id, tags); +$$ + language sql +; + +revoke execute on function tag_product(integer, integer, tag_name[]) from public; +grant execute on function tag_product(integer, integer, tag_name[]) to invoicer; +grant execute on function tag_product(integer, integer, tag_name[]) to admin; + +commit; diff --git a/pkg/products.go b/pkg/products.go index b49ba21..baed0e4 100644 --- a/pkg/products.go +++ b/pkg/products.go @@ -14,6 +14,7 @@ type ProductEntry struct { Slug string Name string Price string + Tags []string } type productsIndexPage struct { @@ -23,8 +24,9 @@ type productsIndexPage struct { func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { conn := getConn(r) company := mustGetCompany(r) + tag := r.URL.Query().Get("tag") page := &productsIndexPage{ - Products: mustGetProductEntries(r.Context(), conn, company), + Products: mustCollectProductEntries(r.Context(), conn, company, tag), } mustRenderMainTemplate(w, r, "products/index.gohtml", page) } @@ -40,7 +42,7 @@ func GetProductForm(w http.ResponseWriter, r *http.Request, params httprouter.Pa mustRenderNewProductForm(w, r, form) return } - if notFoundErrorOrPanic(conn.QueryRow(r.Context(), "select product.name, product.description, to_price(price, decimal_digits), array_agg(tax_id) from product left join product_tax using (product_id) join company using (company_id) join currency using (currency_code) where product.slug = $1 group by product_id, product.name, product.description, price, decimal_digits", slug).Scan(form.Name, form.Description, form.Price, form.Tax)) { + if !form.MustFillFromDatabase(r.Context(), conn, slug) { http.NotFound(w, r) return } @@ -74,12 +76,12 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param return } taxes := mustSliceAtoi(form.Tax.Selected) - conn.MustExec(r.Context(), "select add_product($1, $2, $3, $4, $5)", company.Id, form.Name, form.Description, form.Price, taxes) + conn.MustExec(r.Context(), "select add_product($1, $2, $3, $4, $5, $6)", company.Id, form.Name, form.Description, form.Price, taxes, form.Tags) http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther) } func sliceAtoi(s []string) ([]int, error) { - i := []int{} + var i []int for _, vs := range s { vi, err := strconv.Atoi(vs) if err != nil { @@ -118,14 +120,29 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout } slug := params[0].Value taxes := mustSliceAtoi(form.Tax.Selected) - if ok := conn.MustGetBool(r.Context(), "select edit_product($1, $2, $3, $4, $5)", slug, form.Name, form.Description, form.Price, taxes); !ok { + if ok := conn.MustGetBool(r.Context(), "select edit_product($1, $2, $3, $4, $5, $6)", slug, form.Name, form.Description, form.Price, taxes, form.Tags); !ok { http.NotFound(w, r) } http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther) } -func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []*ProductEntry { - rows, err := conn.Query(ctx, "select product.slug, product.name, to_price(price, decimal_digits) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.Id) +func mustCollectProductEntries(ctx context.Context, conn *Conn, company *Company, tag string) []*ProductEntry { + rows, err := conn.Query(ctx, ` + select product.slug + , product.name + , to_price(price, decimal_digits) + , array_agg(coalesce(tag.name::text, '')) + from product + join company using (company_id) + join currency using (currency_code) + left join product_tag using (product_id) + left join tag using(tag_id) + where product.company_id = $1 and (($2 = '') or (tag.name = $2)) + group by product.slug + , product.name + , to_price(price, decimal_digits) + order by name + `, company.Id, tag) if err != nil { panic(err) } @@ -134,7 +151,7 @@ func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) [] var entries []*ProductEntry for rows.Next() { entry := &ProductEntry{} - err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price) + err = rows.Scan(&entry.Slug, &entry.Name, &entry.Price, &entry.Tags) if err != nil { panic(err) } @@ -154,6 +171,7 @@ type productForm struct { Description *InputField Price *InputField Tax *SelectField + Tags *TagsField } func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm { @@ -187,6 +205,10 @@ func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Co Multiple: true, Options: mustGetTaxOptions(ctx, conn, company), }, + Tags: &TagsField{ + Name: "tags", + Label: pgettext("input", "Tags", locale), + }, } } @@ -198,6 +220,7 @@ func (form *productForm) Parse(r *http.Request) error { form.Description.FillValue(r) form.Price.FillValue(r) form.Tax.FillValue(r) + form.Tags.FillValue(r) return nil } @@ -211,3 +234,30 @@ func (form *productForm) Validate() bool { validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale)) return validator.AllOK() } + +func (form *productForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { + return !notFoundErrorOrPanic(conn.QueryRow(ctx, ` + select product.name + , product.description + , to_price(price, decimal_digits) + , array_agg(tax_id) + , string_agg(tag.name, ',') + from product + left join product_tax using (product_id) + left join product_tag using (product_id) + left join tag using(company_id, tag_id) + join company using (company_id) + join currency using (currency_code) + where product.slug = $1 + group by product_id + , product.name + , product.description + , price + , decimal_digits + `, slug).Scan( + form.Name, + form.Description, + form.Price, + form.Tax, + form.Tags)) +} diff --git a/revert/add_product.sql b/revert/add_product.sql index 447637a..36af919 100644 --- a/revert/add_product.sql +++ b/revert/add_product.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.add_product(integer, text, text, text, integer[]); +drop function if exists numerus.add_product(integer, text, text, text, integer[], numerus.tag_name[]); commit; diff --git a/revert/edit_product.sql b/revert/edit_product.sql index 9a7e38c..5b84886 100644 --- a/revert/edit_product.sql +++ b/revert/edit_product.sql @@ -2,6 +2,6 @@ begin; -drop function if exists numerus.edit_product(uuid, text, text, text, integer[]); +drop function if exists numerus.edit_product(uuid, text, text, text, integer[], numerus.tag_name[]); commit; diff --git a/revert/product_tag.sql b/revert/product_tag.sql new file mode 100644 index 0000000..b934c7e --- /dev/null +++ b/revert/product_tag.sql @@ -0,0 +1,7 @@ +-- Revert numerus:product_tag from pg + +begin; + +drop table if exists numerus.product_tag; + +commit; diff --git a/revert/tag_product.sql b/revert/tag_product.sql new file mode 100644 index 0000000..1639790 --- /dev/null +++ b/revert/tag_product.sql @@ -0,0 +1,7 @@ +-- Revert numerus:tag_product from pg + +begin; + +drop function if exists numerus.tag_product(integer, integer, numerus.tag_name[]); + +commit; diff --git a/sqitch.plan b/sqitch.plan index aba6c61..5aa922b 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -52,16 +52,18 @@ product_tax [schema_numerus product tax] 2023-02-08T11:36:49Z jordi fita mas # Add relation for invoice discount_rate [schema_numerus] 2023-02-10T17:22:40Z jordi fita mas # Add domain for discount rates invoice_product [schema_numerus invoice discount_rate] 2023-02-10T17:07:08Z jordi fita mas # Add relation for invoice product -add_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T10:32:18Z jordi fita mas # Add function to add new products -edit_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T11:06:03Z jordi fita mas # Add function to edit products +tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas # Add domain for tag names +tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas # Add relation for tags +tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas # Add function to tag “relations” +product_tag [schema_numerus tag product] 2023-03-26T10:54:23Z jordi fita mas # Add relation for product tag +tag_product [schema_numerus tag_name tag_relation product_tag] 2023-03-26T11:01:17Z jordi fita mas # Add function to tag products +add_product [schema_numerus product product_tax parse_price company currency tag_name tag_product] 2023-02-14T10:32:18Z jordi fita mas # Add function to add new products +edit_product [schema_numerus product product_tax parse_price company currency tag_name tag_product] 2023-02-14T11:06:03Z jordi fita mas # Add function to edit products invoice_product_tax [schema_numerus invoice_product tax tax_rate] 2023-02-15T13:20:30Z jordi fita mas # Add relation for taxes in invoice products new_invoice_product [schema_numerus discount_rate] 2023-02-16T21:06:01Z jordi fita mas # Add type for passing products to new invoices invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita mas # Add relation to count invoice numbers next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas # Add function to retrieve the next invoice number -tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas # Add domain for tag names -tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas # Add relation for tags invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas # Add relation for invoice tag -tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas # Add function to tag “relations” tag_invoice [schema_numerus tag_name tag_relation invoice_tag] 2023-03-25T18:04:02Z jordi fita mas # Add function to tag invoices add_invoice [schema_numerus invoice company currency parse_price new_invoice_product tax invoice_product invoice_product_tax next_invoice_number tag_name tag_invoice] 2023-02-16T21:12:46Z jordi fita mas # Add function to create new invoices invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas # Add view for invoice tax amount diff --git a/test/add_contact.sql b/test/add_contact.sql index b75b6e6..159dc15 100644 --- a/test/add_contact.sql +++ b/test/add_contact.sql @@ -90,6 +90,8 @@ select bag_eq( $$, 'Should have assigned the tags to contacts' ); + + select * from finish(); diff --git a/test/add_product.sql b/test/add_product.sql index a5b1061..c4c0e46 100644 --- a/test/add_product.sql +++ b/test/add_product.sql @@ -5,22 +5,24 @@ reset client_min_messages; begin; -select plan(14); +select plan(16); set search_path to auth, numerus, public; -select has_function('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]']); -select function_lang_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'plpgsql'); -select function_returns('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'uuid'); -select isnt_definer('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]']); -select volatility_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'volatile'); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'guest', array []::text[]); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'invoicer', array ['EXECUTE']); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'admin', array ['EXECUTE']); -select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]'], 'authenticator', array []::text[]); +select has_function('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select function_lang_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql'); +select function_returns('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'uuid'); +select isnt_definer('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select volatility_is('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]); set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; truncate product_tax cascade; truncate product cascade; truncate tax cascade; @@ -57,17 +59,17 @@ values (3, 1, 11, 'IRPF -15 %', -0.15) select lives_ok( - $$ select add_product(1, 'Product 1', 'Description 1', '12.12', array[3, 4]) $$, + $$ select add_product(1, 'Product 1', 'Description 1', '12.12', array[3, 4], array['tag1']) $$, 'Should be able to add product to first company' ); select lives_ok( - $$ select add_product(2, 'Product 2', 'Description 2', '24.24', array[6]) $$, + $$ select add_product(2, 'Product 2', 'Description 2', '24.24', array[6], array['tag1', 'tag2']) $$, 'Should be able to add product to second company' ); select lives_ok( - $$ select add_product(2, 'Product 3', 'Description 3', '36.36', array[]::integer[]) $$, + $$ select add_product(2, 'Product 3', 'Description 3', '36.36', array[]::integer[], array[]::tag_name[]) $$, 'Should be able to add product without taxes' ); @@ -89,6 +91,25 @@ select bag_eq( 'Should have added the taxes for the products we told to' ); +select bag_eq( + $$ select company_id, name from tag $$, + $$ values (1, 'tag1') + , (2, 'tag1') + , (2, 'tag2') + $$, + 'Should have added all new tags once' +); + +select bag_eq( + $$ select product.name as product_name, tag.name as tag_name from product_tag join product using (product_id) join tag using (tag_id) $$, + $$ values ('Product 1', 'tag1') + , ('Product 2', 'tag1') + , ('Product 2', 'tag2') + $$, + 'Should have assigned the tags to products' +); + + select * from finish(); diff --git a/test/edit_product.sql b/test/edit_product.sql index 2b82ac1..b7460f2 100644 --- a/test/edit_product.sql +++ b/test/edit_product.sql @@ -5,22 +5,24 @@ reset client_min_messages; begin; -select plan(15); +select plan(17); set search_path to auth, numerus, public; -select has_function('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]']); -select function_lang_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'plpgsql'); -select function_returns('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'boolean'); -select isnt_definer('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]']); -select volatility_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'volatile'); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'guest', array []::text[]); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'invoicer', array ['EXECUTE']); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'admin', array ['EXECUTE']); -select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]'], 'authenticator', array []::text[]); +select has_function('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select function_lang_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'plpgsql'); +select function_returns('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'boolean'); +select isnt_definer('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]']); +select volatility_is('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]); set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; truncate product_tax cascade; truncate product cascade; truncate tax cascade; @@ -68,26 +70,40 @@ values (7, 3) , (9, 6) ; +insert into tag (tag_id, company_id, name) +values (10, 1, 'tag1') + , (11, 2, 'tag2') +; +-- edit_product uses the sequence and sometimes it would confict +alter sequence tag_tag_id_seq restart with 15; + +insert into product_tag (product_id, tag_id) +values (7, 10) + , (8, 11) + , (9, 11) +; + + select is( - edit_product('d2300404-bd23-48b3-8e2b-2bbf86dc7bd6', 'Product 1', 'Description 1', '12.12', array[3, 4]), + edit_product('d2300404-bd23-48b3-8e2b-2bbf86dc7bd6', 'Product 1', 'Description 1', '12.12', array[3, 4], array['tag1']), true, 'Should be able to edit product from first company' ); select is( - edit_product('2f085b8b-da90-41fe-b8cf-6ba8d94cfa38', 'Product 2', 'Description 2', '24.24', array[6]), + edit_product('2f085b8b-da90-41fe-b8cf-6ba8d94cfa38', 'Product 2', 'Description 2', '24.24', array[6], array['tag1', 'tag3']), true, 'Should be able to edit product from second company' ); select is( - edit_product('84044d0b-af33-442a-95a6-21efc77260d5', 'Product 3', 'Description 3', '36.36', array[]::integer[]), + edit_product('84044d0b-af33-442a-95a6-21efc77260d5', 'Product 3', 'Description 3', '36.36', array[]::integer[], array[]::tag_name[]), true, 'Should be able to edit a product a remove all taxes' ); select is( - edit_product('87e158d1-a0f5-48a7-854b-b86d7b4bb21c', 'Product 4', 'Description 4', '48.48', array[]::integer[]), + edit_product('87e158d1-a0f5-48a7-854b-b86d7b4bb21c', 'Product 4', 'Description 4', '48.48', array[]::integer[], array[]::tag_name[]), false, 'Should return false when the product does not exist' ); @@ -110,6 +126,25 @@ select bag_eq( 'Should have updated the taxes for the products we told to' ); +select bag_eq( + $$ select company_id, name from tag $$, + $$ values (1, 'tag1') + , (2, 'tag1') + , (2, 'tag2') + , (2, 'tag3') + $$, + 'Should have added all new tags' +); + +select bag_eq( + $$ select product.name as product_name, tag.name as tag_name from product_tag join product using (product_id) join tag using (tag_id) $$, + $$ values ('Product 1', 'tag1') + , ('Product 2', 'tag1') + , ('Product 2', 'tag3') + $$, + 'Should have assigned the tags to products' +); + select * from finish(); diff --git a/test/product_tag.sql b/test/product_tag.sql new file mode 100644 index 0000000..aaa7f4e --- /dev/null +++ b/test/product_tag.sql @@ -0,0 +1,129 @@ +-- Test product_tag +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(23); + +set search_path to numerus, auth, public; + +select has_table('product_tag'); +select has_pk('product_tag'); +select col_is_pk('product_tag', array['product_id', 'tag_id']); +select table_privs_are('product_tag', 'guest', array []::text[]); +select table_privs_are('product_tag', 'invoicer', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tag', 'admin', array ['SELECT', 'INSERT', 'UPDATE', 'DELETE']); +select table_privs_are('product_tag', 'authenticator', array []::text[]); + +select has_column('product_tag', 'product_id'); +select col_is_fk('product_tag', 'product_id'); +select fk_ok('product_tag', 'product_id', 'product', 'product_id'); +select col_type_is('product_tag', 'product_id', 'integer'); +select col_not_null('product_tag', 'product_id'); +select col_hasnt_default('product_tag', 'product_id'); + +select has_column('product_tag', 'tag_id'); +select col_is_fk('product_tag', 'tag_id'); +select fk_ok('product_tag', 'tag_id', 'tag', 'tag_id'); +select col_type_is('product_tag', 'tag_id', 'integer'); +select col_not_null('product_tag', 'tag_id'); +select col_hasnt_default('product_tag', 'tag_id'); + + +set client_min_messages to warning; +truncate product_tag cascade; +truncate product cascade; +truncate tag cascade; +truncate company_user cascade; +truncate company cascade; +truncate payment_method cascade; +truncate auth."user" cascade; +reset client_min_messages; + +insert into auth."user" (user_id, email, name, password, role, cookie, cookie_expires_at) +values (1, 'demo@tandem.blog', 'Demo', 'test', 'invoicer', '44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e', current_timestamp + interval '1 month') + , (5, 'admin@tandem.blog', 'Demo', 'test', 'admin', '12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524', current_timestamp + interval '1 month') +; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (2, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 222) + , (4, 'Company 4', 'XX234', '', '666-666-666', 'b@b', '', '', '', '', '', 'FR', 'USD', 444) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (444, 4, 'cash', 'cash') + , (222, 2, 'cash', 'cash') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into company_user (company_id, user_id) +values (2, 1) + , (4, 5) +; + +insert into product (product_id, company_id, name, description, price) +values (6, 2, 'Product 1', 'Description 1', 1200) + , (8, 4, 'Product 2', 'Description 2', 2400) +; + +insert into tag (tag_id, company_id, name) +values (14, 2, 'web') + , (15, 2, 'design') + , (16, 4, 'product') + , (17, 4, 'development') + , (18, 4, 'something-else') + , (19, 4, 'design') +; + +insert into product_tag (product_id, tag_id) +values (6, 14) + , (6, 15) + , (8, 18) +; + +prepare product_tag_data as +select product_id, tag_id +from product_tag +; + +set role invoicer; +select is_empty('product_tag_data', 'Should show no data when cookie is not set yet'); +reset role; + +select set_cookie('44facbb30d8a419dfd4bfbc44a4b5539d4970148dfc84bed0e/demo@tandem.blog'); +select bag_eq( + 'product_tag_data', + $$ values ( 6, 14 ) + , ( 6, 15 ) + $$, + 'Should only list product tags of the companies where demo@tandem.blog is user of' +); +reset role; + +select set_cookie('12af4c88b528c2ad4222e3740496ecbc58e76e26f087657524/admin@tandem.blog'); +select bag_eq( + 'product_tag_data', + $$ values ( 8, 18 ) + $$, + 'Should only list product tags of the companies where admin@tandem.blog is user of' +); +reset role; + +select set_cookie('not-a-cookie'); +select throws_ok( + 'product_tag_data', + '42501', 'permission denied for table product_tag', + 'Should not allow select to guest users' +); + + +select * +from finish(); + +rollback; + diff --git a/test/tag_product.sql b/test/tag_product.sql new file mode 100644 index 0000000..306c11f --- /dev/null +++ b/test/tag_product.sql @@ -0,0 +1,124 @@ +-- Test tag_product +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(17); + +set search_path to numerus, auth, public; + +select has_function('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]']); +select function_lang_is('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'sql'); +select function_returns('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'void'); +select isnt_definer('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]']); +select volatility_is('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'volatile'); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'guest', array []::text[]); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'tag_product', array ['integer', 'integer', 'tag_name[]'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate product_tag cascade; +truncate tag cascade; +truncate product cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') + , (112, 1, 'bank', 'send money to my bank account') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into tag (tag_id, company_id, name) +values (10, 1, 'tag1') + , (11, 1, 'tag2') +; +-- tag_product uses the sequence and sometimes it would confict +alter sequence tag_tag_id_seq restart with 15; + + +insert into product (product_id, company_id, name, description, price) +values (12, 1, 'Product 1', 'Description 1', 1200) + , (13, 1, 'Product 2', 'Description 2', 2400) +; + +insert into product_tag (product_id, tag_id) +values (12, 10) + , (13, 11) +; + +prepare current_tags as +select product_id, tag.name +from product +join product_tag using (product_id) +join tag using (tag_id); + +select lives_ok( + $$ select tag_product(1, 12, array['tag1']) $$, + 'Should be able to keep the same tags to the product' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (13, 'tag2') + $$, + 'Should not have changed any product tag' +); + +select lives_ok( + $$ select tag_product(1, 12, array['tag1', 'tag2']) $$, + 'Should be able to add tag2 product' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (12, 'tag2') + , (13, 'tag2') + $$, + 'Should have added tag2 to product' +); + +select lives_ok( + $$ select tag_product(1, 13, array['tag3']) $$, + 'Should be able to replace all tags of an product with a new one' +); + +select bag_eq( + 'current_tags', + $$ values (12, 'tag1') + , (12, 'tag2') + , (13, 'tag3') + $$, + 'Should have set tag3 to product' +); + +select lives_ok( + $$ select tag_product(1, 12, array[]::tag_name[]) $$, + 'Should be able to remove all tags from an product' +); + +select bag_eq( + 'current_tags', + $$ values (13, 'tag3') + $$, + 'Should have remove all tags from product' +); + +select * +from finish(); + +rollback; diff --git a/verify/add_product.sql b/verify/add_product.sql index 2f13b23..de39ba8 100644 --- a/verify/add_product.sql +++ b/verify/add_product.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.add_product(integer, text, text, text, integer[])', 'execute'); +select has_function_privilege('numerus.add_product(integer, text, text, text, integer[], numerus.tag_name[])', 'execute'); rollback; diff --git a/verify/edit_product.sql b/verify/edit_product.sql index e916615..8db0f2f 100644 --- a/verify/edit_product.sql +++ b/verify/edit_product.sql @@ -2,6 +2,6 @@ begin; -select has_function_privilege('numerus.edit_product(uuid, text, text, text, integer[])', 'execute'); +select has_function_privilege('numerus.edit_product(uuid, text, text, text, integer[], numerus.tag_name[])', 'execute'); rollback; diff --git a/verify/product_tag.sql b/verify/product_tag.sql new file mode 100644 index 0000000..a008d85 --- /dev/null +++ b/verify/product_tag.sql @@ -0,0 +1,13 @@ +-- Verify numerus:product_tag on pg + +begin; + +select product_id + , tag_id +from numerus.product_tag +where false; + +select 1 / count(*) from pg_class where oid = 'numerus.product_tag'::regclass and relrowsecurity; +select 1 / count(*) from pg_policy where polname = 'company_policy' and polrelid = 'numerus.product_tag'::regclass; + +rollback; diff --git a/verify/tag_product.sql b/verify/tag_product.sql new file mode 100644 index 0000000..cf3af5e --- /dev/null +++ b/verify/tag_product.sql @@ -0,0 +1,7 @@ +-- Verify numerus:tag_product on pg + +begin; + +select has_function_privilege('numerus.tag_product(integer, integer, numerus.tag_name[])', 'execute'); + +rollback; diff --git a/web/template/products/edit.gohtml b/web/template/products/edit.gohtml index e1808dc..afeb7b4 100644 --- a/web/template/products/edit.gohtml +++ b/web/template/products/edit.gohtml @@ -25,6 +25,7 @@ {{ template "input-field" .Description }} {{ template "input-field" .Price }} {{ template "select-field" .Tax }} + {{ template "tags-field" .Tags }}
diff --git a/web/template/products/index.gohtml b/web/template/products/index.gohtml index 5751a9a..f33448e 100644 --- a/web/template/products/index.gohtml +++ b/web/template/products/index.gohtml @@ -23,6 +23,7 @@ {{( pgettext "All" "product" )}} {{( pgettext "Name" "title" )}} + {{( pgettext "Tags" "title" )}} {{( pgettext "Price" "title" )}} @@ -32,6 +33,12 @@ {{ .Name }} + + {{- range $index, $tag := .Tags }} + {{- if gt $index 0 }}, {{ end -}} + {{ . }} + {{- end }} + {{ .Price | formatPrice }} {{- end }} diff --git a/web/template/products/new.gohtml b/web/template/products/new.gohtml index 0d56d11..ac408c8 100644 --- a/web/template/products/new.gohtml +++ b/web/template/products/new.gohtml @@ -24,6 +24,7 @@ {{ template "input-field" .Description }} {{ template "input-field" .Price }} {{ template "select-field" .Tax }} + {{ template "tags-field" .Tags }}