Repository: google/timesketch

Issue:
Update to Flask2
We are running Flask 1.x and it is slowly being outdated by the new Flask 2.x. We need to upgrade to this new major version.




Discussion:

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -35,9 +35,11 @@
 
 version_tuple = (sys.version_info[0], sys.version_info[1])
 if version_tuple < (3, 6):
-    print((
-        'Unsupported Python version: {0:s}, version 3.6 or higher '
-        'required.').format(sys.version))
+    print(
+        (
+            "Unsupported Python version: {0:s}, version 3.6 or higher " "required."
+        ).format(sys.version)
+    )
     sys.exit(1)
 
 
@@ -50,7 +52,7 @@ def parse_requirements_from_file(path):
     Yields:
       str: package resource requirement.
     """
-    with open(path, 'r') as file_object:
+    with open(path, "r") as file_object:
         file_contents = file_object.read()
     for req in pkg_resources.parse_requirements(file_contents):
         try:
@@ -61,38 +63,36 @@ def parse_requirements_from_file(path):
 
 
 timesketch_description = (
-    'Timesketch is a web based tool for collaborative forensic timeline '
-    'analysis. Using sketches you and your collaborators can easily organize '
-    'timelines and analyze them all at the same time.  Add meaning to '
-    'your raw data with rich annotations, comments, tags and stars.')
+    "Timesketch is a web based tool for collaborative forensic timeline "
+    "analysis. Using sketches you and your collaborators can easily organize "
+    "timelines and analyze them all at the same time.  Add meaning to "
+    "your raw data with rich annotations, comments, tags and stars."
+)
 
 setup(
-    name='timesketch',
+    name="timesketch",
     version=version.get_version(),
-    description='Digital forensic timeline analysis',
+    description="Digital forensic timeline analysis",
     long_description=timesketch_description,
-    license='Apache License, Version 2.0',
-    url='http://www.timesketch.org/',
-    maintainer='Timesketch development team',
-    maintainer_email='timesketch-dev@googlegroups.com',
+    license="Apache License, Version 2.0",
+    url="http://www.timesketch.org/",
+    maintainer="Timesketch development team",
+    maintainer_email="timesketch-dev@googlegroups.com",
     classifiers=[
-        'Development Status :: 4 - Beta',
-        'Environment :: Web Environment',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
+        "Development Status :: 4 - Beta",
+        "Environment :: Web Environment",
+        "Operating System :: OS Independent",
+        "Programming Language :: Python",
     ],
     data_files=[
-        ('share/timesketch', glob.glob(
-            os.path.join('data', '*.*'))),
-        ('share/timesketch/linux', glob.glob(
-            os.path.join('data', 'linux', '*.*'))),
-        ('share/doc/timesketch', [
-            'AUTHORS', 'LICENSE', 'README.md']),
+        ("share/timesketch", glob.glob(os.path.join("data", "*.*"))),
+        ("share/timesketch/linux", glob.glob(os.path.join("data", "linux", "*.*"))),
+        ("share/doc/timesketch", ["AUTHORS", "LICENSE", "README.md"]),
     ],
     packages=find_packages(),
     include_package_data=True,
     zip_safe=False,
-    entry_points={'console_scripts': ['tsctl=timesketch.tsctl:main']},
-    install_requires=parse_requirements_from_file('requirements.txt'),
-    tests_require=parse_requirements_from_file('test_requirements.txt'),
+    entry_points={"console_scripts": ["tsctl=timesketch.tsctl:cli"]},
+    install_requires=parse_requirements_from_file("requirements.txt"),
+    tests_require=parse_requirements_from_file("test_requirements.txt"),
 )
diff --git a/timesketch/lib/testlib.py b/timesketch/lib/testlib.py
--- a/timesketch/lib/testlib.py
+++ b/timesketch/lib/testlib.py
@@ -39,22 +39,23 @@
 
 class TestConfig(object):
     """Config for the test environment."""
+
     DEBUG = True
-    SECRET_KEY = 'testing'
-    SQLALCHEMY_DATABASE_URI = 'sqlite://'
+    SECRET_KEY = "testing"
+    SQLALCHEMY_DATABASE_URI = "sqlite://"
     WTF_CSRF_ENABLED = False
-    OPENSEARCH_HOST = 'noserver'
+    OPENSEARCH_HOST = "noserver"
     OPENSEARCH_PORT = 4711
     OPENSEARCH_USER = None
     OPENSEARCH_PASSWORD = None
     OPENSEARCH_SSL = False
     OPENSEARCH_VERIFY_CERTS = True
-    LABELS_TO_PREVENT_DELETION = ['protected', 'magic']
+    LABELS_TO_PREVENT_DELETION = ["protected", "magic"]
     UPLOAD_ENABLED = False
     AUTO_SKETCH_ANALYZERS = []
     SIMILARITY_DATA_TYPES = []
-    SIGMA_RULES_FOLDERS = ['./data/sigma/rules/']
-    INTELLIGENCE_TAG_METADATA = './data/intelligence_tag_metadata.yaml'
+    SIGMA_RULES_FOLDERS = ["./data/sigma/rules/"]
+    INTELLIGENCE_TAG_METADATA = "./data/intelligence_tag_metadata.yaml"
 
 
 class MockOpenSearchClient(object):
@@ -67,23 +68,30 @@ def __init__(self):
     def search(self, index, body, size):  # pylint: disable=unused-argument
         """Mock a client search, used for aggregations."""
         meta = {
-            'es_time': 23,
-            'es_total_count': 5621,
-            'timed_out': False,
-            'max_score': 0.0
+            "es_time": 23,
+            "es_total_count": 5621,
+            "timed_out": False,
+            "max_score": 0.0,
         }
 
-        objects = [{
-            'my_aggregation': {'buckets': [
-                {'foobar': 1, 'second': 'foobar'},
-                {'foobar': 4, 'second': 'more stuff'},
-                {'foobar': 532, 'second': 'hvernig hefurdu thad'}]},
-            'my_second_aggregation': {'buckets': [
-                {'foobar': 54, 'second': 'faranlegt', 'third': 'other text'},
-                {'foobar': 42, 'second': 'asnalegt'}]}
-
-        }]
-        return {'meta': meta, 'objects': objects}
+        objects = [
+            {
+                "my_aggregation": {
+                    "buckets": [
+                        {"foobar": 1, "second": "foobar"},
+                        {"foobar": 4, "second": "more stuff"},
+                        {"foobar": 532, "second": "hvernig hefurdu thad"},
+                    ]
+                },
+                "my_second_aggregation": {
+                    "buckets": [
+                        {"foobar": 54, "second": "faranlegt", "third": "other text"},
+                        {"foobar": 42, "second": "asnalegt"},
+                    ]
+                },
+            }
+        ]
+        return {"meta": meta, "objects": objects}
 
 
 class MockOpenSearchIndices(object):
@@ -93,7 +101,7 @@ def get_mapping(self, *args, **kwargs):
         return {}
 
     def stats(self, *args, **kwargs):
-        return {'indices': {}}
+        return {"indices": {}}
 
     def refresh(self, *args, **kwargs):
         return
@@ -106,66 +114,50 @@ class MockDataStore(object):
     """A mock implementation of a Datastore."""
 
     event_dict = {
-        '_index': [],
-        '_id': 'adc123',
-        '_type': 'plaso_event',
-        '_source': {
-            '__ts_timeline_id': 1,
-            'es_index': '',
-            'es_id': '',
-            'label': '',
-            'timestamp': 1410895419859714,
-            'timestamp_desc': '',
-            'datetime': '2014-09-16T19:23:40+00:00',
-            'source_short': '',
-            'source_long': '',
-            'message': '',
-        }
+        "_index": [],
+        "_id": "adc123",
+        "_type": "plaso_event",
+        "_source": {
+            "__ts_timeline_id": 1,
+            "es_index": "",
+            "es_id": "",
+            "label": "",
+            "timestamp": 1410895419859714,
+            "timestamp_desc": "",
+            "datetime": "2014-09-16T19:23:40+00:00",
+            "source_short": "",
+            "source_long": "",
+            "message": "",
+        },
     }
     search_result_dict = {
-        'hits': {
-            'hits': [{
-                'sort': [1410593223000],
-                '_type': 'plaso_event',
-                '_source': {
-                    'timestamp':
-                    1410593222543942,
-                    'message':
-                    'Test event',
-                    'timesketch_label': [
-                        {
-                            'user_id': 1,
-                            'name': '__ts_star',
-                            'sketch_id': 1
-                        },
-                        {
-                            'user_id': 2,
-                            'name': '__ts_star',
-                            'sketch_id': 99
-                        },
-                    ],
-                    'timestamp_desc':
-                    'Content Modification Time',
-                    'datetime':
-                    '2014-09-13T07:27:03+00:00',
-                    '__ts_timeline_id': 1,
-                },
-                '_score': 'null',
-                '_index': 'test',
-                '_id': 'test'
-            }],
-            'total':
-            1,
-            'max_score':
-            'null'
-        },
-        '_shards': {
-            'successful': 10,
-            'failed': 0,
-            'total': 10
+        "hits": {
+            "hits": [
+                {
+                    "sort": [1410593223000],
+                    "_type": "plaso_event",
+                    "_source": {
+                        "timestamp": 1410593222543942,
+                        "message": "Test event",
+                        "timesketch_label": [
+                            {"user_id": 1, "name": "__ts_star", "sketch_id": 1},
+                            {"user_id": 2, "name": "__ts_star", "sketch_id": 99},
+                        ],
+                        "timestamp_desc": "Content Modification Time",
+                        "datetime": "2014-09-13T07:27:03+00:00",
+                        "__ts_timeline_id": 1,
+                    },
+                    "_score": "null",
+                    "_index": "test",
+                    "_id": "test",
+                }
+            ],
+            "total": 1,
+            "max_score": "null",
         },
-        'took': 5,
-        'timed_out': False
+        "_shards": {"successful": 10, "failed": 0, "total": 10},
+        "took": 5,
+        "timed_out": False,
     }
 
     def __init__(self, host, port):
@@ -186,7 +178,7 @@ def search(self, *args, **kwargs):
         Returns:
             A dictionary with search result or integer if count is requested.
         """
-        if kwargs.get('count'):
+        if kwargs.get("count"):
             # 4711 is sometimes used instead of 17, on occasions when you want
             # to denote a slightly larger number. Probably comes from the name
             # of 'genuine' Eau-de-cologne, 'No. 4711 ".
@@ -227,15 +219,17 @@ def get_filter_labels(sketch_id, indices):
         """
         return []
 
-    def set_label(self,
-                  searchindex_id,
-                  event_id,
-                  event_type,
-                  sketch_id,
-                  user_id,
-                  label,
-                  toggle=False,
-                  single_update=True):
+    def set_label(
+        self,
+        searchindex_id,
+        event_id,
+        event_type,
+        sketch_id,
+        user_id,
+        label,
+        toggle=False,
+        single_update=True,
+    ):
         """Mock adding a label to an event."""
         return
 
@@ -244,8 +238,9 @@ def create_index(self, *args, **kwargs):
         """Mock creating an index."""
         return
 
-    def import_event(self, index_name, event_type, event=None,
-                     event_id=None, flush_interval=None):
+    def import_event(
+        self, index_name, event_type, event=None, event_id=None, flush_interval=None
+    ):
         """Mock adding the event to OpenSearch, instead add the event
         to event_store.
         Args:
@@ -258,14 +253,14 @@ def import_event(self, index_name, event_type, event=None,
         """
 
         if event_id in self.event_store:
-            self.event_store[event_id]['_source'].update(event)
+            self.event_store[event_id]["_source"].update(event)
             return
 
         new_event = {
-            '_index': index_name,
-            '_id': event_id,
-            '_type': event_type,
-            '_source': event
+            "_index": index_name,
+            "_id": event_id,
+            "_type": event_type,
+            "_source": event,
         }
         self.event_store[event_id] = new_event
 
@@ -276,12 +271,19 @@ def version(self):
         Returns:
           Version number as a string.
         """
-        return '6.0'
+        return "6.0"
 
     # pylint: disable=unused-argument
-    def search_stream(self, query_string, query_filter, query_dsl,
-                      indices, return_fields, enable_scroll=True,
-                      timeline_ids=None):
+    def search_stream(
+        self,
+        query_string,
+        query_filter,
+        query_dsl,
+        indices,
+        return_fields,
+        enable_scroll=True,
+        timeline_ids=None,
+    ):
         for i in range(len(self.event_store)):
             yield self.event_store[str(i)]
 
@@ -302,31 +304,32 @@ def __init__(self, host, username, password):
 
     class MockQuerySequence(object):
         """A mock implementation of a QuerySequence."""
-        MOCK_GRAPH = [{
-            'nodes': [{
-                'id': '1',
-                'labels': ['User'],
-                'properties': {
-                    'username': 'test',
-                    'uid': '123456'
-                }
-            }, {
-                'id': '2',
-                'labels': ['Machine'],
-                'properties': {
-                    'hostname': 'test'
-                }
-            }],
-            'relationships': [{
-                'endNode': '2',
-                'id': '3',
-                'startNode': '1',
-                'properties': {
-                    'method': 'Network'
-                },
-                'type': 'ACCESS'
-            }]
-        }]
+
+        MOCK_GRAPH = [
+            {
+                "nodes": [
+                    {
+                        "id": "1",
+                        "labels": ["User"],
+                        "properties": {"username": "test", "uid": "123456"},
+                    },
+                    {
+                        "id": "2",
+                        "labels": ["Machine"],
+                        "properties": {"hostname": "test"},
+                    },
+                ],
+                "relationships": [
+                    {
+                        "endNode": "2",
+                        "id": "3",
+                        "startNode": "1",
+                        "properties": {"method": "Network"},
+                        "type": "ACCESS",
+                    }
+                ],
+            }
+        ]
         MOCK_ROWS = {}
         MOCK_STATS = {}
 
@@ -348,7 +351,7 @@ def query(self, *args, **kwargs):
             A MockQuerySequence instance.
         """
         query = args[0]
-        if query == 'empty':
+        if query == "empty":
             return self.MockEmptyQuerySequence()
         return self.MockQuerySequence()
 
@@ -356,7 +359,7 @@ def query(self, *args, **kwargs):
 class BaseTest(TestCase):
     """Base class for tests."""
 
-    COLOR_WHITE = 'FFFFFF'
+    COLOR_WHITE = "FFFFFF"
 
     def create_app(self):
         """Setup the Flask application.
@@ -384,7 +387,7 @@ def _create_user(self, username, set_password=False):
         """
         user = User.get_or_create(username=username)
         if set_password:
-            user.set_password(plaintext='test', rounds=4)
+            user.set_password(plaintext="test", rounds=4)
         self._commit_to_database(user)
         return user
 
@@ -412,10 +415,10 @@ def _create_sketch(self, name, user, acl=False):
         """
         sketch = Sketch.get_or_create(name=name, description=name, user=user)
         if acl:
-            for permission in ['read', 'write', 'delete']:
+            for permission in ["read", "write", "delete"]:
                 sketch.grant_permission(permission=permission, user=user)
-        label = sketch.Label(label='Test label', user=user)
-        status = sketch.Status(status='Test status', user=user)
+        label = sketch.Label(label="Test label", user=user)
+        status = sketch.Status(status="Test status", user=user)
         sketch.labels.append(label)
         sketch.status.append(status)
         self._commit_to_database(sketch)
@@ -431,11 +434,12 @@ def _create_searchindex(self, name, user, acl=False):
             A searchindex (instance of timesketch.models.sketch.SearchIndex)
         """
         searchindex = SearchIndex.get_or_create(
-            name=name, description=name, index_name=name, user=user)
+            name=name, description=name, index_name=name, user=user
+        )
         if acl:
-            for permission in ['read', 'write', 'delete']:
+            for permission in ["read", "write", "delete"]:
                 searchindex.grant_permission(permission=permission, user=user)
-        searchindex.set_status(status='ready')
+        searchindex.set_status(status="ready")
         self._commit_to_database(searchindex)
         return searchindex
 
@@ -450,8 +454,9 @@ def _create_event(self, sketch, searchindex, user):
             An event (instance of timesketch.models.sketch.Event)
         """
         event = Event.get_or_create(
-            sketch=sketch, searchindex=searchindex, document_id='test')
-        comment = event.Comment(comment='test', user=user)
+            sketch=sketch, searchindex=searchindex, document_id="test"
+        )
+        comment = event.Comment(comment="test", user=user)
         event.comments.append(comment)
         self._commit_to_database(event)
         return event
@@ -465,7 +470,8 @@ def _create_story(self, sketch, user):
             A story (instance of timesketch.models.story.Story)
         """
         story = Story.get_or_create(
-            title='Test', content='Test', sketch=sketch, user=user)
+            title="Test", content="Test", sketch=sketch, user=user
+        )
         self._commit_to_database(story)
         return story
 
@@ -486,8 +492,9 @@ def _create_timeline(self, name, sketch, searchindex, user):
             user=user,
             sketch=sketch,
             searchindex=searchindex,
-            color=self.COLOR_WHITE)
-        timeline.set_status(status='ready')
+            color=self.COLOR_WHITE,
+        )
+        timeline.set_status(status="ready")
         self._commit_to_database(timeline)
         return timeline
 
@@ -505,7 +512,8 @@ def _create_view(self, name, sketch, user):
             query_string=name,
             query_filter=json.dumps(dict()),
             user=user,
-            sketch=sketch)
+            sketch=sketch,
+        )
         self._commit_to_database(view)
         return view
 
@@ -518,10 +526,8 @@ def _create_searchtemplate(self, name, user):
             A search template (timesketch.models.sketch.SearchTemplate)
         """
         searchtemplate = SearchTemplate(
-            name=name,
-            query_string=name,
-            query_filter=json.dumps(dict()),
-            user=user)
+            name=name, query_string=name, query_filter=json.dumps(dict()), user=user
+        )
         self._commit_to_database(searchtemplate)
         return searchtemplate
 
@@ -529,42 +535,45 @@ def setUp(self):
         """Setup the test database."""
         init_db()
 
-        self.user1 = self._create_user(username='test1', set_password=True)
-        self.user2 = self._create_user(username='test2', set_password=False)
+        self.user1 = self._create_user(username="test1", set_password=True)
+        self.user2 = self._create_user(username="test2", set_password=False)
 
-        self.group1 = self._create_group(name='test_group1', user=self.user1)
-        self.group2 = self._create_group(name='test_group2', user=self.user1)
+        self.group1 = self._create_group(name="test_group1", user=self.user1)
+        self.group2 = self._create_group(name="test_group2", user=self.user1)
 
-        self.sketch1 = self._create_sketch(
-            name='Test 1', user=self.user1, acl=True)
-        self.sketch2 = self._create_sketch(
-            name='Test 2', user=self.user1, acl=False)
-        self.sketch3 = self._create_sketch(
-            name='Test 3', user=self.user1, acl=True)
+        self.sketch1 = self._create_sketch(name="Test 1", user=self.user1, acl=True)
+        self.sketch2 = self._create_sketch(name="Test 2", user=self.user1, acl=False)
+        self.sketch3 = self._create_sketch(name="Test 3", user=self.user1, acl=True)
 
         self.searchindex = self._create_searchindex(
-            name='test', user=self.user1, acl=True)
+            name="test", user=self.user1, acl=True
+        )
         self.searchindex2 = self._create_searchindex(
-            name='test2', user=self.user1, acl=True)
+            name="test2", user=self.user1, acl=True
+        )
 
         self.timeline = self._create_timeline(
-            name='Timeline 1',
+            name="Timeline 1",
             sketch=self.sketch1,
             searchindex=self.searchindex,
-            user=self.user1)
+            user=self.user1,
+        )
 
         self.view1 = self._create_view(
-            name='View 1', sketch=self.sketch1, user=self.user1)
+            name="View 1", sketch=self.sketch1, user=self.user1
+        )
         self.view2 = self._create_view(
-            name='View 2', sketch=self.sketch2, user=self.user1)
-        self.view3 = self._create_view(
-            name='', sketch=self.sketch1, user=self.user2)
+            name="View 2", sketch=self.sketch2, user=self.user1
+        )
+        self.view3 = self._create_view(name="", sketch=self.sketch1, user=self.user2)
 
         self.searchtemplate = self._create_searchtemplate(
-            name='template', user=self.user1)
+            name="template", user=self.user1
+        )
 
         self.event = self._create_event(
-            sketch=self.sketch1, searchindex=self.searchindex, user=self.user1)
+            sketch=self.sketch1, searchindex=self.searchindex, user=self.user1
+        )
 
         self.story = self._create_story(sketch=self.sketch1, user=self.user1)
 
@@ -576,26 +585,27 @@ def tearDown(self):
     def login(self):
         """Authenticate the test user."""
         self.client.post(
-            '/login/',
-            data=dict(username='test1', password='test'),
-            follow_redirects=True)
+            "/login/",
+            data=dict(username="test1", password="test"),
+            follow_redirects=True,
+        )
 
     def test_unauthenticated(self):
         """
         Generic test for all resources. It tests that no
         unauthenticated request are accepted.
         """
-        if not getattr(self, 'resource_url', False):
+        if not getattr(self, "resource_url", False):
             self.skipTest(self)
 
         response = self.client.get(self.resource_url)
         if response.status_code == 405:
             response = self.client.post(self.resource_url)
         if isinstance(response.data, six.binary_type):
-            response_data = codecs.decode(response.data, 'utf-8')
+            response_data = codecs.decode(response.data, "utf-8")
         else:
             response_data = response.data
-        self.assertIn('/login/', response_data)
+        self.assertIn("/login/", response_data)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_REDIRECT)
 
 
diff --git a/timesketch/tsctl.py b/timesketch/tsctl.py
--- a/timesketch/tsctl.py
+++ b/timesketch/tsctl.py
@@ -1,5 +1,4 @@
-#!/usr/bin/python
-# Copyright 2015 Google Inc. All rights reserved.
+# Copyright 2020 Google Inc. All rights reserved.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -12,529 +11,239 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-"""This module is for management of the Timesketch application."""
-from __future__ import unicode_literals
+"""CLI management tool."""
 
-import codecs
-import sys
-import yaml
-
-import six
-
-from flask import current_app
-
-from flask_migrate import MigrateCommand
-from flask_script import Command
-from flask_script import Manager
-from flask_script import Server
-from flask_script import Option
-from flask_script import prompt_bool
-from flask_script import prompt_pass
+import click
+from flask.cli import FlaskGroup
 from sqlalchemy.exc import IntegrityError
 
 from timesketch import version
 from timesketch.app import create_app
-from timesketch.lib.datastores.opensearch import OpenSearchDataStore
 from timesketch.models import db_session
 from timesketch.models import drop_all
 from timesketch.models.user import Group
 from timesketch.models.user import User
-from timesketch.models.sketch import SearchIndex
-from timesketch.models.sketch import SearchTemplate
 from timesketch.models.sketch import Sketch
-from timesketch.models.sketch import Timeline
-
-
-class GetVersion(Command):
-    """Returns the version information of Timesketch."""
-
-    # pylint: disable=method-hidden
-    def run(self):
-        """Return the version information of Timesketch."""
-        return 'Timesketch version: {0:s}'.format(
-            version.get_version())
 
 
-class DropDataBaseTables(Command):
-    """Drop all database tables."""
-
-    # pylint: disable=method-hidden
-    def run(self):
-        """Drop all tables after user ha verified."""
-        verified = prompt_bool(
-            'Do you really want to drop all the database tables?')
-        if verified:
-            sys.stdout.write('All tables dropped. Database is now empty.\n')
-            drop_all()
+@click.group(cls=FlaskGroup, create_app=create_app)
+def cli():
+    """Management script for the Timesketch application."""
 
 
-class GrantUser(Command):
-    """Grant a user user access to a sketch."""
-    option_list = (
-        Option('--username', '-u', dest='username', required=True),
-        Option('--sketchId', '-s', dest='sketch_id', required=True), )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, username, sketch_id):
-        """Creates the user."""
-        if not isinstance(sketch_id, six.text_type):
-            sketch_id = codecs.decode(sketch_id, 'utf-8')
-        if not isinstance(username, six.text_type):
-            username = codecs.decode(username, 'utf-8')
-        sketch = Sketch.query.filter_by(id=sketch_id).first()
-        user = User.query.filter_by(username=username).first()
-        if not sketch:
-            sys.stdout.write('No sketch found with this ID.')
-        elif not user:
-            sys.stdout.write('User [{0:s}] does not exist.\n'.format(
-                username))
+@cli.command(name="list-users")
+def list_users():
+    """List all users."""
+    for user in User.query.all():
+        if user.admin:
+            extra = " (admin)"
         else:
-            sketch.grant_permission(permission='read', user=user)
-            sketch.grant_permission(permission='write', user=user)
-            sys.stdout.write('User {0:s} added to the sketch {1:s}.\n'.format(
-                username, sketch_id))
+            extra = ""
+        print(f"{user.username}{extra}")
 
 
-class AddUser(Command):
-    """Create a new Timesketch user."""
-    option_list = (
-        Option('--username', '-u', dest='username', required=True),
-        Option('--password', '-p', dest='password', required=False), )
+@cli.command(name="create-user")
+@click.argument("username")
+@click.option("--password")
+def create_user(username, password=None):
+    """Create a user."""
 
-    def get_password_from_prompt(self):
+    def get_password_from_prompt():
         """Get password from the command line prompt."""
-        first_password = prompt_pass('Enter password')
-        second_password = prompt_pass('Enter password again')
+        first_password = click.prompt("Enter password", hide_input=True, type=str)
+        second_password = click.prompt(
+            "Enter password again", hide_input=True, type=str
+        )
         if first_password != second_password:
-            sys.stderr.write('Passwords don\'t match, try again.\n')
-            self.get_password_from_prompt()
+            print("Passwords don't match, try again.")
+            get_password_from_prompt()
         return first_password
 
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, username, password):
-        """Creates the user."""
-        if not password:
-            password = self.get_password_from_prompt()
-        if not isinstance(password, six.text_type):
-            password = codecs.decode(password, 'utf-8')
-        if not isinstance(username, six.text_type):
-            username = codecs.decode(username, 'utf-8')
-        user = User.get_or_create(username=username)
-        user.set_password(plaintext=password)
-        db_session.add(user)
-        db_session.commit()
-        sys.stdout.write('User {0:s} created/updated\n'.format(username))
-
-
-class MakeUserAdmin(Command):
-    """Make user into an administrator."""
-    option_list = (
-        Option('--username', '-u', dest='username', required=True),
-        Option(
-            '--remove', '-r', dest='remove', action='store_true',
-            required=False, default=False),
-    )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, username, remove):
-        """Adds the admin bit to a user."""
-        user = User.query.filter_by(username=username).first()
-
-        if not user:
-            sys.stdout.write('User [{0:s}] does not exist.\n'.format(
-                username))
-            return
-        user.admin = not remove
-        db_session.add(user)
-        db_session.commit()
-
-        if remove:
-            sys.stdout.write('User {0:s} is no longer an admin.\n'.format(
-                username))
-        else:
-            sys.stdout.write('User {0:s} is now an admin.\n'.format(username))
-
-class DisableUser(Command):
-    """Disable User"""
-    option_list = (
-        Option('--username', '-u', dest='username', required=True),
-    )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, username):
-        """Sets the active bit of a user to false."""
-        user = User.query.filter_by(username=username).first()
-
-        if not user:
-            sys.stdout.write('User [{0:s}] does not exist.\n'.format(
-                username))
-            return
-        user.active = False
-        db_session.add(user)
-        db_session.commit()
-
-        sys.stdout.write('User {0:s} is deactivated.\n'.format(username))
-
-class EnableUser(Command):
-    """Enable User"""
-    option_list = (
-        Option('--username', '-u', dest='username', required=True),
-    )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, username):
-        """Sets the active bit of a user to true."""
-        user = User.query.filter_by(username=username).first()
-
-        if not user:
-            sys.stdout.write('User [{0:s}] does not exist.\n'.format(
-                username))
-            return
-        user.active = True
-        db_session.add(user)
-        db_session.commit()
-
-        sys.stdout.write('User {0:s} is activated.\n'.format(username))
+    if not password:
+        password = get_password_from_prompt()
+    user = User.get_or_create(username=username)
+    user.set_password(plaintext=password)
+    db_session.add(user)
+    db_session.commit()
+    print(f"User {username, password} created/updated")
+
+
+@cli.command(name="enable-user")
+@click.argument("username")
+def enable_user(username):
+    """Enable a user."""
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    user.active = True
+    db_session.add(user)
+    db_session.commit()
+    print(f"Activated user {username}")
+
+
+@cli.command(name="disable-user")
+@click.argument("username")
+def disable_user(username):
+    """Disable a user."""
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    user.active = False
+    db_session.add(user)
+    db_session.commit()
+    print(f"Disabled user {username}")
+
+
+@cli.command(name="make-admin")
+@click.argument("username")
+def make_admin(username):
+    """Give a user the admin role."""
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    user.admin = True
+    db_session.add(user)
+    db_session.commit()
+    print("User is now an admin.")
+
+
+@cli.command(name="revoke-admin")
+@click.argument("username")
+def revoke_admin(username):
+    """Revoke a user the admin role."""
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    user.admin = False
+    db_session.add(user)
+    db_session.commit()
+    print("User is not an admin anymore.")
+
+
+@cli.command(name="grant-user")
+@click.argument("username")
+@click.option("--sketch_id", required=True)
+def grant_user(username, sketch_id):
+    """Grant access to a sketch."""
+    sketch = Sketch.query.filter_by(id=sketch_id).first()
+    user = User.query.filter_by(username=username).first()
+    if not sketch:
+        print("Sketch does not exist.")
+    elif not user:
+        print(f"User {username} does not exist.")
+    else:
+        sketch.grant_permission(permission="read", user=user)
+        sketch.grant_permission(permission="write", user=user)
+        print(f"User {username} added to the sketch {sketch.id} ({sketch.name})")
+
+
+@cli.command(name="version")
+def get_version():
+    """Return the version information of Timesketch."""
+    print(f"Timesketch version: {version.get_version()}")
+
+
+@cli.command(name="drop-db")
+def drop_db():
+    """Drop all database tables."""
+    if click.confirm("Do you really want to drop all the database tables?"):
+        if click.confirm(
+            "Are you REALLLY sure you want to DROP ALL the database tables?"
+        ):
+            print("All tables dropped. Database is now empty.")
+            drop_all()
 
-class ListUsers(Command):
-    """List all users."""
 
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self):
-        """The run method for the command."""
-        for user in User.query.all():
-            if user.admin:
-                extra = ' (admin)'
-            else:
-                extra = ''
-            print('{0:s}{1:s}'.format(user.username, extra))
-
-
-class AddGroup(Command):
-    """Create a new Timesketch group."""
-    option_list = (Option('--name', '-n', dest='name', required=True), )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, name):
-        """Creates the group."""
-        if not isinstance(name, six.text_type):
-            name = codecs.decode(name, 'utf-8')
-        group = Group.get_or_create(name=name)
-        db_session.add(group)
-        db_session.commit()
-        sys.stdout.write('Group {0:s} created\n'.format(name))
+@cli.command(name="list-sketches")
+def list_sketches():
+    """List all sketches."""
+    sketches = Sketch.query.all()
+    for sketch in sketches:
+        status = sketch.get_status.status
+        if status == "deleted":
+            continue
+        print(sketch.id, sketch.name, f"status:{status}")
 
 
-class ListGroups(Command):
+@cli.command(name="list-groups")
+def list_groups():
     """List all groups."""
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self):
-        """The run method for the command."""
-        for group in Group.query.all():
-            print(group.name)
-
-
-class GroupManager(Command):
-    """Manage group memberships."""
-    option_list = (
-        Option(
-            '--remove',
-            '-r',
-            dest='remove',
-            action='store_true',
-            required=False,
-            default=False),
-        Option(
-            '--expand',
-            dest='expand',
-            action='store_true',
-            required=False,
-            default=False),
-        Option('--group', '-g', dest='group_name', required=True),
-        Option('--user', '-u', dest='user_name', required=False, default=None)
-    )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, remove, expand, group_name, user_name):
-        """Add the user to the group."""
-        if not isinstance(group_name, six.text_type):
-            group_name = codecs.decode(group_name, 'utf-8')
-
-        group = Group.query.filter_by(name=group_name).first()
-
-        # List all members of a group and then exit.
-        if expand:
-            for _user in group.users:
-                print(_user.username)
-            return
-
-        if not isinstance(user_name, six.text_type):
-            user_name = codecs.decode(user_name, 'utf-8')
-        user = None
-        if user_name:
-            user = User.query.filter_by(username=user_name).first()
-
-        # Add or remove user from group
-        if remove and user:
-            try:
-                user.groups.remove(group)
-                sys.stdout.write('{0:s} removed from group {1:s}\n'.format(
-                    user_name, group_name))
-                db_session.commit()
-            except ValueError:
-                sys.stdout.write('{0:s} is not a member of group {1:s}\n'.
-                                 format(user_name, group_name))
-        elif user:
-            user.groups.append(group)
-            try:
-                db_session.commit()
-                sys.stdout.write('{0:s} added to group {1:s}\n'.format(
-                    user_name, group_name))
-            except IntegrityError:
-                sys.stdout.write('{0:s} is already a member of group {1:s}\n'.
-                                 format(user_name, group_name))
-
-
-class AddSearchIndex(Command):
-    """Create a new Timesketch searchindex."""
-    option_list = (
-        Option('--name', '-n', dest='name', required=True),
-        Option('--index', '-i', dest='index', required=True),
-        Option('--user', '-u', dest='username', required=True), )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, name, index, username):
-        """Create the SearchIndex."""
-        datastore = OpenSearchDataStore(
-            host=current_app.config['OPENSEARCH_HOST'],
-            port=current_app.config['OPENSEARCH_PORT'])
-        user = User.query.filter_by(username=username).first()
-        if not user:
-            sys.stderr.write('User does not exist\n')
-            sys.exit(1)
-        if not datastore.client.indices.exists(index=index):
-            sys.stderr.write('Index does not exist in the datastore\n')
-            sys.exit(1)
-        if SearchIndex.query.filter_by(name=name, index_name=index).first():
-            sys.stderr.write(
-                'Index with this name already exist in Timesketch\n')
-            sys.exit(1)
-        searchindex = SearchIndex(
-            name=name, description=name, user=user, index_name=index)
-        db_session.add(searchindex)
+    for group in Group.query.all():
+        print(group.name)
+
+
+@cli.command(name="create-group")
+@click.argument("group_name")
+def create_group(group_name):
+    """Create a group."""
+    group = Group.get_or_create(name=group_name)
+    db_session.add(group)
+    db_session.commit()
+    print(f"Group created: {group_name}")
+
+
+@cli.command(name="list-group-members")
+@click.argument("group_name")
+def list_group_members(group_name):
+    """List all members of a group."""
+    group = Group.query.filter_by(name=group_name).first()
+    if not group:
+        print("No such group.")
+        return
+
+    for user in group.users:
+        print(user.username)
+
+
+@cli.command(name="add-group-member")
+@click.argument("group_name")
+@click.option("--username", required=True)
+def add_group_member(group_name, username):
+    """Add a user to a group."""
+    group = Group.query.filter_by(name=group_name).first()
+    if not group:
+        print("No such group.")
+        return
+
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    try:
+        user.groups.append(group)
+        db_session.commit()
+        print("Added user to group.")
+    except IntegrityError:
+        print("User is already a member of the group.")
+
+
+@cli.command(name="remove-group-member")
+@click.argument("group_name")
+@click.option("--username", required=True)
+def remove_group_member(group_name, username):
+    """Remove a user from a group."""
+    group = Group.query.filter_by(name=group_name).first()
+    if not group:
+        print("No such group.")
+        return
+
+    user = User.query.filter_by(username=username).first()
+    if not user:
+        print("User does not exist.")
+        return
+
+    try:
+        user.groups.remove(group)
         db_session.commit()
-        searchindex.grant_permission('read')
-        sys.stdout.write('Search index {0:s} created\n'.format(name))
-
-
-class PurgeTimeline(Command):
-    """Delete timeline permanently from Timesketch and OpenSearch."""
-    option_list = (Option(
-        '--index', '-i', dest='index_name', required=True), )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, index_name):
-        """Delete timeline in both Timesketch and OpenSearch.
-
-        Args:
-            index_name: The name of the index in OpenSearch
-        """
-        if not isinstance(index_name, six.text_type):
-            index_name = codecs.decode(index_name, 'utf-8')
-
-        searchindex = SearchIndex.query.filter_by(
-            index_name=index_name).first()
-
-        if not searchindex:
-            sys.stdout.write('No such index\n')
-            sys.exit()
-
-        datastore = OpenSearchDataStore(
-            host=current_app.config['OPENSEARCH_HOST'],
-            port=current_app.config['OPENSEARCH_PORT'])
-
-        timelines = Timeline.query.filter_by(searchindex=searchindex).all()
-        sketches = [
-            t.sketch for t in timelines
-            if t.sketch and t.sketch.get_status.status != 'deleted'
-        ]
-        if sketches:
-            sys.stdout.write('WARNING: This timeline is in use by:\n')
-            for sketch in sketches:
-                sys.stdout.write(' * {0:s}\n'.format(sketch.name))
-                sys.stdout.flush()
-        really_delete = prompt_bool(
-            'Are you sure you want to delete this timeline?')
-        if really_delete:
-            for timeline in timelines:
-                db_session.delete(timeline)
-            db_session.delete(searchindex)
-            db_session.commit()
-            datastore.client.indices.delete(index=index_name)
-
-
-class SearchTemplateManager(Command):
-    """Command Module to manipulate Search templates."""
-    option_list = (
-        Option('--import', '-i', dest='import_location', required=False),
-        Option('--export', '-e', dest='export_location', required=False),
-    )
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self, import_location, export_location):
-        """Export/Import search templates to/from file.
-
-        Args:
-            import_location: Path to the yaml file to import templates.
-            export_location: Path to the yaml file to export templates.
-        """
-
-        if export_location:
-            search_templates = []
-            for search_template in SearchTemplate.query.all():
-                labels = []
-                for label in search_template.labels:
-                    if label.label.startswith('supported_os:'):
-                        labels.append(label.label.replace(
-                            'supported_os:', ''))
-                search_templates.append({
-                    'name': search_template.name,
-                    'query_string': search_template.query_string,
-                    'query_dsl': search_template.query_dsl,
-                    'supported_os': labels
-                })
-
-            with open(export_location, 'w') as fh:
-                yaml.safe_dump(search_templates, stream=fh)
-
-        if import_location:
-            try:
-                with open(import_location, 'rb') as fh:
-                    search_templates = yaml.safe_load(fh)
-            except IOError as e:
-                sys.stdout.write('Unable to open file: {0!s}\n'.format(e))
-                sys.exit(1)
-
-            for search_template in search_templates:
-                name = search_template['name']
-                query_string = search_template['query_string']
-                query_dsl = search_template['query_dsl']
-
-                # Skip search template if already exits.
-                if SearchTemplate.query.filter_by(name=name).first():
-                    continue
-
-                imported_template = SearchTemplate(
-                    name=name,
-                    user=User(None),
-                    query_string=query_string,
-                    query_dsl=query_dsl)
-
-                # Add supported_os labels.
-                for supported_os in search_template['supported_os']:
-                    label_name = 'supported_os:{0:s}'.format(supported_os)
-                    label = SearchTemplate.Label.get_or_create(
-                        label=label_name, user=None)
-                    imported_template.labels.append(label)
-
-                # Set flag to identify local vs import templates.
-                remote_flag = SearchTemplate.Label.get_or_create(
-                    label='remote_template', user=None)
-                imported_template.labels.append(remote_flag)
-
-                db_session.add(imported_template)
-                db_session.commit()
-
-
-class ListSketches(Command):
-    """List all available sketches."""
-
-    # pylint: disable=arguments-differ, method-hidden
-    def run(self):
-        """The run method for the command."""
-        sketches = Sketch.query.all()
-
-        name_len = max([len(x.name) for x in sketches])
-        desc_len = max([len(x.description) for x in sketches])
-
-        if not name_len:
-            name_len = 5
-        if not desc_len:
-            desc_len = 10
-
-        fmt_string = '{{0:^3d}} | {{1:{0:d}s}} | {{2:{1:d}s}}'.format(
-            name_len, desc_len)
-
-        print('+-'*40)
-        print(' ID | Name {0:s} | Description'.format(' '*(name_len-5)))
-        print('+-'*40)
-        for sketch in sketches:
-            status = sketch.get_status.status
-            if status == 'deleted':
-                continue
-
-            if status == 'archived':
-                name = '{0:s} (archived)'.format(sketch.name)
-            else:
-                name = sketch.name
-
-            print(fmt_string.format(
-                sketch.id, name, sketch.description))
-            print('-'*80)
-
-
-class ImportTimeline(Command):
-    """Create a new Timesketch timeline from a file."""
-    option_list = (
-        Option('--file', '-f', dest='file_path', required=True),
-        Option('--sketch_id', '-s', dest='sketch_id', required=False),
-        Option('--username', '-u', dest='username', required=False),
-        Option('--timeline_name', '-n', dest='timeline_name',
-               required=False),
-    )
-
-    # pylint: disable=arguments-differ, method-hidden, unused-argument
-    def run(self, file_path, sketch_id, username, timeline_name):
-        """This is the run method."""
-        print(
-            'This function has been deprecated, please use the '
-            'timesketch_importer instead: '
-            'https://github.com/google/timesketch/blob/master/'
-            'docs/UploadData.md')
-
-
-def main():
-    """Main function of the script, setting up the shell manager."""
-    # Setup Flask-script command manager and register commands.
-    shell_manager = Manager(create_app)
-    shell_manager.add_command('grant_user', GrantUser())
-    shell_manager.add_command('add_user', AddUser())
-    shell_manager.add_command('make_admin', MakeUserAdmin())
-    shell_manager.add_command('list_users', ListUsers())
-    shell_manager.add_command('add_group', AddGroup())
-    shell_manager.add_command('list_groups', ListGroups)
-    shell_manager.add_command('manage_group', GroupManager())
-    shell_manager.add_command('add_index', AddSearchIndex())
-    shell_manager.add_command('db', MigrateCommand)
-    shell_manager.add_command('drop_db', DropDataBaseTables())
-    shell_manager.add_command('list_sketches', ListSketches())
-    shell_manager.add_command('purge', PurgeTimeline())
-    shell_manager.add_command('search_template', SearchTemplateManager())
-    shell_manager.add_command('import', ImportTimeline())
-    shell_manager.add_command('version', GetVersion())
-    shell_manager.add_command('disable_user', DisableUser())
-    shell_manager.add_command("enable_user", EnableUser())
-    shell_manager.add_command('runserver',
-                              Server(host='127.0.0.1', port=5000))
-    shell_manager.add_option(
-        '-c',
-        '--config',
-        dest='config',
-        default='/etc/timesketch/timesketch.conf',
-        required=False)
-    shell_manager.run()
-
-
-if __name__ == '__main__':
-    main()
+        print("Removed user from group.")
+    except ValueError:
+        print("User is not a member of the group.")


Test patch:
diff --git a/timesketch/api/v1/resources_test.py b/timesketch/api/v1/resources_test.py
--- a/timesketch/api/v1/resources_test.py
+++ b/timesketch/api/v1/resources_test.py
@@ -36,8 +36,8 @@ def test_to_json_empty_list(self):
         self.assertEqual(
             response.json,
             {
-                'meta': {},
-                'objects': [],
+                "meta": {},
+                "objects": [],
             },
         )
 
@@ -45,25 +45,25 @@ def test_to_json_empty_list(self):
 class SketchListResourceTest(BaseTest):
     """Test SketchListResource."""
 
-    resource_url = '/api/v1/sketches/'
+    resource_url = "/api/v1/sketches/"
 
     def test_sketch_list_resource(self):
         """Authenticated request to get list of sketches."""
         self.login()
         response = self.client.get(self.resource_url)
-        self.assertEqual(len(response.json['objects']), 2)
-        result = sorted(i['name'] for i in response.json['objects'])
-        self.assertEqual(result, ['Test 1', 'Test 3'])
+        self.assertEqual(len(response.json["objects"]), 2)
+        result = sorted(i["name"] for i in response.json["objects"])
+        self.assertEqual(result, ["Test 1", "Test 3"])
         self.assert200(response)
 
     def test_sketch_post_resource(self):
         """Authenticated request to create a sketch."""
         self.login()
-        data = dict(name='test', description='test')
+        data = dict(name="test", description="test")
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
@@ -71,19 +71,17 @@ def test_sketch_post_resource(self):
 class SketchResourceTest(BaseTest):
     """Test SketchResource."""
 
-    resource_url = '/api/v1/sketches/1/'
+    resource_url = "/api/v1/sketches/1/"
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_sketch_resource(self):
         """Authenticated request to get a sketch."""
         self.login()
         response = self.client.get(self.resource_url)
-        self.assertEqual(len(response.json['objects']), 1)
-        self.assertEqual(len(response.json['objects'][0]['timelines']), 1)
-        self.assertEqual(response.json['objects'][0]['name'], 'Test 1')
-        self.assertIsInstance(response.json['meta']['emojis'], dict)
+        self.assertEqual(len(response.json["objects"]), 1)
+        self.assertEqual(len(response.json["objects"][0]["timelines"]), 1)
+        self.assertEqual(response.json["objects"][0]["name"], "Test 1")
+        self.assertIsInstance(response.json["meta"]["emojis"], dict)
         self.assert200(response)
 
     def test_sketch_acl(self):
@@ -92,35 +90,35 @@ def test_sketch_acl(self):
         permission on.
         """
         self.login()
-        response = self.client.get('/api/v1/sketches/2/')
+        response = self.client.get("/api/v1/sketches/2/")
         self.assert403(response)
 
 
 class ViewListResourceTest(BaseTest):
     """Test ViewListResource."""
 
-    resource_url = '/api/v1/sketches/1/views/'
+    resource_url = "/api/v1/sketches/1/views/"
 
     def test_post_view_list_resource(self):
         """Authenticated request to create a view."""
         self.login()
         data = dict(
-            name='test',
+            name="test",
             new_searchtemplate=False,
-            query='test',
+            query="test",
             filter={},
             dsl={},
         )
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
-        data['from_searchtemplate_id'] = 1
+        data["from_searchtemplate_id"] = 1
         response_with_searchtemplate = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
         self.assertEqual(
@@ -131,143 +129,139 @@ def test_post_view_list_resource(self):
 class ViewResourceTest(BaseTest):
     """Test ViewResource."""
 
-    resource_url = '/api/v1/sketches/1/views/1/'
+    resource_url = "/api/v1/sketches/1/views/1/"
 
     def test_view_resource(self):
         """Authenticated request to get a view."""
         self.login()
         response = self.client.get(self.resource_url)
-        self.assertEqual(len(response.json['objects']), 1)
-        self.assertEqual(response.json['objects'][0]['name'], 'View 1')
+        self.assertEqual(len(response.json["objects"]), 1)
+        self.assertEqual(response.json["objects"][0]["name"], "View 1")
         self.assert200(response)
 
     def test_post_view_resource(self):
         """Authenticated request to update a view."""
         self.login()
-        data = dict(name='test', query='test', filter='{}')
+        data = dict(name="test", query="test", filter="{}")
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
     def test_invalid_user_in_view(self):
         """Authenticated request to get a view for another user."""
         self.login()
-        response = self.client.get('/api/v1/sketches/1/views/3/')
+        response = self.client.get("/api/v1/sketches/1/views/3/")
         self.assert403(response)
 
     def test_invalid_view(self):
         """Authenticated request to get a view for non existing view."""
         self.login()
-        response = self.client.get('/api/v1/sketches/1/views/2/')
+        response = self.client.get("/api/v1/sketches/1/views/2/")
         self.assert404(response)
 
 
 class SearchTemplateResourceTest(BaseTest):
     """Test Search template resource."""
 
-    resource_url = '/api/v1/searchtemplate/1/'
+    resource_url = "/api/v1/searchtemplate/1/"
 
     def test_searchtemplate_resource(self):
         """Authenticated request to get a search template."""
         self.login()
         response = self.client.get(self.resource_url)
-        self.assertEqual(len(response.json['objects']), 1)
-        self.assertEqual(response.json['objects'][0]['name'], 'template')
+        self.assertEqual(len(response.json["objects"]), 1)
+        self.assertEqual(response.json["objects"][0]["name"], "template")
         self.assert200(response)
 
     def test_invalid_searchtemplate(self):
         """Authenticated request to get a non existing search template."""
         self.login()
-        response = self.client.get('/api/v1/searchtemplate/2/')
+        response = self.client.get("/api/v1/searchtemplate/2/")
         self.assert404(response)
 
 
 class ExploreResourceTest(BaseTest):
     """Test ExploreResource."""
 
-    resource_url = '/api/v1/sketches/1/explore/'
+    resource_url = "/api/v1/sketches/1/explore/"
     expected_response = {
-        'meta': {
-            'es_time': 5,
-            'es_total_count': 1,
-            'es_total_count_complete': 0,
-            'timeline_colors': {'test': 'FFFFFF'},
-            'timeline_names': {'test': 'Timeline 1'},
-            'count_per_index': {},
-            'count_per_timeline': {},
-            'count_over_time': {'data': {}, 'interval': ''},
-            'scroll_id': '',
-            'search_node': {
-                'children': [],
-                'description': None,
-                'id': 1,
-                'labels': [],
-                'parent': None,
-                'query_dsl': None,
-                'query_filter': '{}',
-                'query_result_count': 0,
-                'query_string': 'test',
+        "meta": {
+            "es_time": 5,
+            "es_total_count": 1,
+            "es_total_count_complete": 0,
+            "timeline_colors": {"test": "FFFFFF"},
+            "timeline_names": {"test": "Timeline 1"},
+            "count_per_index": {},
+            "count_per_timeline": {},
+            "count_over_time": {"data": {}, "interval": ""},
+            "scroll_id": "",
+            "search_node": {
+                "children": [],
+                "description": None,
+                "id": 1,
+                "labels": [],
+                "parent": None,
+                "query_dsl": None,
+                "query_filter": "{}",
+                "query_result_count": 0,
+                "query_string": "test",
             },
         },
-        'objects': [
+        "objects": [
             {
-                'sort': [1410593223000],
-                '_type': 'plaso_event',
-                '_source': {
-                    'timestamp': 1410593222543942,
-                    'message': 'Test event',
-                    'label': ['__ts_star'],
-                    'timestamp_desc': 'Content Modification Time',
-                    'datetime': '2014-09-13T07:27:03+00:00',
-                    '__ts_timeline_id': 1,
+                "sort": [1410593223000],
+                "_type": "plaso_event",
+                "_source": {
+                    "timestamp": 1410593222543942,
+                    "message": "Test event",
+                    "label": ["__ts_star"],
+                    "timestamp_desc": "Content Modification Time",
+                    "datetime": "2014-09-13T07:27:03+00:00",
+                    "__ts_timeline_id": 1,
                 },
-                '_score': 'null',
-                'selected': False,
-                '_index': 'test',
-                '_id': 'test',
+                "_score": "null",
+                "selected": False,
+                "_index": "test",
+                "_id": "test",
             }
         ],
     }
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_search(self):
         """Authenticated request to query the datastore."""
         self.login()
-        data = dict(query='test', filter={})
+        data = dict(query="test", filter={})
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         response_json = response.json
         # Remove flaky properties (dynamically generated)
-        del response_json['meta']['search_node']['created_at']
-        del response_json['meta']['search_node']['query_time']
-        self.assertDictEqual(response.json, self.expected_response)
+        del response_json["meta"]["search_node"]["created_at"]
+        del response_json["meta"]["search_node"]["query_time"]
+        self.assertDictEqual(response_json, self.expected_response)
         self.assert200(response)
 
 
 class AggregationExploreResourceTest(BaseTest):
     """Test AggregationExploreResource."""
 
-    resource_url = '/api/v1/sketches/1/aggregation/explore/'
+    resource_url = "/api/v1/sketches/1/aggregation/explore/"
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_heatmap_aggregation(self):
         """Authenticated request to get aggregation requests."""
         self.login()
-        data = dict(aggregation_dsl='test')
+        data = dict(aggregation_dsl="test")
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assert200(response)
 
@@ -275,37 +269,33 @@ def test_heatmap_aggregation(self):
 class EventResourceTest(BaseTest):
     """Test EventResource."""
 
-    resource_url = '/api/v1/sketches/1/event/'
+    resource_url = "/api/v1/sketches/1/event/"
     expected_response = {
-        'objects': {
-            'timestamp_desc': '',
-            'timestamp': 1410895419859714,
-            'label': '',
-            'source_long': '',
-            'source_short': '',
-            'es_index': '',
-            'es_id': '',
-            'message': '',
-            'datetime': '2014-09-16T19:23:40+00:00',
-            '__ts_timeline_id': 1,
+        "objects": {
+            "timestamp_desc": "",
+            "timestamp": 1410895419859714,
+            "label": "",
+            "source_long": "",
+            "source_short": "",
+            "es_index": "",
+            "es_id": "",
+            "message": "",
+            "datetime": "2014-09-16T19:23:40+00:00",
+            "__ts_timeline_id": 1,
         }
     }
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_get_event(self):
         """Authenticated request to get an event from the datastore."""
         self.login()
         response = self.client.get(
-            self.resource_url + '?searchindex_id=test&event_id=test'
+            self.resource_url + "?searchindex_id=test&event_id=test"
         )
         self.assertDictContainsSubset(self.expected_response, response.json)
         self.assert200(response)
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_invalid_index(self):
         """
         Authenticated request to get an event from the datastore, but in the
@@ -313,7 +303,7 @@ def test_invalid_index(self):
         """
         self.login()
         response_400 = self.client.get(
-            self.resource_url + '?searchindex_id=wrong_index&event_id=test'
+            self.resource_url + "?searchindex_id=wrong_index&event_id=test"
         )
         self.assert400(response_400)
 
@@ -321,25 +311,23 @@ def test_invalid_index(self):
 class EventAnnotationResourceTest(BaseTest):
     """Test EventAnnotationResource."""
 
-    resource_url = '/api/v1/sketches/1/event/annotate/'
+    resource_url = "/api/v1/sketches/1/event/annotate/"
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_post_annotate_resource(self):
         """Authenticated request to create an annotation."""
         self.login()
-        for annotation_type in ['comment', 'label']:
-            event = {'_type': 'test_event', '_index': 'test', '_id': 'test'}
+        for annotation_type in ["comment", "label"]:
+            event = {"_type": "test_event", "_index": "test", "_id": "test"}
             data = dict(
-                annotation='test',
+                annotation="test",
                 annotation_type=annotation_type,
                 events=[event],
             )
             response = self.client.post(
                 self.resource_url,
                 data=json.dumps(data),
-                content_type='application/json',
+                content_type="application/json",
             )
             self.assertIsInstance(response.json, dict)
             self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
@@ -350,15 +338,15 @@ def test_post_annotate_invalid_index_resource(self):
         """
         self.login()
         data = dict(
-            annotation='test',
-            annotation_type='comment',
-            event_id='test',
-            searchindex_id='invalid_searchindex',
+            annotation="test",
+            annotation_type="comment",
+            event_id="test",
+            searchindex_id="invalid_searchindex",
         )
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_BAD_REQUEST)
 
@@ -366,21 +354,17 @@ def test_post_annotate_invalid_index_resource(self):
 class SearchIndexResourceTest(BaseTest):
     """Test SearchIndexResource."""
 
-    resource_url = '/api/v1/searchindices/'
+    resource_url = "/api/v1/searchindices/"
 
-    @mock.patch(
-        'timesketch.api.v1.resources.OpenSearchDataStore', MockDataStore
-    )
+    @mock.patch("timesketch.api.v1.resources.OpenSearchDataStore", MockDataStore)
     def test_post_create_searchindex(self):
         """Authenticated request to create a searchindex."""
         self.login()
-        data = dict(
-            searchindex_name='test3', es_index_name='test3', public=False
-        )
+        data = dict(searchindex_name="test3", es_index_name="test3", public=False)
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertIsInstance(response.json, dict)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
@@ -389,7 +373,7 @@ def test_post_create_searchindex(self):
 class TimelineListResourceTest(BaseTest):
     """Test TimelineList resource."""
 
-    resource_url = '/api/v1/sketches/1/timelines/'
+    resource_url = "/api/v1/sketches/1/timelines/"
 
     def test_add_existing_timeline_resource(self):
         """Authenticated request to add a timeline to a sketch."""
@@ -398,7 +382,7 @@ def test_add_existing_timeline_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_OK)
 
@@ -409,7 +393,7 @@ def test_add_new_timeline_resource(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_CREATED)
 
@@ -417,14 +401,14 @@ def test_add_new_timeline_resource(self):
 class SigmaResourceTest(BaseTest):
     """Test Sigma resource."""
 
-    resource_url = '/api/v1/sigma/rule/'
+    resource_url = "/api/v1/sigma/rule/"
     expected_response = {
-        'objects': {
-            'description': 'Detects suspicious installation of ZMap',
-            'id': '5266a592-b793-11ea-b3de-0242ac130004',
-            'level': 'high',
-            'logsource': {'product': 'linux', 'service': 'shell'},
-            'title': 'Suspicious Installation of ZMap',
+        "objects": {
+            "description": "Detects suspicious installation of ZMap",
+            "id": "5266a592-b793-11ea-b3de-0242ac130004",
+            "level": "high",
+            "logsource": {"product": "linux", "service": "shell"},
+            "title": "Suspicious Installation of ZMap",
         }
     }
 
@@ -432,7 +416,7 @@ def test_get_sigma_rule(self):
         """Authenticated request to get an sigma rule."""
         self.login()
         response = self.client.get(
-            self.resource_url + '5266a592-b793-11ea-b3de-0242ac130004'
+            self.resource_url + "5266a592-b793-11ea-b3de-0242ac130004"
         )
         self.assertIsNotNone(response)
 
@@ -440,35 +424,35 @@ def test_get_sigma_rule(self):
 class SigmaListResourceTest(BaseTest):
     """Test Sigma resource."""
 
-    resource_url = '/api/v1/sigma/'
+    resource_url = "/api/v1/sigma/"
     expected_response = {
-        'meta': {'current_user': 'test1', 'rules_count': 1},
-        'objects': [
+        "meta": {"current_user": "test1", "rules_count": 1},
+        "objects": [
             {
-                'author': 'Alexander Jaeger',
-                'date': '2020/06/26',
-                'description': 'Detects suspicious installation of ZMap',
-                'detection': {
-                    'condition': 'keywords',
-                    'keywords': ['*apt-get install zmap*'],
+                "author": "Alexander Jaeger",
+                "date": "2020/06/26",
+                "description": "Detects suspicious installation of ZMap",
+                "detection": {
+                    "condition": "keywords",
+                    "keywords": ["*apt-get install zmap*"],
                 },
-                'es_query': '(data_type:("shell:zsh:history" OR '
+                "es_query": '(data_type:("shell:zsh:history" OR '
                 '"bash:history:command" OR '
                 '"apt:history:line" OR '
                 '"selinux:line") AND '
                 '"apt-get install zmap")',
-                'falsepositives': ['Unknown'],
-                'file_name': 'lnx_susp_zmap.yml',
-                'file_relpath': 'lnx_susp_zmap.yml',
-                'id': '5266a592-b793-11ea-b3de-0242ac130004',
-                'level': 'high',
-                'logsource': {'product': 'linux', 'service': 'shell'},
-                'modified': '2020/06/26',
-                'tags': ['attack.discovery', 'attack.t1046'],
-                'references': [
-                    'https://rmusser.net/docs/ATT&CK-Stuff/ATT&CK/Discovery.html'  # pylint: disable=line-too-long
+                "falsepositives": ["Unknown"],
+                "file_name": "lnx_susp_zmap.yml",
+                "file_relpath": "lnx_susp_zmap.yml",
+                "id": "5266a592-b793-11ea-b3de-0242ac130004",
+                "level": "high",
+                "logsource": {"product": "linux", "service": "shell"},
+                "modified": "2020/06/26",
+                "tags": ["attack.discovery", "attack.t1046"],
+                "references": [
+                    "https://rmusser.net/docs/ATT&CK-Stuff/ATT&CK/Discovery.html"  # pylint: disable=line-too-long
                 ],
-                'title': 'Suspicious Installation of ZMap',
+                "title": "Suspicious Installation of ZMap",
             }
         ],
     }
@@ -485,8 +469,8 @@ def test_get_sigma_rule_list(self):
 class SigmaByTextResourceTest(BaseTest):
     """Test Sigma by text resource."""
 
-    resource_url = '/api/v1/sigma/text/'
-    correct_rule = '''
+    resource_url = "/api/v1/sigma/text/"
+    correct_rule = """
         title: Installation of foobar
         id: bb1e0d1d-cd13-4b65-bf7e-69b4e740266b
         description: Detects suspicious installation of foobar
@@ -509,29 +493,29 @@ class SigmaByTextResourceTest(BaseTest):
         falsepositives:
             - Unknown
         level: high
-        '''
+        """
     expected_response = {
-        'meta': {'parsed': True},
-        'objects': [
+        "meta": {"parsed": True},
+        "objects": [
             {
-                'title': 'Installation of foobar',
-                'id': 'bb1e0d1d-cd13-4b65-bf7e-69b4e740266b',
-                'description': 'Detects suspicious installation of foobar',
-                'references': ['https://samle.com/foobar'],
-                'author': 'Alexander Jaeger',
-                'date': '2020/12/10',
-                'modified': '2020/12/10',
-                'tags': ['attack.discovery', 'attack.t1046'],
-                'logsource': {'product': 'linux', 'service': 'shell'},
-                'detection': {
-                    'keywords': ['*apt-get install foobar*'],
-                    'condition': 'keywords',
+                "title": "Installation of foobar",
+                "id": "bb1e0d1d-cd13-4b65-bf7e-69b4e740266b",
+                "description": "Detects suspicious installation of foobar",
+                "references": ["https://samle.com/foobar"],
+                "author": "Alexander Jaeger",
+                "date": "2020/12/10",
+                "modified": "2020/12/10",
+                "tags": ["attack.discovery", "attack.t1046"],
+                "logsource": {"product": "linux", "service": "shell"},
+                "detection": {
+                    "keywords": ["*apt-get install foobar*"],
+                    "condition": "keywords",
                 },
-                'falsepositives': ['Unknown'],
-                'level': 'high',
-                'es_query': '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "apt-get install foobar")',  # pylint: disable=line-too-long
-                'file_name': 'N/A',
-                'file_relpath': 'N/A',
+                "falsepositives": ["Unknown"],
+                "level": "high",
+                "es_query": '(data_type:("shell:zsh:history" OR "bash:history:command" OR "apt:history:line" OR "selinux:line") AND "apt-get install foobar")',  # pylint: disable=line-too-long
+                "file_name": "N/A",
+                "file_relpath": "N/A",
             }
         ],
     }
@@ -544,7 +528,7 @@ def test_get_sigma_rule(self):
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         self.assertIsNotNone(response)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_OK)
@@ -552,27 +536,27 @@ def test_get_sigma_rule(self):
         self.assert200(response)
 
         # wrong sigma rule
-        data = dict(content='foobar: asd')
+        data = dict(content="foobar: asd")
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         data = json.loads(response.get_data(as_text=True))
 
-        self.assertIn('No detection definitions found', data['message'])
+        self.assertIn("No detection definitions found", data["message"])
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_BAD_REQUEST)
 
         # no content given
-        data = dict(action='post')
+        data = dict(action="post")
         response = self.client.post(
             self.resource_url,
             data=json.dumps(data, ensure_ascii=False),
-            content_type='application/json',
+            content_type="application/json",
         )
         data = json.loads(response.get_data(as_text=True))
 
-        self.assertIn('Missing values from the request', data['message'])
+        self.assertIn("Missing values from the request", data["message"])
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_BAD_REQUEST)
 
 
@@ -582,25 +566,13 @@ class IntelligenceResourceTest(BaseTest):
     def test_get_intelligence_tag_metadata(self):
         """Authenticated request to get intelligence tag metadata."""
         expected_tag_metadata = {
-            "default": {
-                "class": "info",
-                "weight": 0
-            },
-            "legit": {
-                "class": "success",
-                "weight": 10
-            },
-            "malware": {
-                "class": "danger",
-                "weight": 100
-            },
-            "suspicious": {
-                "class": "warning",
-                "weight": 50
-            }
+            "default": {"class": "info", "weight": 0},
+            "legit": {"class": "success", "weight": 10},
+            "malware": {"class": "danger", "weight": 100},
+            "suspicious": {"class": "warning", "weight": 50},
         }
         self.login()
-        response = self.client.get('/api/v1/intelligence/tagmetadata/')
+        response = self.client.get("/api/v1/intelligence/tagmetadata/")
         data = json.loads(response.get_data(as_text=True))
         self.assertIsNotNone(response)
         self.assertEqual(response.status_code, HTTP_STATUS_CODE_OK)