Manage Cities (Admin Interface Package)
In this tutorial I will show you how you can create a full admin interface for a model with a few steps. In our scenario we love cities and we want an admin interface where we can handle our cities via API in zammad.
- Create a new package directory:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad$ mkdir Example-AdminCity
ubuntu-rs@ubuntu-rs:/workspace/git_zammad$ cd Example-AdminCity
- Init your github repository:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ git init
- Setup a szpm dummy source file:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ git zammad-new-szpm
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ cat example-admin_city.szpm
{
"name": "Example-AdminCity",
"version": "1.0.0",
"vendor": "Example GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://example.com/",
"files": []
}
- To store our city names we need a database migration. It will create a new table
cities
with a name column, add a permission which is used to access the data and also add some translations for the case, that you are (in this example) german speaking.
db/addon/example_admin_city/20230825000000_create_base.rb
class CreateBase < ActiveRecord::Migration[4.2]
def self.up
create_table :cities do |t|
t.column :name, :string, limit: 255, null: false
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.references :updated_by, null: false, foreign_key: { to_table: :users }
t.timestamps null: false, limit: 3
end
Permission.create_if_not_exists(
name: 'admin.city',
note: __('Manage %s'),
preferences: {
translations: [__('Cities')]
},
)
{
'City' => 'Stadt',
'Cities' => 'Städte',
'I like cities' => 'Ich mag Städte',
'New City' => 'Neue Stadt',
}.each do |key, value|
Translation.create_if_not_exists(
locale: 'de-de',
source: key,
target: value,
target_initial: value,
updated_by_id: 1,
created_by_id: 1,
)
end
end
def self.down
Permission.find_by(name: 'admin.city').destroy
drop_table :cities
end
end
- To access the data via rails we need a model:
app/model/city.rb
class City < ApplicationModel; end
- To access the data via the API we need a controller and a policy which restricts the access to the admin permission. We have generic controller functions which make it easy to a generate API’s like this.
app/controllers/cities_controller.rb
class CitiesController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def index
model_index_render(City, params)
end
def show
model_show_render(City, params)
end
def create
model_create_render(City, params)
end
def update
model_update_render(City, params)
end
def destroy
model_destroy_render(City, params)
end
end
app/policies/controllers/cities_controller_policy.rb
class Controllers::CitiesControllerPolicy < Controllers::ApplicationControllerPolicy
default_permit!('admin.city')
end
- To make our controller accessible in the frontend we need also some routes:
config/routes/city.rb
Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path
match api_path + '/cities', to: 'cities#index', via: :get
match api_path + '/cities/:id', to: 'cities#show', via: :get
match api_path + '/cities', to: 'cities#create', via: :post
match api_path + '/cities/:id', to: 'cities#update', via: :put
match api_path + '/cities/:id', to: 'cities#destroy', via: :delete
end
- Now our backend is working but we also want to have a frontend to administration. This model will
grant us access and store our data in the frontend to handle it.
app/assets/javascripts/app/models/city.coffee
class App.City extends App.Model
@configure 'City', 'name'
@extend Spine.Model.Ajax
@url: @apiPath + '/cities'
@configure_attributes = [
{ name: 'name', display: __('Name'), tag: 'input', type: 'text', limit: 100, null: false },
]
@configure_delete = true
@configure_clone = true
@configure_overview = [
'name',
]
@description = __('I like cities')
This controller will use the frontend model and generate a generic admin interface for it:
app/assets/javascripts/app/controllers/city.coffee
class City extends App.ControllerSubContent
@requiredPermission: 'admin.city'
header: __('Cities')
constructor: ->
super
@genericController = new App.ControllerGenericIndex(
el: @el
id: @id
genericObject: 'City'
defaultSortBy: 'name'
pageData:
home: 'cities'
object: __('City')
objects: __('Cities')
pagerAjax: true
pagerBaseUrl: '#manage/cities/'
pagerSelected: ( @page || 1 )
pagerPerPage: 150
navupdate: '#cities'
notes: [
__('I like cities')
]
buttons: [
{ name: __('New City'), 'data-type': 'new', class: 'btn--success' }
]
container: @el.closest('.content')
)
show: (params) =>
for key, value of params
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
@[key] = value
@genericController.paginate( @page || 1 )
App.Config.set('City', { prio: 3300, name: __('City'), parent: '#manage', target: '#manage/cities', controller: City, permission: ['admin.city'] }, 'NavBarAdmin')
- Now we can update our file list and get ready for the build:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ git zammad-update-szpm
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ cat example-admin_city.szpm
{
"name": "Example-AdminCity",
"version": "1.0.0",
"vendor": "Example GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://example.com/",
"files": [
{
"location": "app/assets/javascripts/app/controllers/city.coffee",
"permission": 644
},
{
"location": "app/assets/javascripts/app/models/city.coffee",
"permission": 644
},
{
"location": "app/controllers/cities_controller.rb",
"permission": 644
},
{
"location": "app/model/city.rb",
"permission": 644
},
{
"location": "app/policies/controllers/cities_controller_policy.rb",
"permission": 644
},
{
"location": "config/routes/city.rb",
"permission": 644
},
{
"location": "db/addon/example_admin_city/20230825000000_create_base.rb",
"permission": 644
}
]
}
- And build it:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ git zammad-create-zpm 1.0.0
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ cat example-admin_city-1.0.0.zpm
{
"name": "Example-AdminCity",
"version": "1.0.0",
"vendor": "Example GmbH",
"license": "GNU AFFERO GENERAL PUBLIC LICENSE",
"url": "http://example.com/",
"files": [
{
"location": "app/assets/javascripts/app/controllers/city.coffee",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQ2l0eSBleHRlbmRzIEFwcC5Db250cm9sbGVyU3ViQ29udGVudAog\nIEByZXF1aXJlZFBlcm1pc3Npb246ICdhZG1pbi5jaXR5JwogIGhlYWRlcjog\nX18oJ0NpdGllcycpCgogIGNvbnN0cnVjdG9yOiAtPgogICAgc3VwZXIKCiAg\nICBAZ2VuZXJpY0NvbnRyb2xsZXIgPSBuZXcgQXBwLkNvbnRyb2xsZXJHZW5l\ncmljSW5kZXgoCiAgICAgIGVsOiBAZWwKICAgICAgaWQ6IEBpZAogICAgICBn\nZW5lcmljT2JqZWN0OiAnQ2l0eScKICAgICAgZGVmYXVsdFNvcnRCeTogJ25h\nbWUnCiAgICAgIHBhZ2VEYXRhOgogICAgICAgIGhvbWU6ICdjaXRpZXMnCiAg\nICAgICAgb2JqZWN0OiBfXygnQ2l0eScpCiAgICAgICAgb2JqZWN0czogX18o\nJ0NpdGllcycpCiAgICAgICAgcGFnZXJBamF4OiB0cnVlCiAgICAgICAgcGFn\nZXJCYXNlVXJsOiAnI21hbmFnZS9jaXRpZXMvJwogICAgICAgIHBhZ2VyU2Vs\nZWN0ZWQ6ICggQHBhZ2UgfHwgMSApCiAgICAgICAgcGFnZXJQZXJQYWdlOiAx\nNTAKICAgICAgICBuYXZ1cGRhdGU6ICcjY2l0aWVzJwogICAgICAgIG5vdGVz\nOiBbCiAgICAgICAgICBfXygnSSBsaWtlIGNpdGllcycpCiAgICAgICAgXQog\nICAgICAgIGJ1dHRvbnM6IFsKICAgICAgICAgIHsgbmFtZTogX18oJ05ldyBD\naXR5JyksICdkYXRhLXR5cGUnOiAnbmV3JywgY2xhc3M6ICdidG4tLXN1Y2Nl\nc3MnIH0KICAgICAgICBdCiAgICAgIGNvbnRhaW5lcjogQGVsLmNsb3Nlc3Qo\nJy5jb250ZW50JykKICAgICkKCiAgc2hvdzogKHBhcmFtcykgPT4KICAgIGZv\nciBrZXksIHZhbHVlIG9mIHBhcmFtcwogICAgICBpZiBrZXkgaXNudCAnZWwn\nICYmIGtleSBpc250ICdzaG93bicgJiYga2V5IGlzbnQgJ21hdGNoJwogICAg\nICAgIEBba2V5XSA9IHZhbHVlCgogICAgQGdlbmVyaWNDb250cm9sbGVyLnBh\nZ2luYXRlKCBAcGFnZSB8fCAxICkKCkFwcC5Db25maWcuc2V0KCdDaXR5Jywg\neyBwcmlvOiAzMzAwLCBuYW1lOiBfXygnQ2l0eScpLCBwYXJlbnQ6ICcjbWFu\nYWdlJywgdGFyZ2V0OiAnI21hbmFnZS9jaXRpZXMnLCBjb250cm9sbGVyOiBD\naXR5LCBwZXJtaXNzaW9uOiBbJ2FkbWluLmNpdHknXSB9LCAnTmF2QmFyQWRt\naW4nKQo="
},
{
"location": "app/assets/javascripts/app/models/city.coffee",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQXBwLkNpdHkgZXh0ZW5kcyBBcHAuTW9kZWwKICBAY29uZmlndXJl\nICdDaXR5JywgJ25hbWUnCiAgQGV4dGVuZCBTcGluZS5Nb2RlbC5BamF4CiAg\nQHVybDogQGFwaVBhdGggKyAnL2NpdGllcycKICBAY29uZmlndXJlX2F0dHJp\nYnV0ZXMgPSBbCiAgICB7IG5hbWU6ICduYW1lJywgZGlzcGxheTogX18oJ05h\nbWUnKSwgdGFnOiAnaW5wdXQnLCB0eXBlOiAndGV4dCcsIGxpbWl0OiAxMDAs\nICBudWxsOiBmYWxzZSB9LAogIF0KICBAY29uZmlndXJlX2RlbGV0ZSA9IHRy\ndWUKICBAY29uZmlndXJlX2Nsb25lID0gdHJ1ZQogIEBjb25maWd1cmVfb3Zl\ncnZpZXcgPSBbCiAgICAnbmFtZScsCiAgXQoKICBAZGVzY3JpcHRpb24gPSBf\nXygnSSBsaWtlIGNpdGllcycpCg=="
},
{
"location": "app/controllers/cities_controller.rb",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQ2l0aWVzQ29udHJvbGxlciA8IEFwcGxpY2F0aW9uQ29udHJvbGxl\ncgogIHByZXBlbmRfYmVmb3JlX2FjdGlvbiA6YXV0aGVudGljYXRlX2FuZF9h\ndXRob3JpemUhCgogIGRlZiBpbmRleAogICAgbW9kZWxfaW5kZXhfcmVuZGVy\nKENpdHksIHBhcmFtcykKICBlbmQKCiAgZGVmIHNob3cKICAgIG1vZGVsX3No\nb3dfcmVuZGVyKENpdHksIHBhcmFtcykKICBlbmQKCiAgZGVmIGNyZWF0ZQog\nICAgbW9kZWxfY3JlYXRlX3JlbmRlcihDaXR5LCBwYXJhbXMpCiAgZW5kCgog\nIGRlZiB1cGRhdGUKICAgIG1vZGVsX3VwZGF0ZV9yZW5kZXIoQ2l0eSwgcGFy\nYW1zKQogIGVuZAoKICBkZWYgZGVzdHJveQogICAgbW9kZWxfZGVzdHJveV9y\nZW5kZXIoQ2l0eSwgcGFyYW1zKQogIGVuZAplbmQK"
},
{
"location": "app/model/city.rb",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQ2l0eSA8IEFwcGxpY2F0aW9uTW9kZWw7IGVuZAo="
},
{
"location": "app/policies/controllers/cities_controller_policy.rb",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQ29udHJvbGxlcnM6OkNpdGllc0NvbnRyb2xsZXJQb2xpY3kgPCBD\nb250cm9sbGVyczo6QXBwbGljYXRpb25Db250cm9sbGVyUG9saWN5CiAgZGVm\nYXVsdF9wZXJtaXQhKCdhZG1pbi5jaXR5JykKZW5kCg=="
},
{
"location": "config/routes/city.rb",
"permission": 644,
"encode": "base64",
"content": "WmFtbWFkOjpBcHBsaWNhdGlvbi5yb3V0ZXMuZHJhdyBkbwogIGFwaV9wYXRo\nID0gUmFpbHMuY29uZmlndXJhdGlvbi5hcGlfcGF0aAoKICBtYXRjaCBhcGlf\ncGF0aCArICcvY2l0aWVzJywgICAgICAgICAgICB0bzogJ2NpdGllcyNpbmRl\neCcsICAgdmlhOiA6Z2V0CiAgbWF0Y2ggYXBpX3BhdGggKyAnL2NpdGllcy86\naWQnLCAgICAgICAgdG86ICdjaXRpZXMjc2hvdycsICAgIHZpYTogOmdldAog\nIG1hdGNoIGFwaV9wYXRoICsgJy9jaXRpZXMnLCAgICAgICAgICAgIHRvOiAn\nY2l0aWVzI2NyZWF0ZScsICB2aWE6IDpwb3N0CiAgbWF0Y2ggYXBpX3BhdGgg\nKyAnL2NpdGllcy86aWQnLCAgICAgICAgdG86ICdjaXRpZXMjdXBkYXRlJywg\nIHZpYTogOnB1dAogIG1hdGNoIGFwaV9wYXRoICsgJy9jaXRpZXMvOmlkJywg\nICAgICAgIHRvOiAnY2l0aWVzI2Rlc3Ryb3knLCB2aWE6IDpkZWxldGUKZW5k\nCg=="
},
{
"location": "db/addon/example_admin_city/20230825000000_create_base.rb",
"permission": 644,
"encode": "base64",
"content": "Y2xhc3MgQ3JlYXRlQmFzZSA8IEFjdGl2ZVJlY29yZDo6TWlncmF0aW9uWzQu\nMl0KICBkZWYgc2VsZi51cAogICAgY3JlYXRlX3RhYmxlIDpjaXRpZXMgZG8g\nfHR8CiAgICAgIHQuY29sdW1uIDpuYW1lLCA6c3RyaW5nLCBsaW1pdDogMjAw\nLCAgbnVsbDogZmFsc2UKICAgICAgdC5yZWZlcmVuY2VzIDpjcmVhdGVkX2J5\nLCBudWxsOiBmYWxzZSwgZm9yZWlnbl9rZXk6IHsgdG9fdGFibGU6IDp1c2Vy\ncyB9CiAgICAgIHQucmVmZXJlbmNlcyA6dXBkYXRlZF9ieSwgbnVsbDogZmFs\nc2UsIGZvcmVpZ25fa2V5OiB7IHRvX3RhYmxlOiA6dXNlcnMgfQogICAgICB0\nLnRpbWVzdGFtcHMgbnVsbDogZmFsc2UsIGxpbWl0OiAzCiAgICBlbmQKCiAg\nICBQZXJtaXNzaW9uLmNyZWF0ZV9pZl9ub3RfZXhpc3RzKAogICAgICBuYW1l\nOiAgICAgICAgJ2FkbWluLmNpdHknLAogICAgICBub3RlOiAgICAgICAgX18o\nJ01hbmFnZSAlcycpLAogICAgICBwcmVmZXJlbmNlczogewogICAgICAgIHRy\nYW5zbGF0aW9uczogW19fKCdDaXRpZXMnKV0KICAgICAgfSwKICAgICkKCiAg\nICB7CiAgICAgICdDaXR5JyAgICAgICAgICA9PiAnU3RhZHQnLAogICAgICAn\nQ2l0aWVzJyAgICAgICAgPT4gJ1N0w6RkdGUnLAogICAgICAnSSBsaWtlIGNp\ndGllcycgPT4gJ0ljaCBtYWcgU3TDpGR0ZScsCiAgICAgICdOZXcgQ2l0eScg\nPT4gJ05ldWUgU3RhZHQnLAogICAgfS5lYWNoIGRvIHxrZXksIHZhbHVlfAog\nICAgICBUcmFuc2xhdGlvbi5jcmVhdGVfaWZfbm90X2V4aXN0cygKICAgICAg\nICBsb2NhbGU6ICAgICAgICAgJ2RlLWRlJywKICAgICAgICBzb3VyY2U6ICAg\nICAgICAga2V5LAogICAgICAgIHRhcmdldDogICAgICAgICB2YWx1ZSwKICAg\nICAgICB0YXJnZXRfaW5pdGlhbDogdmFsdWUsCiAgICAgICAgdXBkYXRlZF9i\neV9pZDogIDEsCiAgICAgICAgY3JlYXRlZF9ieV9pZDogIDEsCiAgICAgICkK\nICAgIGVuZAogIGVuZAoKICBkZWYgc2VsZi5kb3duCiAgICBQZXJtaXNzaW9u\nLmZpbmRfYnkobmFtZTogJ2FkbWluLmNpdHknKS5kZXN0cm95CiAgICBkcm9w\nX3RhYmxlIDpjaXRpZXMKICBlbmQKZW5kCg=="
}
]
}
- To install the package
example-admin_city-1.0.0.zpm
we need to install it via Admin → Package and afterwards run the following commands:
zammad> rake zammad:package:migrate
zammad> rake assets:precompile
root> systemctl restart zammad
- Here we go: