Add tags for products too

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`.
This commit is contained in:
jordi fita mas 2023-03-26 13:51:57 +02:00
parent 4131602fa3
commit a1f70ff654
23 changed files with 529 additions and 61 deletions

View File

@ -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']); 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; 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 laigua règia.', '55.92', 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 laigua 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]); 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]); 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 susa per mantenir la higiene personal després de defecar o orinar.', '7.99', array[4]); select add_product(1, 'Paper higiènic (pack de 32 U)', 'Paper que susa 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]); 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]); 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 daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[2]); select add_product(1, 'Teia', 'Fusta resinosa de pi i daltres arbres, provinent sobretot del cor de larbre, que crema amb molta facilitat.', '7.00', array[2], array['obsolet']);
alter sequence invoice_invoice_id_seq restart; alter sequence invoice_invoice_id_seq restart;
alter sequence invoice_product_invoice_product_id_seq restart; alter sequence invoice_product_invoice_product_id_seq restart;

View File

@ -5,12 +5,14 @@
-- requires: parse_price -- requires: parse_price
-- requires: company -- requires: company
-- requires: currency -- requires: currency
-- requires: tag_name
-- requires: tag_product
begin; begin;
set search_path to numerus, public; 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 $$ as $$
declare declare
pid integer; pid integer;
@ -28,12 +30,14 @@ begin
select pid, tax_id select pid, tax_id
from unnest(taxes) as tax(tax_id); from unnest(taxes) as tax(tax_id);
perform tag_product(company_id, pid, tags);
return pslug; return pslug;
end; end;
$$ language plpgsql; $$ language plpgsql;
revoke execute on function add_product(integer, text, text, text, integer[]) from public; 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[]) to invoicer; 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[]) to admin; grant execute on function add_product(integer, text, text, text, integer[], tag_name[]) to admin;
commit; commit;

View File

@ -5,15 +5,18 @@
-- requires: parse_price -- requires: parse_price
-- requires: company -- requires: company
-- requires: currency -- requires: currency
-- requires: tag_name
-- requires: tag_product
begin; begin;
set search_path to numerus, public; 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 $$ as $$
declare declare
pid integer; pid integer;
company integer;
begin begin
update product update product
set name = edit_product.name set name = edit_product.name
@ -23,8 +26,8 @@ begin
join currency using (currency_code) join currency using (currency_code)
where product.company_id = company.company_id where product.company_id = company.company_id
and product.slug = edit_product.slug and product.slug = edit_product.slug
returning product_id returning product_id, product.company_id
into pid; into pid, company;
if pid is null then if pid is null then
return false; return false;
@ -36,12 +39,14 @@ begin
select pid, tax_id select pid, tax_id
from unnest(taxes) as tax(tax_id); from unnest(taxes) as tax(tax_id);
perform tag_product(company, pid, tags);
return true; return true;
end; end;
$$ language plpgsql; $$ language plpgsql;
revoke execute on function edit_product(uuid, text, text, text, integer[]) from public; 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[]) to invoicer; 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[]) to admin; grant execute on function edit_product(uuid, text, text, text, integer[], tag_name[]) to admin;
commit; commit;

31
deploy/product_tag.sql Normal file
View File

@ -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;

22
deploy/tag_product.sql Normal file
View File

@ -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;

View File

@ -14,6 +14,7 @@ type ProductEntry struct {
Slug string Slug string
Name string Name string
Price string Price string
Tags []string
} }
type productsIndexPage struct { type productsIndexPage struct {
@ -23,8 +24,9 @@ type productsIndexPage struct {
func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { func IndexProducts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
conn := getConn(r) conn := getConn(r)
company := mustGetCompany(r) company := mustGetCompany(r)
tag := r.URL.Query().Get("tag")
page := &productsIndexPage{ page := &productsIndexPage{
Products: mustGetProductEntries(r.Context(), conn, company), Products: mustCollectProductEntries(r.Context(), conn, company, tag),
} }
mustRenderMainTemplate(w, r, "products/index.gohtml", page) 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) mustRenderNewProductForm(w, r, form)
return 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) http.NotFound(w, r)
return return
} }
@ -74,12 +76,12 @@ func HandleAddProduct(w http.ResponseWriter, r *http.Request, _ httprouter.Param
return return
} }
taxes := mustSliceAtoi(form.Tax.Selected) 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) http.Redirect(w, r, companyURI(company, "/products"), http.StatusSeeOther)
} }
func sliceAtoi(s []string) ([]int, error) { func sliceAtoi(s []string) ([]int, error) {
i := []int{} var i []int
for _, vs := range s { for _, vs := range s {
vi, err := strconv.Atoi(vs) vi, err := strconv.Atoi(vs)
if err != nil { if err != nil {
@ -118,14 +120,29 @@ func HandleUpdateProduct(w http.ResponseWriter, r *http.Request, params httprout
} }
slug := params[0].Value slug := params[0].Value
taxes := mustSliceAtoi(form.Tax.Selected) 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.NotFound(w, r)
} }
http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther) http.Redirect(w, r, companyURI(company, "/products/"+slug), http.StatusSeeOther)
} }
func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []*ProductEntry { 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) from product join company using (company_id) join currency using (currency_code) where company_id = $1 order by name", company.Id) 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 { if err != nil {
panic(err) panic(err)
} }
@ -134,7 +151,7 @@ func mustGetProductEntries(ctx context.Context, conn *Conn, company *Company) []
var entries []*ProductEntry var entries []*ProductEntry
for rows.Next() { for rows.Next() {
entry := &ProductEntry{} 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 { if err != nil {
panic(err) panic(err)
} }
@ -154,6 +171,7 @@ type productForm struct {
Description *InputField Description *InputField
Price *InputField Price *InputField
Tax *SelectField Tax *SelectField
Tags *TagsField
} }
func newProductForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *productForm { 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, Multiple: true,
Options: mustGetTaxOptions(ctx, conn, company), 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.Description.FillValue(r)
form.Price.FillValue(r) form.Price.FillValue(r)
form.Tax.FillValue(r) form.Tax.FillValue(r)
form.Tags.FillValue(r)
return nil 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)) validator.CheckAtMostOneOfEachGroup(form.Tax, gettext("You can only select a tax of each class.", form.locale))
return validator.AllOK() 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))
}

View File

@ -2,6 +2,6 @@
begin; 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; commit;

View File

@ -2,6 +2,6 @@
begin; 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; commit;

7
revert/product_tag.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:product_tag from pg
begin;
drop table if exists numerus.product_tag;
commit;

7
revert/tag_product.sql Normal file
View File

@ -0,0 +1,7 @@
-- Revert numerus:tag_product from pg
begin;
drop function if exists numerus.tag_product(integer, integer, numerus.tag_name[]);
commit;

View File

@ -52,16 +52,18 @@ product_tax [schema_numerus product tax] 2023-02-08T11:36:49Z jordi fita mas <jo
invoice [schema_numerus company contact invoice_status payment_method currency] 2023-02-09T09:52:21Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice invoice [schema_numerus company contact invoice_status payment_method currency] 2023-02-09T09:52:21Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice
discount_rate [schema_numerus] 2023-02-10T17:22:40Z jordi fita mas <jordi@tandem.blog> # Add domain for discount rates discount_rate [schema_numerus] 2023-02-10T17:22:40Z jordi fita mas <jordi@tandem.blog> # Add domain for discount rates
invoice_product [schema_numerus invoice discount_rate] 2023-02-10T17:07:08Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice product invoice_product [schema_numerus invoice discount_rate] 2023-02-10T17:07:08Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice product
add_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T10:32:18Z jordi fita mas <jordi@tandem.blog> # Add function to add new products tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas <jordi@tandem.blog> # Add domain for tag names
edit_product [schema_numerus product product_tax parse_price company currency] 2023-02-14T11:06:03Z jordi fita mas <jordi@tandem.blog> # Add function to edit products tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas <jordi@tandem.blog> # Add relation for tags
tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas <jordi@tandem.blog> # Add function to tag “relations”
product_tag [schema_numerus tag product] 2023-03-26T10:54:23Z jordi fita mas <jordi@tandem.blog> # Add relation for product tag
tag_product [schema_numerus tag_name tag_relation product_tag] 2023-03-26T11:01:17Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to edit products
invoice_product_tax [schema_numerus invoice_product tax tax_rate] 2023-02-15T13:20:30Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes in invoice products invoice_product_tax [schema_numerus invoice_product tax tax_rate] 2023-02-15T13:20:30Z jordi fita mas <jordi@tandem.blog> # Add relation for taxes in invoice products
new_invoice_product [schema_numerus discount_rate] 2023-02-16T21:06:01Z jordi fita mas <jordi@tandem.blog> # Add type for passing products to new invoices new_invoice_product [schema_numerus discount_rate] 2023-02-16T21:06:01Z jordi fita mas <jordi@tandem.blog> # Add type for passing products to new invoices
invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita mas <jordi@tandem.blog> # Add relation to count invoice numbers invoice_number_counter [schema_numerus company] 2023-02-17T13:04:48Z jordi fita mas <jordi@tandem.blog> # Add relation to count invoice numbers
next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas <jordi@tandem.blog> # Add function to retrieve the next invoice number next_invoice_number [schema_numerus invoice_number_counter] 2023-02-17T13:21:48Z jordi fita mas <jordi@tandem.blog> # Add function to retrieve the next invoice number
tag_name [schema_numerus] 2023-03-10T11:06:11Z jordi fita mas <jordi@tandem.blog> # Add domain for tag names
tag [schema_numerus tag_name] 2023-03-10T11:04:24Z jordi fita mas <jordi@tandem.blog> # Add relation for tags
invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice tag invoice_tag [schema_numerus tag invoice] 2023-03-10T11:37:43Z jordi fita mas <jordi@tandem.blog> # Add relation for invoice tag
tag_relation [schema_numerus tag tag_name] 2023-03-25T17:40:52Z jordi fita mas <jordi@tandem.blog> # Add function to tag “relations”
tag_invoice [schema_numerus tag_name tag_relation invoice_tag] 2023-03-25T18:04:02Z jordi fita mas <jordi@tandem.blog> # Add function to tag invoices tag_invoice [schema_numerus tag_name tag_relation invoice_tag] 2023-03-25T18:04:02Z jordi fita mas <jordi@tandem.blog> # 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 <jordi@tandem.blog> # Add function to create new 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 <jordi@tandem.blog> # Add function to create new invoices
invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas <jordi@tandem.blog> # Add view for invoice tax amount invoice_tax_amount [schema_numerus invoice_product invoice_product_tax] 2023-02-22T12:08:35Z jordi fita mas <jordi@tandem.blog> # Add view for invoice tax amount

View File

@ -90,6 +90,8 @@ select bag_eq(
$$, $$,
'Should have assigned the tags to contacts' 'Should have assigned the tags to contacts'
); );
select * select *
from finish(); from finish();

View File

@ -5,22 +5,24 @@ reset client_min_messages;
begin; begin;
select plan(14); select plan(16);
set search_path to auth, numerus, public; set search_path to auth, numerus, public;
select has_function('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]']); 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[]'], 'plpgsql'); 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[]'], 'uuid'); 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[]']); 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[]'], 'volatile'); 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[]'], 'guest', array []::text[]); 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[]'], 'invoicer', array ['EXECUTE']); 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[]'], 'admin', 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[]'], 'authenticator', array []::text[]); select function_privs_are('numerus', 'add_product', array ['integer', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning; set client_min_messages to warning;
truncate product_tag cascade;
truncate tag cascade;
truncate product_tax cascade; truncate product_tax cascade;
truncate product cascade; truncate product cascade;
truncate tax cascade; truncate tax cascade;
@ -57,17 +59,17 @@ values (3, 1, 11, 'IRPF -15 %', -0.15)
select lives_ok( 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' 'Should be able to add product to first company'
); );
select lives_ok( 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' 'Should be able to add product to second company'
); );
select lives_ok( 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' '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' '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 * select *
from finish(); from finish();

View File

@ -5,22 +5,24 @@ reset client_min_messages;
begin; begin;
select plan(15); select plan(17);
set search_path to auth, numerus, public; set search_path to auth, numerus, public;
select has_function('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]']); 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[]'], 'plpgsql'); 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[]'], 'boolean'); 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[]']); 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[]'], 'volatile'); 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[]'], 'guest', array []::text[]); 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[]'], 'invoicer', array ['EXECUTE']); 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[]'], 'admin', 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[]'], 'authenticator', array []::text[]); select function_privs_are('numerus', 'edit_product', array ['uuid', 'text', 'text', 'text', 'integer[]', 'tag_name[]'], 'authenticator', array []::text[]);
set client_min_messages to warning; set client_min_messages to warning;
truncate product_tag cascade;
truncate tag cascade;
truncate product_tax cascade; truncate product_tax cascade;
truncate product cascade; truncate product cascade;
truncate tax cascade; truncate tax cascade;
@ -68,26 +70,40 @@ values (7, 3)
, (9, 6) , (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( 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, true,
'Should be able to edit product from first company' 'Should be able to edit product from first company'
); );
select is( 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, true,
'Should be able to edit product from second company' 'Should be able to edit product from second company'
); );
select is( 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, true,
'Should be able to edit a product a remove all taxes' 'Should be able to edit a product a remove all taxes'
); );
select is( 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, false,
'Should return false when the product does not exist' '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' '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 * select *
from finish(); from finish();

129
test/product_tag.sql Normal file
View File

@ -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;

124
test/tag_product.sql Normal file
View File

@ -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;

View File

@ -2,6 +2,6 @@
begin; 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; rollback;

View File

@ -2,6 +2,6 @@
begin; 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; rollback;

13
verify/product_tag.sql Normal file
View File

@ -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;

7
verify/tag_product.sql Normal file
View File

@ -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;

View File

@ -25,6 +25,7 @@
{{ template "input-field" .Description }} {{ template "input-field" .Description }}
{{ template "input-field" .Price }} {{ template "input-field" .Price }}
{{ template "select-field" .Tax }} {{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
<fieldset> <fieldset>
<button class="primary" type="submit">{{( pgettext "Update product" "action" )}}</button> <button class="primary" type="submit">{{( pgettext "Update product" "action" )}}</button>

View File

@ -23,6 +23,7 @@
<tr> <tr>
<th>{{( pgettext "All" "product" )}}</th> <th>{{( pgettext "All" "product" )}}</th>
<th>{{( pgettext "Name" "title" )}}</th> <th>{{( pgettext "Name" "title" )}}</th>
<th>{{( pgettext "Tags" "title" )}}</th>
<th>{{( pgettext "Price" "title" )}}</th> <th>{{( pgettext "Price" "title" )}}</th>
</tr> </tr>
</thead> </thead>
@ -32,6 +33,12 @@
<tr> <tr>
<td></td> <td></td>
<td><a href="{{ companyURI "/products/"}}{{ .Slug }}">{{ .Name }}</a></td> <td><a href="{{ companyURI "/products/"}}{{ .Slug }}">{{ .Name }}</a></td>
<td>
{{- range $index, $tag := .Tags }}
{{- if gt $index 0 }}, {{ end -}}
<a href="?tag={{ . }}">{{ . }}</a>
{{- end }}
</td>
<td class="numeric">{{ .Price | formatPrice }}</td> <td class="numeric">{{ .Price | formatPrice }}</td>
</tr> </tr>
{{- end }} {{- end }}

View File

@ -24,6 +24,7 @@
{{ template "input-field" .Description }} {{ template "input-field" .Description }}
{{ template "input-field" .Price }} {{ template "input-field" .Price }}
{{ template "select-field" .Tax }} {{ template "select-field" .Tax }}
{{ template "tags-field" .Tags }}
<fieldset> <fieldset>
<button class="primary" type="submit">{{( pgettext "New product" "action" )}}</button> <button class="primary" type="submit">{{( pgettext "New product" "action" )}}</button>