Add the form to update company’s tax details

It is inside the “user menu” only because this is where Numerus has the
same option, although it makes less sense in this case, because Numerus
is geared toward individual freelancers while Camper is for companies.
But, since it is easy to change afterward, this will do for now.

However, it should be only shown to admin users, because regular
employees have no UPDATE privilege on the company relation.  Thus, the
need for a new template function to check if the user is admin.

Part of #17.
This commit is contained in:
jordi fita mas 2023-08-15 22:35:21 +02:00
parent 93364b896c
commit 50fbfce9ee
19 changed files with 940 additions and 19 deletions

23
deploy/input_is_valid.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy camper:input_is_valid to pg
-- requires: roles
-- requires: schema_public
begin;
set search_path to public;
create or replace function input_is_valid(input text, domname text) returns boolean as
$$
begin
begin
execute format('select %L::%s', input, domname);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
commit;

View File

@ -0,0 +1,24 @@
-- Deploy camper:input_is_valid_phone to pg
-- requires: roles
-- requires: schema_public
-- requires: extension_pg_libphonenumber
begin;
set search_path to public;
create or replace function input_is_valid_phone(phone text, country text) returns boolean as
$$
begin
begin
perform parse_packed_phone_number(phone, country);
return true;
exception when others then
return false;
end;
end;
$$
language plpgsql
stable;
commit;

View File

@ -10,6 +10,7 @@ import (
"dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/campsite" "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/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http" httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/template"
@ -17,11 +18,13 @@ import (
type adminHandler struct { type adminHandler struct {
campsite *campsite.AdminHandler campsite *campsite.AdminHandler
company *company.AdminHandler
} }
func newAdminHandler() *adminHandler { func newAdminHandler() *adminHandler {
return &adminHandler{ return &adminHandler{
campsite: campsite.NewAdminHandler(), campsite: campsite.NewAdminHandler(),
company: company.NewAdminHandler(),
} }
} }
@ -43,6 +46,8 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
switch head { switch head {
case "campsites": case "campsites":
h.campsite.Handler(user, company, conn).ServeHTTP(w, r) h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "company":
h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "": case "":
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:

View File

@ -44,3 +44,7 @@ func (user *User) IsEmployee() bool {
role := user.Role[0] role := user.Role[0]
return role == 'e' || role == 'a' return role == 'e' || role == 'a'
} }
func (user *User) IsAdmin() bool {
return user.Role[0] == 'a'
}

283
pkg/company/admin.go Normal file
View File

@ -0,0 +1,283 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package company
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 AdminHandler struct {
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
}
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:
f := newTaxDetailsForm(r.Context(), conn, user.Locale)
if err := f.FillFromDatabase(r.Context(), company, conn); err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
case http.MethodPut:
editTaxDetails(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
http.NotFound(w, r)
}
})
}
type taxDetailsForm struct {
BusinessName *form.Input
VATIN *form.Input
TradeName *form.Input
Phone *form.Input
Email *form.Input
Web *form.Input
Address *form.Input
City *form.Input
Province *form.Input
PostalCode *form.Input
Country *form.Select
Currency *form.Select
DefaultLanguage *form.Select
InvoiceNumberFormat *form.Input
LegalDisclaimer *form.Input
}
func newTaxDetailsForm(ctx context.Context, conn *database.Conn, l *locale.Locale) *taxDetailsForm {
return &taxDetailsForm{
BusinessName: &form.Input{
Name: "business_name",
},
VATIN: &form.Input{
Name: "vatin",
},
TradeName: &form.Input{
Name: "trade_name",
},
Phone: &form.Input{
Name: "phone",
},
Email: &form.Input{
Name: "email",
},
Web: &form.Input{
Name: "web",
},
Address: &form.Input{
Name: "address",
},
City: &form.Input{
Name: "city",
},
Province: &form.Input{
Name: "province",
},
PostalCode: &form.Input{
Name: "postal_code",
},
Country: &form.Select{
Name: "country",
Options: form.MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language),
},
Currency: &form.Select{
Name: "currency",
Options: form.MustGetOptions(ctx, conn, "select currency_code, currency_symbol from currency order by currency_code"),
},
DefaultLanguage: &form.Select{
Name: "default_language",
Options: form.MustGetOptions(ctx, conn, "select lang_tag, endonym from language where selectable"),
},
InvoiceNumberFormat: &form.Input{
Name: "invoice_number_format",
},
LegalDisclaimer: &form.Input{
Name: "legal_disclaimer",
},
}
}
func (f *taxDetailsForm) FillFromDatabase(ctx context.Context, company *auth.Company, conn *database.Conn) error {
return conn.QueryRow(ctx, `
select business_name
, substr(vatin::text, 3)
, trade_name
, phone
, email
, web
, address
, city
, province
, postal_code
, array[country_code::text]
, array[currency_code::text]
, array[default_lang_tag]
, invoice_number_format
, legal_disclaimer
from company
where company.company_id = $1`, company.ID).Scan(
&f.BusinessName.Val,
&f.VATIN.Val,
&f.TradeName.Val,
&f.Phone.Val,
&f.Email.Val,
&f.Web.Val,
&f.Address.Val,
&f.City.Val,
&f.Province.Val,
&f.PostalCode.Val,
&f.Country.Selected,
&f.Currency.Selected,
&f.DefaultLanguage.Selected,
&f.InvoiceNumberFormat.Val,
&f.LegalDisclaimer.Val,
)
}
func (f *taxDetailsForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.BusinessName.FillValue(r)
f.VATIN.FillValue(r)
f.TradeName.FillValue(r)
f.Phone.FillValue(r)
f.Email.FillValue(r)
f.Web.FillValue(r)
f.Address.FillValue(r)
f.City.FillValue(r)
f.Province.FillValue(r)
f.PostalCode.FillValue(r)
f.Country.FillValue(r)
f.Currency.FillValue(r)
f.DefaultLanguage.FillValue(r)
f.InvoiceNumberFormat.FillValue(r)
f.LegalDisclaimer.FillValue(r)
return nil
}
func (f *taxDetailsForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
var country string
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
country = f.Country.Selected[0]
}
if v.CheckRequired(f.BusinessName, l.GettextNoop("Business name can not be empty.")) {
v.CheckMinLength(f.BusinessName, 2, l.GettextNoop("Business name must have at least two letters."))
}
if v.CheckRequired(f.VATIN, l.GettextNoop("VAT number can not be empty.")) {
if _, err := v.CheckValidVATIN(ctx, conn, f.VATIN, country, l.GettextNoop("This VAT number is not valid.")); err != nil {
return false, err
}
}
if v.CheckRequired(f.Phone, l.GettextNoop("Phone can not be empty.")) {
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
if f.Web.Val != "" {
v.CheckValidURL(f.Web, l.GettextNoop("This web address is not valid. It should be like https://domain.com/."))
}
v.CheckRequired(f.Address, l.GettextNoop("Address can not be empty."))
v.CheckRequired(f.City, l.GettextNoop("City can not be empty."))
v.CheckRequired(f.Province, l.GettextNoop("Province can not be empty."))
if v.CheckRequired(f.PostalCode, l.GettextNoop("Postal code can not be empty.")) {
if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postal code is not valid.")); err != nil {
return false, err
}
}
v.CheckSelectedOptions(f.Currency, l.GettextNoop("Selected currency is not valid."))
v.CheckSelectedOptions(f.DefaultLanguage, l.GettextNoop("Selected language is not valid."))
v.CheckRequired(f.InvoiceNumberFormat, l.GettextNoop("Invoice number format can not be empty."))
return v.AllOK, nil
}
func (f *taxDetailsForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdmin(w, r, user, company, "taxDetails.gohtml", f)
}
func editTaxDetails(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := newTaxDetailsForm(r.Context(), conn, user.Locale)
if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
conn.MustExec(r.Context(), `
update company
set business_name = $1
, vatin = ($11 || $2)::vatin
, trade_name = $3
, phone = parse_packed_phone_number($4, $11)
, email = $5
, web = $6
, address = $7
, city = $8
, province = $9
, postal_code = $10
, country_code = $11
, currency_code = $12
, default_lang_tag = $13
, invoice_number_format = $14
, legal_disclaimer = $15
where company_id = $16
`,
f.BusinessName,
f.VATIN,
f.TradeName,
f.Phone,
f.Email,
f.Web,
f.Address,
f.City,
f.Province,
f.PostalCode,
f.Country,
f.Currency,
f.DefaultLanguage,
f.InvoiceNumberFormat,
f.LegalDisclaimer,
company.ID)
httplib.Redirect(w, r, "/admin/company", http.StatusSeeOther)
}

View File

@ -86,3 +86,11 @@ func (c *Conn) MustGetText(ctx context.Context, sql string, args ...interface{})
panic(err) panic(err)
} }
} }
func (c *Conn) GetBool(ctx context.Context, sql string, args ...interface{}) (bool, error) {
var result bool
if err := c.QueryRow(ctx, sql, args...).Scan(&result); err != nil {
return false, err
}
return result, nil
}

View File

@ -6,9 +6,14 @@
package form package form
import ( import (
"dev.tandem.ws/tandem/camper/pkg/locale" "context"
"errors" "errors"
"net/mail" "net/mail"
"net/url"
"regexp"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/locale"
) )
type Validator struct { type Validator struct {
@ -27,11 +32,48 @@ func (v *Validator) CheckRequired(input *Input, message string) bool {
return v.check(input, input.Val != "", message) return v.check(input, input.Val != "", message)
} }
func (v *Validator) CheckMinLength(input *Input, min int, message string) bool {
return v.check(input, len(input.Val) >= min, message)
}
func (v *Validator) CheckValidEmail(input *Input, message string) bool { func (v *Validator) CheckValidEmail(input *Input, message string) bool {
_, err := mail.ParseAddress(input.Val) _, err := mail.ParseAddress(input.Val)
return v.check(input, err == nil, message) return v.check(input, err == nil, message)
} }
func (v *Validator) CheckValidURL(input *Input, message string) bool {
_, err := url.Parse(input.Val)
return v.check(input, err == nil, message)
}
func (v *Validator) CheckValidVATIN(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
b, err := conn.GetBool(ctx, "select input_is_valid($1 || $2, 'vatin')", country, input.Val)
if err != nil {
return false, err
}
return v.check(input, b, message), nil
}
func (v *Validator) CheckValidPhone(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
b, err := conn.GetBool(ctx, "select input_is_valid_phone($1, $2)", input.Val, country)
if err != nil {
return false, err
}
return v.check(input, b, message), nil
}
func (v *Validator) CheckValidPostalCode(ctx context.Context, conn *database.Conn, input *Input, country string, message string) (bool, error) {
pattern, err := conn.GetText(ctx, "select '^' || postal_code_regex || '$' from country where country_code = $1", country)
if err != nil {
return false, err
}
match, err := regexp.MatchString(pattern, input.Val)
if err != nil {
return false, err
}
return v.check(input, match, message), nil
}
func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool { func (v *Validator) CheckPasswordConfirmation(password *Input, confirm *Input, message string) bool {
return v.check(confirm, password.Val == confirm.Val, message) return v.check(confirm, password.Val == confirm.Val, message)
} }

View File

@ -47,6 +47,7 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ
"isLoggedIn": func() bool { "isLoggedIn": func() bool {
return user.LoggedIn return user.LoggedIn
}, },
"isAdmin": user.IsAdmin,
"CSRFHeader": func() string { "CSRFHeader": func() string {
return fmt.Sprintf(`"%s": "%s"`, auth.CSRFTokenHeader, user.CSRFToken) return fmt.Sprintf(`"%s": "%s"`, auth.CSRFTokenHeader, user.CSRFToken)
}, },

154
po/ca.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-08-14 20:09+0200\n" "POT-Creation-Date: 2023-08-15 22:22+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -164,7 +164,7 @@ msgid "No campsite types added yet."
msgstr "No sha afegit cap tipus dallotjament encara." msgstr "No sha afegit cap tipus dallotjament encara."
#: web/templates/admin/dashboard.gohtml:6 #: web/templates/admin/dashboard.gohtml:6
#: web/templates/admin/dashboard.gohtml:10 web/templates/admin/layout.gohtml:44 #: web/templates/admin/dashboard.gohtml:10 web/templates/admin/layout.gohtml:49
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
msgstr "Tauler" msgstr "Tauler"
@ -175,6 +175,7 @@ msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/templates/admin/login.gohtml:22 web/templates/admin/profile.gohtml:35 #: web/templates/admin/login.gohtml:22 web/templates/admin/profile.gohtml:35
#: web/templates/admin/taxDetails.gohtml:50
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu-e" msgstr "Correu-e"
@ -216,25 +217,103 @@ msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/templates/admin/profile.gohtml:75 #: web/templates/admin/profile.gohtml:75
#: web/templates/admin/taxDetails.gohtml:144
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Desa els canvis" msgstr "Desa els canvis"
#: web/templates/admin/taxDetails.gohtml:6
#: web/templates/admin/taxDetails.gohtml:12
msgctxt "title"
msgid "Tax Details"
msgstr "Configuració fiscal"
#: web/templates/admin/taxDetails.gohtml:17
#: web/templates/admin/taxDetails.gohtml:58
msgctxt "input"
msgid "Business Name"
msgstr "Nom de lempresa"
#: web/templates/admin/taxDetails.gohtml:26
msgctxt "input"
msgid "VAT Number"
msgstr "NIF"
#: web/templates/admin/taxDetails.gohtml:34
msgctxt "input"
msgid "Trade Name"
msgstr "Nom comercial"
#: web/templates/admin/taxDetails.gohtml:42
msgctxt "input"
msgid "Phone"
msgstr "Telèfon"
#: web/templates/admin/taxDetails.gohtml:66
msgctxt "input"
msgid "Address"
msgstr "Adreça"
#: web/templates/admin/taxDetails.gohtml:74
msgctxt "input"
msgid "City"
msgstr "Població"
#: web/templates/admin/taxDetails.gohtml:82
msgctxt "input"
msgid "Province"
msgstr "Província"
#: web/templates/admin/taxDetails.gohtml:90
msgctxt "input"
msgid "Postal Code"
msgstr "Codi postal"
#: web/templates/admin/taxDetails.gohtml:98
msgctxt "input"
msgid "Country"
msgstr "País"
#: web/templates/admin/taxDetails.gohtml:108
msgctxt "input"
msgid "Currency"
msgstr "Moneda"
#: web/templates/admin/taxDetails.gohtml:118
msgctxt "input"
msgid "Default Language"
msgstr "Idioma per defecte"
#: web/templates/admin/taxDetails.gohtml:128
msgctxt "input"
msgid "Invoice Number Format"
msgstr "Format del número de factura"
#: web/templates/admin/taxDetails.gohtml:136
msgctxt "input"
msgid "Legal Disclaimer"
msgstr "Nota legal"
#: web/templates/admin/layout.gohtml:25 #: web/templates/admin/layout.gohtml:25
msgctxt "title" msgctxt "title"
msgid "User Menu" msgid "User Menu"
msgstr "Menú dusuari" msgstr "Menú dusuari"
#: web/templates/admin/layout.gohtml:33 #: web/templates/admin/layout.gohtml:33
msgctxt "title"
msgid "Company Settings"
msgstr "Paràmetres de lempresa"
#: web/templates/admin/layout.gohtml:38
msgctxt "action" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" msgstr "Surt"
#: pkg/app/login.go:56 pkg/app/user.go:246 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podeu deixar el correu-e en blanc." msgstr "No podeu deixar el correu-e en blanc."
#: pkg/app/login.go:57 pkg/app/user.go:247 #: pkg/app/login.go:57 pkg/app/user.go:247 pkg/company/admin.go:204
msgid "This email is not valid. It should be like name@domain.com." msgid "This email is not valid. It should be like name@domain.com."
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com." msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
@ -259,7 +338,7 @@ msgstr "No podeu deixar el nom en blanc."
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmació no es correspon amb la contrasenya." msgstr "La confirmació no es correspon amb la contrasenya."
#: pkg/app/user.go:251 #: pkg/app/user.go:251 pkg/company/admin.go:218
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid." msgstr "Lidioma escollit no és vàlid."
@ -267,7 +346,7 @@ msgstr "Lidioma escollit no és vàlid."
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida." msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/app/admin.go:37 #: pkg/app/admin.go:40
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Accés prohibit" msgstr "Accés prohibit"
@ -279,6 +358,66 @@ msgstr "El tipus dallotjament escollit no és vàlid."
msgid "Label can not be empty." msgid "Label can not be empty."
msgstr "No podeu deixar letiqueta en blanc." msgstr "No podeu deixar letiqueta en blanc."
#: pkg/company/admin.go:186
msgid "Selected country is not valid."
msgstr "El país escollit no és vàlid."
#: pkg/company/admin.go:190
msgid "Business name can not be empty."
msgstr "No podeu deixar el nom dempresa en blanc."
#: pkg/company/admin.go:191
msgid "Business name must have at least two letters."
msgstr "El nom dempresa ha de tenir com a mínim dues lletres."
#: pkg/company/admin.go:193
msgid "VAT number can not be empty."
msgstr "No podeu deixar el NIF en blanc."
#: pkg/company/admin.go:194
msgid "This VAT number is not valid."
msgstr "Aquest NIF no és vàlid."
#: pkg/company/admin.go:198
msgid "Phone can not be empty."
msgstr "No podeu deixar el telèfon en blanc."
#: pkg/company/admin.go:199
msgid "This phone number is not valid."
msgstr "Aquest número de telèfon no és vàlid."
#: pkg/company/admin.go:207
msgid "This web address is not valid. It should be like https://domain.com/."
msgstr "Aquesta adreça web no és vàlida. Hauria de ser similar a https://domini.com/."
#: pkg/company/admin.go:209
msgid "Address can not be empty."
msgstr "No podeu deixar ladreça en blanc."
#: pkg/company/admin.go:210
msgid "City can not be empty."
msgstr "No podeu deixar la població en blanc."
#: pkg/company/admin.go:211
msgid "Province can not be empty."
msgstr "No podeu deixar la província en blanc."
#: pkg/company/admin.go:212
msgid "Postal code can not be empty."
msgstr "No podeu deixar el codi postal en blanc."
#: pkg/company/admin.go:213
msgid "This postal code is not valid."
msgstr "Aquest codi postal no és vàlid."
#: pkg/company/admin.go:217
msgid "Selected currency is not valid."
msgstr "La moneda escollida no és vàlida."
#: pkg/company/admin.go:219
msgid "Invoice number format can not be empty."
msgstr "No podeu deixar el format del número de factura en blanc."
#: pkg/auth/user.go:40 #: pkg/auth/user.go:40
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats." msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."
@ -313,6 +452,3 @@ msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."
#~ msgid "No pages added yet." #~ msgid "No pages added yet."
#~ msgstr "No sha afegit cap pàgina encara." #~ msgstr "No sha afegit cap pàgina encara."
#~ msgid "Title can not be empty."
#~ msgstr "No podeu deixar el títol en blanc."

154
po/es.po
View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-08-14 20:09+0200\n" "POT-Creation-Date: 2023-08-15 22:23+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -164,7 +164,7 @@ msgid "No campsite types added yet."
msgstr "No se ha añadido ningún tipo de alojamiento todavía." msgstr "No se ha añadido ningún tipo de alojamiento todavía."
#: web/templates/admin/dashboard.gohtml:6 #: web/templates/admin/dashboard.gohtml:6
#: web/templates/admin/dashboard.gohtml:10 web/templates/admin/layout.gohtml:44 #: web/templates/admin/dashboard.gohtml:10 web/templates/admin/layout.gohtml:49
msgctxt "title" msgctxt "title"
msgid "Dashboard" msgid "Dashboard"
msgstr "Panel" msgstr "Panel"
@ -175,6 +175,7 @@ msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/templates/admin/login.gohtml:22 web/templates/admin/profile.gohtml:35 #: web/templates/admin/login.gohtml:22 web/templates/admin/profile.gohtml:35
#: web/templates/admin/taxDetails.gohtml:50
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo-e" msgstr "Correo-e"
@ -216,25 +217,103 @@ msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/templates/admin/profile.gohtml:75 #: web/templates/admin/profile.gohtml:75
#: web/templates/admin/taxDetails.gohtml:144
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Guardar los cambios" msgstr "Guardar los cambios"
#: web/templates/admin/taxDetails.gohtml:6
#: web/templates/admin/taxDetails.gohtml:12
msgctxt "title"
msgid "Tax Details"
msgstr "Configuración fiscal"
#: web/templates/admin/taxDetails.gohtml:17
#: web/templates/admin/taxDetails.gohtml:58
msgctxt "input"
msgid "Business Name"
msgstr "Nombre de empresa"
#: web/templates/admin/taxDetails.gohtml:26
msgctxt "input"
msgid "VAT Number"
msgstr "NIF"
#: web/templates/admin/taxDetails.gohtml:34
msgctxt "input"
msgid "Trade Name"
msgstr "Nombre comercial"
#: web/templates/admin/taxDetails.gohtml:42
msgctxt "input"
msgid "Phone"
msgstr "Teléfono"
#: web/templates/admin/taxDetails.gohtml:66
msgctxt "input"
msgid "Address"
msgstr "Dirección"
#: web/templates/admin/taxDetails.gohtml:74
msgctxt "input"
msgid "City"
msgstr "Población"
#: web/templates/admin/taxDetails.gohtml:82
msgctxt "input"
msgid "Province"
msgstr "Provincia"
#: web/templates/admin/taxDetails.gohtml:90
msgctxt "input"
msgid "Postal Code"
msgstr "Código postal"
#: web/templates/admin/taxDetails.gohtml:98
msgctxt "input"
msgid "Country"
msgstr "País"
#: web/templates/admin/taxDetails.gohtml:108
msgctxt "input"
msgid "Currency"
msgstr "Moneda"
#: web/templates/admin/taxDetails.gohtml:118
msgctxt "input"
msgid "Default Language"
msgstr "Idioma por defecto"
#: web/templates/admin/taxDetails.gohtml:128
msgctxt "input"
msgid "Invoice Number Format"
msgstr "Formato de número de factura"
#: web/templates/admin/taxDetails.gohtml:136
msgctxt "input"
msgid "Legal Disclaimer"
msgstr "Nota legal"
#: web/templates/admin/layout.gohtml:25 #: web/templates/admin/layout.gohtml:25
msgctxt "title" msgctxt "title"
msgid "User Menu" msgid "User Menu"
msgstr "Menú de usuario" msgstr "Menú de usuario"
#: web/templates/admin/layout.gohtml:33 #: web/templates/admin/layout.gohtml:33
msgctxt "title"
msgid "Company Settings"
msgstr "Parámetros de la empresa"
#: web/templates/admin/layout.gohtml:38
msgctxt "action" msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" msgstr "Salir"
#: pkg/app/login.go:56 pkg/app/user.go:246 #: pkg/app/login.go:56 pkg/app/user.go:246 pkg/company/admin.go:203
msgid "Email can not be empty." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." msgstr "No podéis dejar el correo-e en blanco."
#: pkg/app/login.go:57 pkg/app/user.go:247 #: pkg/app/login.go:57 pkg/app/user.go:247 pkg/company/admin.go:204
msgid "This email is not valid. It should be like name@domain.com." msgid "This email is not valid. It should be like name@domain.com."
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com." msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
@ -259,7 +338,7 @@ msgstr "No podéis dejar el nombre en blanco."
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmación no se corresponde con la contraseña." msgstr "La confirmación no se corresponde con la contraseña."
#: pkg/app/user.go:251 #: pkg/app/user.go:251 pkg/company/admin.go:218
msgid "Selected language is not valid." msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido." msgstr "El idioma escogido no es válido."
@ -267,7 +346,7 @@ msgstr "El idioma escogido no es válido."
msgid "File must be a valid PNG or JPEG image." msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida." msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/app/admin.go:37 #: pkg/app/admin.go:40
msgid "Access forbidden" msgid "Access forbidden"
msgstr "Acceso prohibido" msgstr "Acceso prohibido"
@ -279,6 +358,66 @@ msgstr "El tipo de alojamiento escogido no es válido."
msgid "Label can not be empty." msgid "Label can not be empty."
msgstr "No podéis dejar la etiqueta en blanco." msgstr "No podéis dejar la etiqueta en blanco."
#: pkg/company/admin.go:186
msgid "Selected country is not valid."
msgstr "El país escogido no es válido."
#: pkg/company/admin.go:190
msgid "Business name can not be empty."
msgstr "No podéis dejar el nombre de empresa en blanco."
#: pkg/company/admin.go:191
msgid "Business name must have at least two letters."
msgstr "El nombre de la empresa tiene que tener como mínimo dos letras."
#: pkg/company/admin.go:193
msgid "VAT number can not be empty."
msgstr "No podéis dejar el NIF en blanco."
#: pkg/company/admin.go:194
msgid "This VAT number is not valid."
msgstr "Este NIF no es válido."
#: pkg/company/admin.go:198
msgid "Phone can not be empty."
msgstr "No podéis dejar el teléfono en blanco."
#: pkg/company/admin.go:199
msgid "This phone number is not valid."
msgstr "Este teléfono no es válido."
#: pkg/company/admin.go:207
msgid "This web address is not valid. It should be like https://domain.com/."
msgstr "Esta dirección web no es válida. Tiene que ser parecido a https://dominio.com/."
#: pkg/company/admin.go:209
msgid "Address can not be empty."
msgstr "No podéis dejar la dirección en blanco."
#: pkg/company/admin.go:210
msgid "City can not be empty."
msgstr "No podéis dejar la población en blanco."
#: pkg/company/admin.go:211
msgid "Province can not be empty."
msgstr "No podéis dejar la provincia en blanco."
#: pkg/company/admin.go:212
msgid "Postal code can not be empty."
msgstr "No podéis dejar el código postal en blanco."
#: pkg/company/admin.go:213
msgid "This postal code is not valid."
msgstr "Este código postal no es válido."
#: pkg/company/admin.go:217
msgid "Selected currency is not valid."
msgstr "La moneda escogida no es válida."
#: pkg/company/admin.go:219
msgid "Invoice number format can not be empty."
msgstr "No podéis dejar el formato de número de factura en blanco."
#: pkg/auth/user.go:40 #: pkg/auth/user.go:40
msgid "Cross-site request forgery detected." msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados." msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."
@ -313,6 +452,3 @@ msgstr "Se ha detectado un intento de falsificación de petición en sitios cruz
#~ msgid "No pages added yet." #~ msgid "No pages added yet."
#~ msgstr "No se ha añadido ninguna página todavía." #~ msgstr "No se ha añadido ninguna página todavía."
#~ msgid "Title can not be empty."
#~ msgstr "No podéis dejar el título en blanco."

View File

@ -0,0 +1,7 @@
-- Revert camper:input_is_valid from pg
begin;
drop function if exists public.input_is_valid(text, text);
commit;

View File

@ -0,0 +1,7 @@
-- Revert camper:input_is_valid_phone from pg
begin;
drop function if exists public.input_is_valid_phone(text, text);
commit;

View File

@ -45,3 +45,5 @@ edit_campsite_type [roles schema_camper campsite_type company] 2023-08-07T22:21:
campsite [roles schema_camper company campsite_type user_profile] 2023-08-14T10:11:51Z jordi fita mas <jordi@tandem.blog> # Add campsite relation campsite [roles schema_camper company campsite_type user_profile] 2023-08-14T10:11:51Z jordi fita mas <jordi@tandem.blog> # Add campsite relation
add_campsite [roles schema_camper campsite campsite_type] 2023-08-14T17:03:23Z jordi fita mas <jordi@tandem.blog> # Add function to create campsites add_campsite [roles schema_camper campsite campsite_type] 2023-08-14T17:03:23Z jordi fita mas <jordi@tandem.blog> # Add function to create campsites
edit_campsite [roles schema_camper campsite] 2023-08-14T17:28:16Z jordi fita mas <jordi@tandem.blog> # Add function to update campsites edit_campsite [roles schema_camper campsite] 2023-08-14T17:28:16Z jordi fita mas <jordi@tandem.blog> # Add function to update campsites
input_is_valid [roles schema_public] 2023-08-15T20:10:59Z jordi fita mas <jordi@tandem.blog> # Add function to check if an input string is valid for a domain
input_is_valid_phone [roles schema_public extension_pg_libphonenumber] 2023-08-15T20:15:01Z jordi fita mas <jordi@tandem.blog> # Add function to check if an input string is valid for the phone number domain

45
test/input_is_valid.sql Normal file
View File

@ -0,0 +1,45 @@
-- Test input_is_valid
set client_min_messages to warning;
create extension if not exists pgtap;
reset client_min_messages;
begin;
select plan(28);
set search_path to camper, public;
select has_function('public', 'input_is_valid', array ['text', 'text']);
select function_lang_is('public', 'input_is_valid', array ['text', 'text'], 'plpgsql');
select function_returns('public', 'input_is_valid', array ['text', 'text'], 'boolean');
select isnt_definer('public', 'input_is_valid', array ['text', 'text']);
select volatility_is('public', 'input_is_valid', array ['text', 'text'], 'stable');
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'guest', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'employee', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'admin', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid', array ['text', 'text'], 'authenticator', array ['EXECUTE']);
select is( input_is_valid('123', 'integer'), true );
select is( input_is_valid('abc', 'integer'), false );
select is( input_is_valid('abc', 'email'), false );
select is( input_is_valid('ESabc', 'vatin'), false );
select is( input_is_valid('abc', 'text'), true );
select is( input_is_valid('ES44444444A', 'vatin'), true );
select is( input_is_valid('ES44444444A', 'text'), true );
select is( input_is_valid('ES44444444A', 'email'), false );
select is( input_is_valid('NL04RABO9373475770', 'text'), true );
select is( input_is_valid('ESNL04RABO9373475770', 'vatin'), false );
select is( input_is_valid('NL04RABO9373475770', 'email'), false );
select is( input_is_valid('ARBNNL22', 'text'), true );
select is( input_is_valid('ESARBNNL22', 'vatin'), false );
select is( input_is_valid('ARBNNL22', 'email'), false );
select is( input_is_valid('2023-05-12', 'text'), true );
select is( input_is_valid('2023-05-12', 'date'), true );
select is( input_is_valid('2023-05-12', 'integer'), false );
select is( input_is_valid('', 'text'), true );
select is( input_is_valid('', 'inexistent'), false );
select *
from finish();
rollback;

View File

@ -0,0 +1,32 @@
-- Test input_is_valid_phone
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('public', 'input_is_valid_phone', array ['text', 'text']);
select function_lang_is('public', 'input_is_valid_phone', array ['text', 'text'], 'plpgsql');
select function_returns('public', 'input_is_valid_phone', array ['text', 'text'], 'boolean');
select isnt_definer('public', 'input_is_valid_phone', array ['text', 'text']);
select volatility_is('public', 'input_is_valid_phone', array ['text', 'text'], 'stable');
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'guest', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'employee', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'admin', array ['EXECUTE']);
select function_privs_are('public', 'input_is_valid_phone', array ['text', 'text'], 'authenticator', array ['EXECUTE']);
select is( input_is_valid_phone('555-555-5555', 'US'), true );
select is( input_is_valid_phone('555-555-5555555555', 'US'), false );
select is( input_is_valid_phone('555-555-55555555555', 'US'), false );
select *
from finish();
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:input_is_valid on pg
begin;
select has_function_privilege('public.input_is_valid(text, text)', 'execute');
rollback;

View File

@ -0,0 +1,7 @@
-- Verify camper:input_is_valid_phone on pg
begin;
select has_function_privilege('public.input_is_valid_phone(text, text)', 'execute');
rollback;

View File

@ -28,6 +28,11 @@
<li class="icon_profile"> <li class="icon_profile">
<a href="/me">{{( pgettext "Profile" "title" )}}</a> <a href="/me">{{( pgettext "Profile" "title" )}}</a>
</li> </li>
{{ if isAdmin -}}
<li class="icon_company">
<a href="/admin/company">{{( pgettext "Company Settings" "title" )}}</a>
</li>
{{- end }}
<li class="icon_logout"> <li class="icon_logout">
<button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }' <button data-hx-delete="/me/session" data-hx-headers='{ {{ CSRFHeader }} }'
>{{( pgettext "Logout" "action" )}}</button> >{{( pgettext "Logout" "action" )}}</button>

View File

@ -0,0 +1,147 @@
<!--
SPDX-FileCopyrightText: 2023 jordi fita mas <jordi@tandem.blog>
SPDX-License-Identifier: AGPL-3.0-only
-->
{{ define "title" -}}
{{( pgettext "Tax Details" "title" )}}
{{- end }}
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/company.taxDetailsForm*/ -}}
<form data-hx-put="/admin/company">
<h2>{{( pgettext "Tax Details" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .BusinessName -}}
<label>
{{( pgettext "Business Name" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required autocomplete="organization" minlength="2"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .VATIN -}}
<label>
{{( pgettext "VAT Number" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .TradeName -}}
<label>
{{( pgettext "Trade Name" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Phone -}}
<label>
{{( pgettext "Phone" "input")}}<br>
<input type="tel" name="{{ .Name }}" value="{{ .Val }}"
required autocomplete="tel" {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Email -}}
<label>
{{( pgettext "Email" "input")}}<br>
<input type="email" name="{{ .Name }}" value="{{ .Val }}"
required autocomplete="email" {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Web -}}
<label>
{{( pgettext "Business Name" "input")}}<br>
<input type="url" name="{{ .Name }}" value="{{ .Val }}"
autocomplete="url" {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Address -}}
<label>
{{( pgettext "Address" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required autocomplete="address-line1" {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .City -}}
<label>
{{( pgettext "City" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Province -}}
<label>
{{( pgettext "Province" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .PostalCode -}}
<label>
{{( pgettext "Postal Code" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required autocomplete="postal-code" {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Country -}}
<label>
{{( pgettext "Country" "input")}}<br>
<select name="{{ .Name }}"
required autocomplete="country"
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Currency -}}
<label>
{{( pgettext "Currency" "input")}}<br>
<select name="{{ .Name }}"
required
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .DefaultLanguage -}}
<label>
{{( pgettext "Default Language" "input")}}<br>
<select name="{{ .Name }}"
required
{{ template "error-attrs" . }}>{{ template "list-options" . }}
</select><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .InvoiceNumberFormat -}}
<label>
{{( pgettext "Invoice Number Format" "input")}}<br>
<input type="text" name="{{ .Name }}" value="{{ .Val }}"
required {{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .LegalDisclaimer -}}
<label>
{{( pgettext "Legal Disclaimer" "input")}}<br>
<textarea name="{{ .Name }}"
{{ template "error-attrs" . }}>{{ .Val }}</textarea><br>
</label>
{{ template "error-message" . }}
{{- end }}
</fieldset>
<footer>
<button type="submit">{{( pgettext "Save changes" "action" )}}</button>
</footer>
</form>
{{- end }}