Packages Tutorial

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.

  1. 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
  1. Init your github repository:
ubuntu-rs@ubuntu-rs:/workspace/git_zammad/Example-AdminCity$ git init
  1. 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": []
}
  1. 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
  1. To access the data via rails we need a model:

app/model/city.rb

class City < ApplicationModel; end
  1. 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
  1. 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
  1. 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')
  1. 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
    }
  ]
}
  1. 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=="
    }
  ]
}
  1. 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
  1. Here we go:

2 Likes