Export/Import Overviews using the rails console

Infos:

  • Used Zammad version: 6.2.0-1709709730.4ab26fc3.jammy
  • Used Zammad installation type: package
  • Operating system: Ubuntu 22.04
  • Browser + version: 124.0.1 (64-bit)

Expected behavior:

  • Export Overviews consistently

Actual behavior:

  • It is possible export Overviews using the rails console
# Export Overviews to JSON file
roles = Overview.all
File.open('/tmp/overview.json', 'w') do |file|
  file.write(overview.to_json)
end

But the Available for the following roles * and Restrict to only the following users are missing in the JSON, which makes it hard(er) to add 430 roles to the overviews as it is necessary to click every single role instead of export/copy/paste/import

[
    {"id":11,"name":"My Assigned Tickets","link":"my_assigned","prio":1,"condition":{"ticket.state_id":{"operator":"is","value":["1","4","8","7","6"]},"ticket.owner_id":{"operator":"is","pre_condition":"current_user.id","value":[],"value_completion":""},"ticket.organization_id":{"operator":"is","pre_condition":"current_user.organization_id","value":[],"value_completion":""}},"order":{"by":"created_at","direction":"ASC"},"group_by":"organization","group_direction":"ASC","organization_shared":false,"out_of_office":false,"view":{"s":["title","customer","group","created_at"]},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.820Z","updated_at":"2024-04-04T07:13:26.422Z"},
    {"id":12,"name":"đź“ĄUnassigned \u0026 Open Tickets","link":"all_unassigned","prio":2,"condition":{"ticket.state_id":{"operator":"is","value":["1","4","6"]},"ticket.owner_id":{"operator":"is","pre_condition":"not_set","value":[],"value_completion":""}},"order":{"by":"created_at","direction":"ASC"},"group_by":"","group_direction":"ASC","organization_shared":false,"out_of_office":false,"view":{"s":["title","customer","group","created_at"]},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.877Z","updated_at":"2024-04-04T07:13:26.778Z"},
    {"id":13,"name":"My Pending Reached Tickets","link":"my_pending_reached","prio":3,"condition":{"ticket.state_id":{"operator":"is","value":[6]},"ticket.owner_id":{"operator":"is","pre_condition":"current_user.id"},"ticket.pending_time":{"operator":"before (relative)","value":0,"range":"minute"}},"order":{"by":"created_at","direction":"ASC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","group","created_at"],"s":["title","customer","group","created_at"],"m":["number","title","customer","group","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.898Z","updated_at":"2024-04-04T07:13:26.804Z"},
    {"id":14,"name":"My Subscribed Tickets","link":"my_subscribed_tickets","prio":4,"condition":{"ticket.mention_user_ids":{"operator":"is","pre_condition":"current_user.id","value":"","value_completion":""}},"order":{"by":"created_at","direction":"ASC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","group","created_at"],"s":["title","customer","group","created_at"],"m":["number","title","customer","group","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.915Z","updated_at":"2024-04-04T07:13:26.833Z"},
    {"id":15,"name":"Open Tickets","link":"all_open","prio":5,"condition":{"ticket.state_id":{"operator":"is","value":[1,6,4]}},"order":{"by":"created_at","direction":"ASC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","group","state","owner","created_at"],"s":["title","customer","group","state","owner","created_at"],"m":["number","title","customer","group","state","owner","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.934Z","updated_at":"2024-04-04T07:13:26.865Z"},
    {"id":16,"name":"Pending Reached Tickets","link":"all_pending_reached","prio":6,"condition":{"ticket.state_id":{"operator":"is","value":[6]},"ticket.pending_time":{"operator":"before (relative)","value":0,"range":"minute"}},"order":{"by":"created_at","direction":"ASC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","group","owner","created_at"],"s":["title","customer","group","owner","created_at"],"m":["number","title","customer","group","owner","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.954Z","updated_at":"2024-04-04T07:13:26.897Z"},
    {"id":17,"name":"Escalated Tickets","link":"all_escalated","prio":7,"condition":{"ticket.escalation_at":{"operator":"till (relative)","value":"10","range":"minute"}},"order":{"by":"escalation_at","direction":"ASC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","group","owner","escalation_at"],"s":["title","customer","group","owner","escalation_at"],"m":["number","title","customer","group","owner","escalation_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.971Z","updated_at":"2024-04-04T07:13:26.914Z"},
    {"id":18,"name":"My Replacement Tickets","link":"my_replacement_tickets","prio":9,"condition":{"ticket.state_id":{"operator":"is","value":[1,6,7,8,4]},"ticket.out_of_office_replacement_id":{"operator":"is","pre_condition":"current_user.id"}},"order":{"by":"created_at","direction":"DESC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":true,"view":{"d":["title","customer","group","owner","escalation_at"],"s":["title","customer","group","owner","escalation_at"],"m":["number","title","customer","group","owner","escalation_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:02.990Z","updated_at":"2024-04-04T07:13:26.989Z"},
    {"id":19,"name":"My Tickets","link":"my_tickets","prio":10,"condition":{"ticket.state_id":{"operator":"is","value":[1,3,5,6,7,8,4,2]},"ticket.customer_id":{"operator":"is","pre_condition":"current_user.id"}},"order":{"by":"created_at","direction":"DESC"},"group_by":null,"group_direction":null,"organization_shared":false,"out_of_office":false,"view":{"d":["title","customer","state","created_at"],"s":["number","title","state","created_at"],"m":["number","title","state","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:03.012Z","updated_at":"2024-04-04T07:13:27.020Z"},
    {"id":20,"name":"My Organization Tickets","link":"my_organization_tickets","prio":11,"condition":{"ticket.state_id":{"operator":"is","value":[1,3,5,6,7,8,4,2]},"ticket.organization_id":{"operator":"is","pre_condition":"current_user.organization_id"}},"order":{"by":"created_at","direction":"DESC"},"group_by":null,"group_direction":null,"organization_shared":true,"out_of_office":false,"view":{"d":["title","customer","organization","state","created_at"],"s":["number","title","customer","organization","state","created_at"],"m":["number","title","customer","organization","state","created_at"],"view_mode_default":"s"},"active":true,"updated_by_id":85,"created_by_id":1,"created_at":"2024-03-18T08:38:03.032Z","updated_at":"2024-04-04T07:13:27.056Z"},
    {"id":21,"name":"đź“źMonitoring","link":"monitoring","prio":8,"condition":{"ticket.group_id":{"operator":"is","value":["283"],"value_completion":""},"ticket.organization_id":{"operator":"is","pre_condition":"current_user.organization_id","value":[],"value_completion":""}},"order":{"by":"created_at","direction":"ASC"},"group_by":"organization","group_direction":"ASC","organization_shared":false,"out_of_office":false,"view":{"s":["title","customer","group","created_at"]},"active":true,"updated_by_id":85,"created_by_id":85,"created_at":"2024-04-04T07:12:22.484Z","updated_at":"2024-04-04T07:36:31.306Z"}
]

Steps to reproduce the behavior:

  • Export using rails console

If anyone could point me towards where the link between Roles and Overview is, I will look into how to export/import that consistently

It is e.g. available for roles with RoleGroup.all but I could not find it for the overview so far.

Thanks

Why don’t you just have a basic agent role that every agent has with the basic user preferences as well. This overview can be used to provide the overviews needed (as it doesn’t seem like you have differences there).

Think about maintainability. 430 roles with lets say 15 overviews are not.

Hi!

Thanks for the questions.

The issue is pretty interesting.

There is a public organization with 450 sites.
Single sites have a group and two or more sites can be part of a district and have a common group.
Every site is treated independently as it’s own entity and agents are responsible for around 12 sites each and there is a backup agent which is not always the same for the 12 sites. Which means around 24 mixed sites per agent.

There is a group for every site or district.
There is an organization for every single site.
There is the requirement that every organization only sees their group
To do so, there are 450 agent roles, every role has full control on either a site group or a district group.
This ensure that the web notification (the one with the bird) for new tickets is only received by agents that have the role for the dedicated group.
This way, if the agent changes sites, the assigned roles are changed and the permissions permeate through all the system.
The Customer in this case is assigned a single group using core workflows which is their site or district group. I was able to export/import workflows successfully, which is nice.
A following challenge is that within a district (collection of sites) different agents might be responsible for different sites part of the district, therefore 1 site = 1 group, 1 group access = 1 role in conclusion 450 sites = 450 roles.

The manageability is given by the fact that no manual management is done in Zammad and everything is managed through API calls (or rails console) with JSON or CSV files.

Update a user? Change the CSV file and upload/update user data
Update permissions? Change the CSV file and upload/update roles
and so on

This makes it also possible to track changes (the files are on GIT) and to replicate in case of a disaster (backup gone, all dead? set up systems with ansible, reimport all files from git, done. old tickets are gone, but settings, permissions, groups, users, etc… are all available again)

I would love to simplify the agent thing, but the web notification in Zammad itself is an issue because it is not configurable who receives the notification based on what. A “is part of organization” would solve that, since in that case I could use organization as filter for overviews and workflows as I am doing already for groups assignments.

Best,
Skip

Does anyone have an idea or a tip to understand how to correctly export/import overviews between Zammad instances?

Thanks

I found also the solution to this.

Instead of using the rails console I can use the API.

Here some example code to import a JSON file using the API in Python

import json
import requests
from requests.exceptions import HTTPError

# Function to load JSON data from a file
def load_json_from_file(file_path):
    try:
        with open(file_path, 'r') as file:
            json_data = json.load(file)
        return json_data
    except FileNotFoundError:
        print(f"Error: The file '{file_path}' was not found.")
        return None
    except json.JSONDecodeError:
        print(f"Error: The file '{file_path}' does not contain valid JSON.")
        return None

# Function to import a JSON object to the API endpoint
def import_json_to_api(json_data, api_url, api_token):
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Token token={api_token}'
    }
    
    try:
        response = requests.post(api_url, headers=headers, data=json.dumps(json_data), verify=False)
        response.raise_for_status()  # Raise an HTTPError if the HTTP request returned an unsuccessful status code
        print(f"JSON data imported successfully: {response.status_code}")
    except HTTPError as http_err:
        print(f"HTTP error occurred: {http_err}")  # HTTP error
        if response.content:
            print(f"Response content: {response.content.decode()}")  # Print response content for more details
    except Exception as err:
        print(f"An error occurred: {err}")  # Other errors

# Example usage
domain_url = 'https://example.com'  # Replace with your actual domain URL
api_endpoint = '/api/v1/overviews/'
api_url = f'{domain_url}{api_endpoint}'
api_token = 'your_api_token'  # Replace with your actual API token

# Load JSON data from file
json_file_path = 'path_to_your_json_file.json'  # Replace with your actual JSON file path
json_data = load_json_from_file(json_file_path)

if json_data:
    import_json_to_api(json_data, api_url, api_token)

The JSON file has to be built like this:

{
    "id": 28,
    "name": "Escalated Tickets Manager",
    "link": "escalated_tickets_manager",
    "prio": 17,
    "condition": {
        "ticket.escalation_at": {
            "operator": "till (relative)",
            "value": "10",
            "range": "minute"
        }
    },
    "order": {
        "by": "escalation_at",
        "direction": "ASC"
    },
    "group_by": "organization",
    "group_direction": "ASC",
    "organization_shared": false,
    "out_of_office": false,
    "view": {
        "s": [
            "title",
            "customer",
            "group",
            "owner",
            "escalation_at"
        ]
    },
    "active": true,
    "updated_by_id": 85,
    "created_by_id": 85,
    "created_at": "2024-05-27T09:25:33.357Z",
    "updated_at": "2024-05-27T09:25:33.350Z",
    "role_ids": [
        502
    ],
    "user_ids": []
},
{
    "id": 29,
    "name": "Escalated Tickets",
    "link": "escalated_tickets",
    "prio": 18,
    "condition": {
        "ticket.escalation_at": {
            "operator": "till (relative)",
            "value": "10",
            "range": "minute"
        }
    },
    "order": {
        "by": "escalation_at",
        "direction": "ASC"
    },
    "group_by": "organization",
    "group_direction": "ASC",
    "organization_shared": false,
    "out_of_office": false,
    "view": {
        "s": [
            "title",
            "customer",
            "group",
            "owner",
            "escalation_at"
        ]
    },
    "active": true,
    "updated_by_id": 85,
    "created_by_id": 85,
    "created_at": "2024-05-27T09:25:45.791Z",
    "updated_at": "2024-05-27T09:25:45.783Z",
    "role_ids": [
        504
    ],
    "user_ids": []
}

This way if role_ids and all other parameters already exists and are exactly the same, it is possible to import overviews from a staging system to a production system

Cheerio!

Changed the code to make it work better:

import json
import requests
from requests.exceptions import HTTPError

# Function to load JSON data from a file
def load_json_from_file(file_path):
    try:
        with open(file_path, 'r') as file:
            json_data = json.load(file)
        return json_data
    except FileNotFoundError:
        print(f"Error: The file '{file_path}' was not found.")
        return None
    except json.JSONDecodeError:
        print(f"Error: The file '{file_path}' does not contain valid JSON.")
        return None

# Function to delete all overviews from the API endpoint
def delete_all_overviews(api_url, api_token):
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Token token={api_token}'
    }
    
    try:
        response = requests.get(api_url, headers=headers, verify=False)
        response.raise_for_status()  # Raise an HTTPError if the HTTP request returned an unsuccessful status code
        overviews = response.json()
        
        for overview in overviews:
            delete_url = f"{api_url}/{overview['id']}"
            delete_response = requests.delete(delete_url, headers=headers, verify=False)
            delete_response.raise_for_status()  # Raise an HTTPError if the delete request returned an unsuccessful status code
            print(f"Overview with id {overview['id']} deleted successfully.")
            
    except HTTPError as http_err:
        print(f"HTTP error occurred while deleting overviews: {http_err}")
        if response.content:
            print(f"Response content: {response.content.decode()}")  # Print response content for more details
    except Exception as err:
        print(f"An error occurred while deleting overviews: {err}")

# Function to import a JSON object to the API endpoint
def import_json_to_api(json_data, api_url, api_token):
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Token token={api_token}'
    }
    
    # If json_data is a list, iterate over each item and send it separately
    if isinstance(json_data, list):
        for item in json_data:
            try:
                response = requests.post(api_url, headers=headers, data=json.dumps(item), verify=False)
                response.raise_for_status()  # Raise an HTTPError if the HTTP request returned an unsuccessful status code
                print(f"JSON data imported successfully: {response.status_code}")
            except HTTPError as http_err:
                print(f"HTTP error occurred: {http_err}")  # HTTP error
                if response.content:
                    print(f"Response content: {response.content.decode()}")  # Print response content for more details
            except Exception as err:
                print(f"An error occurred: {err}")  # Other errors
    else:
        try:
            response = requests.post(api_url, headers=headers, data=json.dumps(json_data), verify=False)
            response.raise_for_status()  # Raise an HTTPError if the HTTP request returned an unsuccessful status code
            print(f"JSON data imported successfully: {response.status_code}")
        except HTTPError as http_err:
            print(f"HTTP error occurred: {http_err}")  # HTTP error
            if response.content:
                print(f"Response content: {response.content.decode()}")  # Print response content for more details
        except Exception as err:
            print(f"An error occurred: {err}")  # Other errors

# Load JSON data from file
def import_overview(json_filename, api_url, api_token):
    json_data = load_json_from_file(json_filename)

    if json_data:
        delete_all_overviews(api_url, api_token)  # Delete all existing overviews before importing
        import_json_to_api(json_data, api_url, api_token)

# Example usage
domain_url = 'https://example.com'  # Replace with your actual domain URL
api_endpoint = '/api/v1/overviews/'
api_url = f'{domain_url}{api_endpoint}'
api_token = 'your_api_token'  # Replace with your actual API token

# Load JSON data from file and import it
json_file_path = 'path_to_your_json_file.json'  # Replace with your actual JSON file path
import_overview(json_file_path, api_url, api_token)

This way, we first delete all existing overviews and then import all the overviews again

My suggestion is to always cleanly import all overviews