Implement user editing

main
Jan Dittberner 2 years ago
parent 5efc57d2c3
commit a5475ec16e

@ -612,8 +612,8 @@ func (app *application) userList(w http.ResponseWriter, r *http.Request) {
users, err := app.users.List(
r.Context(),
app.users.WithRolesOption(),
app.users.CanDeleteOption(),
app.users.WithRoles(),
app.users.CanDelete(),
)
if err != nil {
app.serverError(w, err)
@ -631,14 +631,121 @@ func (app *application) submitUserRoles(_ http.ResponseWriter, _ *http.Request)
panic("not implemented")
}
func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement editUserForm
panic("not implemented")
func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses())
if userToEdit == nil {
return
}
roles, err := userToEdit.Roles()
if err != nil {
app.serverError(w, err)
return
}
emailAddresses, err := userToEdit.EmailAddresses()
if err != nil {
app.serverError(w, err)
return
}
roleNames := make([]string, len(roles))
for i := range roles {
roleNames[i] = roles[i].Name
}
data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers)
data.Form = &forms.EditUserForm{
User: userToEdit,
Name: userToEdit.Name,
MailAddresses: emailAddresses,
AllRoles: models.AllRoles,
ReminderMail: userToEdit.Reminder.String,
Roles: roleNames,
}
app.render(w, http.StatusOK, "edit_user.html", data)
}
func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement editUserSubmit
panic("not implemented")
func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context())
userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses())
if userToEdit == nil {
return
}
var form forms.EditUserForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.User = userToEdit
form.AllRoles = models.AllRoles
if err := form.Validate(); err != nil {
app.serverError(w, err)
return
}
data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers)
if !form.Valid() {
roles, err := userToEdit.Roles()
if err != nil {
app.serverError(w, err)
return
}
emailAddresses, err := userToEdit.EmailAddresses()
if err != nil {
app.serverError(w, err)
return
}
roleNames := make([]string, len(roles))
for i := range roles {
roleNames[i] = roles[i].Name
}
form.MailAddresses = emailAddresses
form.Roles = roleNames
data.Form = &form
app.render(w, http.StatusUnprocessableEntity, "edit_user.html", data)
return
}
form.Normalize()
form.UpdateUser(userToEdit)
if err = app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil {
app.serverError(w, err)
return
}
app.sessionManager.Put(
r.Context(),
"flash",
fmt.Sprintf("User %s has been modified.", userToEdit.Name),
)
http.Redirect(w, r, "/users/", http.StatusSeeOther)
}
func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) {
@ -651,8 +758,28 @@ func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Reques
panic("not implemented")
}
func (app *application) userDeleteEmailForm(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement userDeleteEmailForm
panic("not implemented")
}
func (app *application) userDeleteEmailSubmit(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement userDeleteEmailSubmit
panic("not implemented")
}
func (app *application) newUserForm(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement newUserForm
panic("not implemented")
}
func (app *application) newUserSubmit(_ http.ResponseWriter, _ *http.Request) {
// TODO: implement userDeleteEmailSubmit
panic("not implemented")
}
func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) {
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete())
if userToDelete == nil {
return
}
@ -673,7 +800,7 @@ func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) {
}
func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) {
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete())
if userToDelete == nil {
return
}

@ -210,7 +210,7 @@ func (app *application) startHTTPSServer(config *Config) error {
}
func (app *application) getVoter(w http.ResponseWriter, r *http.Request, voterID int64) *models.User {
voter, err := app.users.ByID(r.Context(), voterID, app.users.WithRolesOption())
voter, err := app.users.ByID(r.Context(), voterID, app.users.WithRoles())
if err != nil {
app.serverError(w, err)

@ -188,18 +188,24 @@ type RemindVoterNotification struct {
}
func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent {
recipientAddress := make([]recipientData, 0)
if r.voter.Reminder.Valid {
recipientAddress = append(recipientAddress, recipientData{
field: "To",
address: r.voter.Reminder.String,
name: r.voter.Name,
})
}
return &NotificationContent{
template: "remind_voter_mail.txt",
data: struct {
Decisions []*models.Motion
Name string
}{Decisions: r.decisions, Name: r.voter.Name},
subject: "Outstanding CAcert board votes",
recipients: []recipientData{{
field: "To",
address: r.voter.Reminder,
name: r.voter.Name,
}},
subject: "Outstanding CAcert board votes",
recipients: recipientAddress,
}
}

@ -84,10 +84,16 @@ func (app *application) routes() http.Handler {
router.Handler(http.MethodGet, "/users/", canManageUsers.ThenFunc(app.userList))
router.Handler(http.MethodPost, "/users/", canManageUsers.ThenFunc(app.submitUserRoles))
router.Handler(http.MethodGet, "/new-user/", canManageUsers.ThenFunc(app.newUserForm))
router.Handler(http.MethodPost, "/new-user/", canManageUsers.ThenFunc(app.newUserSubmit))
router.Handler(http.MethodGet, "/users/:id/", canManageUsers.ThenFunc(app.editUserForm))
router.Handler(http.MethodPost, "/users/:id/", canManageUsers.ThenFunc(app.editUserSubmit))
router.Handler(http.MethodGet, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailForm))
router.Handler(http.MethodPost, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailSubmit))
router.Handler(http.MethodGet, "/users/:id/mail/:address/delete",
canManageUsers.ThenFunc(app.userDeleteEmailForm))
router.Handler(http.MethodPost, "/users/:id/mail/:address/delete",
canManageUsers.ThenFunc(app.userDeleteEmailSubmit))
router.Handler(http.MethodGet, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserForm))
router.Handler(http.MethodPost, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserSubmit))

@ -120,6 +120,67 @@ func (f *ProxyVoteForm) Normalize() {
f.Justification = strings.TrimSpace(f.Justification)
}
type EditUserForm struct {
User *models.User `form:"-"`
MailAddresses []string `form:"-"`
AllRoles []*models.Role `form:"-"`
Name string `form:"name"`
Roles []string `form:"roles"`
ReminderMail string `form:"reminder_mail"`
Reasoning string `form:"reasoning"`
validator.Validator `form:"-"`
}
func (f *EditUserForm) Validate() error {
addresses, err := f.User.EmailAddresses()
if err != nil {
return fmt.Errorf("error while validating form: %w", err)
}
f.CheckField(validator.NotBlank(f.Name), "name", "This field cannot be blank")
f.CheckField(
validator.NotBlank(f.ReminderMail),
"reminder_mail",
"Must choose a reminder mail address",
)
f.CheckField(
validator.PermittedString(f.ReminderMail, addresses...),
"reminder_mail",
"Reminder mail must be one of the user's email addresses",
)
allRoleNames := make([]string, len(f.AllRoles))
for i := range f.AllRoles {
allRoleNames[i] = f.AllRoles[i].Name
}
f.CheckField(
validator.PermittedStringSet(f.Roles, allRoleNames),
"roles",
fmt.Sprintf("Roles must only contain values from %s", strings.Join(allRoleNames, ", ")),
)
f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank")
f.CheckField(
validator.MinChars(f.Reasoning, minimumJustificationLen),
"reasoning",
fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen),
)
return nil
}
func (f *EditUserForm) Normalize() {
f.Name = strings.TrimSpace(f.Name)
f.Reasoning = strings.TrimSpace(f.Reasoning)
}
func (f *EditUserForm) UpdateUser(edit *models.User) {
edit.Reminder.String = f.ReminderMail
edit.Name = f.Name
}
type DeleteUserForm struct {
User *models.User `form:"user"`
Reasoning string `form:"reasoning"`

@ -19,9 +19,13 @@ package models
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jmoiron/sqlx"
"golang.org/x/text/cases"
@ -47,6 +51,10 @@ func (r *Role) Scan(src any) error {
return nil
}
func (r *Role) Value() (driver.Value, error) {
return r.Name, nil
}
type RoleName string
const (
@ -55,6 +63,12 @@ const (
RoleVoter RoleName = "VOTER"
)
var AllRoles = []*Role{
{Name: string(RoleVoter)},
{Name: string(RoleSecretary)},
{Name: string(RoleAdmin)},
}
// The User type is used for mapping users from the voters table. The table
// has two columns that are obsolete:
//
@ -64,11 +78,12 @@ const (
// The columns cannot be dropped in SQLite without dropping and recreating
// the voters table and all foreign key indices pointing to that table.
type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
Reminder string `db:"reminder"` // reminder email address
roles []*Role `db:"-"`
canDelete bool
ID int64 `db:"id"`
Name string `db:"name"`
Reminder sql.NullString `db:"reminder"` // reminder email address
roles []*Role `db:"-"`
emailAddresses []string `db:"-"`
canDelete bool `db:"-"`
}
func (u *User) Roles() ([]*Role, error) {
@ -76,7 +91,15 @@ func (u *User) Roles() ([]*Role, error) {
return u.roles, nil
}
return nil, errors.New("call to Roles required")
return nil, errors.New("WithRoles required")
}
func (u *User) EmailAddresses() ([]string, error) {
if u.emailAddresses != nil {
return u.emailAddresses, nil
}
return nil, errors.New("WithEmailAddressesOption required")
}
func (u *User) HasRole(roles ...RoleName) (bool, error) {
@ -105,6 +128,40 @@ func (u *User) CanDelete() bool {
return u.canDelete
}
func (u *User) rolesAndAddresses() ([]string, []string) {
roleNames := make([]string, len(u.roles))
for i := range u.roles {
roleNames[i] = u.roles[i].Name
}
return roleNames, u.emailAddresses
}
func (u *User) rolesEqual(other *User) bool {
roles1 := u.roles
roles2 := other.roles
if len(roles1) != len(roles2) {
return false
}
sort.SliceStable(roles1, func(i, j int) bool { return roles1[i].Name < roles1[j].Name })
sort.SliceStable(roles2, func(i, j int) bool { return roles2[i].Name < roles2[j].Name })
for i, v := range roles1 {
if v.Name != roles2[i].Name {
return false
}
}
return true
}
func (u *User) reminderEqual(other *User) bool {
return u.Reminder.Valid == other.Reminder.Valid && u.Reminder.String == other.Reminder.String
}
type UserModel struct {
DB *sqlx.DB
}
@ -325,9 +382,8 @@ func (m *UserModel) ByID(ctx context.Context, voterID int64, options ...UserList
ctx,
`SELECT DISTINCT v.id, v.name, e.address AS reminder
FROM voters v
LEFT OUTER JOIN emails e ON e.voter = v.id
LEFT OUTER JOIN emails e ON e.voter = v.id AND e.reminder = TRUE
WHERE v.id = ?
AND e.reminder = TRUE
`,
voterID,
)
@ -387,17 +443,85 @@ func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*Use
return users, nil
}
func (m *UserModel) WithRolesOption() UserListOption {
func (m *UserModel) WithEmailAddresses() UserListOption {
return func(ctx context.Context, users []*User) error {
if len(users) == 0 {
return nil
}
userIDs := make([]int64, len(users))
userIDs := userIDsFromUsers(users)
query, args, err := sqlx.In(
`SELECT voter, address FROM emails WHERE voter IN (?)`,
userIDs,
)
if err != nil {
return errCouldNotCreateInQuery(err)
}
rows, err := m.DB.QueryxContext(ctx, query, args...)
if err != nil {
return errCouldNotExecuteQuery(err)
}
defer func(rows *sqlx.Rows) {
_ = rows.Close()
}(rows)
addressMap, err := buildAddressMap(rows, len(users))
if err != nil {
return err
}
for idx := range users {
userIDs[idx] = users[idx].ID
if addresses, ok := addressMap[users[idx].ID]; ok {
users[idx].emailAddresses = addresses
continue
}
users[idx].emailAddresses = make([]string, 0)
}
return nil
}
}
func buildAddressMap(rows *sqlx.Rows, userCount int) (map[int64][]string, error) {
addressMap := make(map[int64][]string, userCount)
for rows.Next() {
if err := rows.Err(); err != nil {
return nil, errCouldNotFetchRow(err)
}
var (
userID int64
address string
)
if err := rows.Scan(&userID, &address); err != nil {
return nil, errCouldNotScanResult(err)
}
if _, ok := addressMap[userID]; !ok {
addressMap[userID] = []string{address}
} else {
addressMap[userID] = append(addressMap[userID], address)
}
}
return addressMap, nil
}
func (m *UserModel) WithRoles() UserListOption {
return func(ctx context.Context, users []*User) error {
if len(users) == 0 {
return nil
}
userIDs := userIDsFromUsers(users)
query, args, err := sqlx.In(
`SELECT voter_id, role FROM user_roles WHERE voter_id IN (?)`,
userIDs,
@ -434,6 +558,14 @@ func (m *UserModel) WithRolesOption() UserListOption {
}
}
func userIDsFromUsers(users []*User) []int64 {
userIDs := make([]int64, len(users))
for idx := range users {
userIDs[idx] = users[idx].ID
}
return userIDs
}
func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) {
roleMap := make(map[int64][]*Role, userCount)
@ -461,7 +593,7 @@ func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) {
return roleMap, nil
}
func (m *UserModel) CanDeleteOption() UserListOption {
func (m *UserModel) CanDelete() UserListOption {
return func(ctx context.Context, users []*User) error {
if len(users) == 0 {
return nil
@ -520,7 +652,7 @@ func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, r
userInfo := struct {
Name string `json:"user"`
Address string `json:"address"`
}{Name: userToDelete.Name, Address: userToDelete.Reminder}
}{Name: userToDelete.Name, Address: userToDelete.Reminder.String}
details := struct {
User any `json:"user"`
}{User: userInfo}
@ -552,3 +684,145 @@ func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, r
return nil
}
func (m *UserModel) EditUser(ctx context.Context, edit *User, admin *User, roles []string, reasoning string) error {
newRoles := make([]*Role, len(roles))
for i := range roles {
newRoles[i] = &Role{Name: roles[i]}
}
edit.roles = newRoles
userBefore, err := m.ByID(ctx, edit.ID, m.WithRoles(), m.WithEmailAddresses())
if err != nil {
return err
}
tx, err := m.DB.BeginTxx(ctx, nil)
if err != nil {
return errCouldNotStartTransaction(err)
}
defer func(tx *sqlx.Tx) {
_ = tx.Rollback()
}(tx)
if edit.Name != userBefore.Name {
if _, err = tx.NamedExecContext(ctx, `UPDATE voters SET name=:name WHERE id=:id`, edit); err != nil {
return errCouldNotExecuteQuery(err)
}
}
if !userBefore.rolesEqual(edit) {
if err = updateRoles(ctx, tx, edit); err != nil {
return err
}
}
if !userBefore.reminderEqual(edit) {
if err = updateReminder(ctx, tx, edit); err != nil {
return err
}
}
if err = AuditLog(ctx, tx, admin, AuditEditUser, reasoning, userChangeInfo(userBefore, edit)); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return errCouldNotCommitTransaction(err)
}
return nil
}
func updateReminder(ctx context.Context, tx *sqlx.Tx, edit *User) error {
if _, err := tx.ExecContext(
ctx,
`UPDATE emails SET reminder=TRUE WHERE address=? AND voter=?`,
edit.Reminder.String,
edit.ID,
); err != nil {
return errCouldNotExecuteQuery(err)
}
if _, err := tx.ExecContext(
ctx,
`UPDATE emails SET reminder=FALSE WHERE address<>? AND voter=?`,
edit.Reminder.String,
edit.ID,
); err != nil {
return errCouldNotExecuteQuery(err)
}
return nil
}
func updateRoles(ctx context.Context, tx *sqlx.Tx, user *User) error {
if len(user.roles) == 0 {
if _, err := tx.ExecContext(ctx, `DELETE FROM user_roles WHERE voter_id=?`, user.ID); err != nil {
return errCouldNotExecuteQuery(err)
}
return nil
}
query, args, err := sqlx.In(
`DELETE FROM user_roles WHERE role NOT IN (?) AND voter_id=?`,
user.roles,
user.ID,
)
if err != nil {
return errCouldNotCreateInQuery(err)
}
if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return errCouldNotExecuteQuery(err)
}
for idx := range user.roles {
if _, err = tx.ExecContext(
ctx,
`INSERT INTO user_roles (voter_id, role, created)
SELECT ?, ?, ?
WHERE NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = ? AND role = ?)`,
user.ID,
user.roles[idx],
time.Now().UTC(),
user.ID,
user.roles[idx],
); err != nil {
return errCouldNotExecuteQuery(err)
}
}
return nil
}
func userChangeInfo(before *User, after *User) any {
beforeRoleNames, beforeAddresses := before.rolesAndAddresses()
afterRoleNames, afterAddresses := after.rolesAndAddresses()
type userInfo struct {
Name string `json:"user"`
Roles []string `json:"roles"`
Addresses []string `json:"addresses"`
Reminder string `json:"reminder"`
}
details := struct {
Before userInfo `json:"before"`
After userInfo `json:"after"`
}{userInfo{
Name: before.Name,
Roles: beforeRoleNames,
Addresses: beforeAddresses,
Reminder: before.Reminder.String,
}, userInfo{
Name: after.Name,
Roles: afterRoleNames,
Addresses: afterAddresses,
Reminder: after.Reminder.String,
}}
return details
}

@ -74,3 +74,32 @@ func PermittedInt(value int, permittedValues ...int) bool {
return false
}
func PermittedString(value string, permittedValues ...string) bool {
for i := range permittedValues {
if value == permittedValues[i] {
return true
}
}
return false
}
func PermittedStringSet(value []string, permittedValues []string) bool {
if value == nil || len(value) == 0 {
return true
}
valueMap := make(map[string]struct{}, len(permittedValues))
for _, v := range permittedValues {
valueMap[v] = struct{}{}
}
for j := range value {
if _, ok := valueMap[value[j]]; !ok {
return false
}
}
return true
}

@ -6,7 +6,7 @@
<div class="ui form segment">
<div class="ui negative message">
<div class="header">
Withdraw motion?
Delete user?
</div>
<p>Do you want to delete user <strong>{{ .Form.User.Name }}</strong>?</p>
</div>
@ -23,7 +23,7 @@
<button class="ui negative labeled icon button" type="submit">
<i class="trash icon"></i> Delete user
</button>
<a href="/motions/" class="ui button">Cancel</a>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
</div>

@ -0,0 +1,79 @@
{{ define "title" }}Edit User {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
{{ $form := .Form }}
{{ $user := .User }}
<div class="ui form segment">
<form action="/users/{{ .Form.User.ID }}/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.name }} error{{ end }}">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ .Form.Name }}">
{{ if .Form.FieldErrors.name }}
<span class="ui small error text">{{ .Form.FieldErrors.name }}</span>
{{ end }}
</div>
<div class="grouped fields{{ if .Form.FieldErrors.reminder_mail }} error{{ end }}">
<label>Reminder email address</label>
<table class="ui very basic collapsing table">
<tbody>
{{ range .Form.MailAddresses }}
<tr>
<td>
<div class="field">
<div class="ui radio checkbox">
<input id="mail-{{ . }}" type="radio" name="reminder_mail"
value="{{ . }}"{{ if eq $form.ReminderMail . }} checked{{ end }}>
<label for="mail-{{ . }}">{{ . }}</label>
</div>
</div>
</td>
<td>
{{ if not (eq $form.ReminderMail .) }} <a
class="ui small negative icon button"
href="/users/{{ $form.User.ID }}/mail/{{ . }}/delete"><i
class="icon delete" title="Delete email address {{ . }}"></i></a>{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ if .Form.FieldErrors.reminder_mail }}
<span class="ui small error text">{{ .Form.FieldErrors.reminder_mail }}</span>
{{ end }}
<a class="ui small positive icon button" href="/users/{{ $form.User.ID }}/add-mail"><i
class="add icon"></i> Add new email address</a>
</div>
<div class="inline fields{{ if .Form.FieldErrors.roles }} error{{ end }}">
<label>Roles</label>
{{ range .Form.AllRoles }}
{{ $currentRole := . }}
<div class="field">
<div class="ui checkbox">
<input id="role-{{ . }}" aria-labelledby="role-label-{{ . }}" type="checkbox"
name="roles"
value="{{ .Name }}"{{ range $form.Roles }}{{ if eq $currentRole.Name . }} checked{{ end }}{{ end }}>
<label for="role-{{ . }}" id="role-label-{{ . }}">{{ . }}</label>
</div>
</div>
{{ end }}
{{ if .Form.FieldErrors.roles }}
<span class="ui small error text">{{ .Form.FieldErrors.roles }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="trash icon"></i> Edit user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
</div>
{{ end }}
Loading…
Cancel
Save