From 2ddaf917720babd62618108d8d9ebc98a2353347 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 3 Mar 2022 08:48:11 +0100
Subject: [PATCH 4/4] studio: recent errors & changes on home page, more links
 (#58796)

---
 tests/admin_pages/test_studio.py         | 272 ++++++++++++++++++++++-
 wcs/backoffice/studio.py                 |  41 +++-
 wcs/qommon/static/css/dc2/admin.scss     |  16 +-
 wcs/snapshots.py                         |  19 +-
 wcs/sql.py                               |  16 ++
 wcs/templates/wcs/backoffice/studio.html |  62 ++++--
 6 files changed, 394 insertions(+), 32 deletions(-)

diff --git a/tests/admin_pages/test_studio.py b/tests/admin_pages/test_studio.py
index ce74b58bf..03e9d9da2 100644
--- a/tests/admin_pages/test_studio.py
+++ b/tests/admin_pages/test_studio.py
@@ -1,6 +1,17 @@
+import datetime
+from collections import defaultdict
+
 import pytest
 
+from wcs.blocks import BlockDef
+from wcs.carddef import CardDef
+from wcs.data_sources import NamedDataSource
+from wcs.formdef import FormDef
+from wcs.mail_templates import MailTemplate
 from wcs.qommon.http_request import HTTPRequest
+from wcs.qommon.storage import Equal
+from wcs.workflows import Workflow
+from wcs.wscalls import NamedWsCall
 
 from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
 from .test_all import create_superuser
@@ -11,9 +22,8 @@ def pytest_generate_tests(metafunc):
         metafunc.parametrize('pub', ['pickle', 'sql'], indirect=True)
 
 
-@pytest.fixture
-def pub(request):
-    pub = create_temporary_pub(sql_mode=bool('sql' in request.param))
+def pub_fixture(**kwargs):
+    pub = create_temporary_pub(**kwargs)
 
     req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
     pub.set_app_dir(req)
@@ -24,6 +34,16 @@ def pub(request):
     return pub
 
 
+@pytest.fixture
+def pub(request):
+    return pub_fixture(sql_mode=(request.param == 'sql'))
+
+
+@pytest.fixture
+def sql_pub(request):
+    return pub_fixture(sql_mode=True)
+
+
 def teardown_module(module):
     clean_temporary_pub()
 
@@ -37,7 +57,13 @@ def test_studio_home(pub):
     assert '../forms/' in resp.text
     assert '../cards/' in resp.text
     assert '../workflows/' in resp.text
-    assert 'Logged Errors' in resp.text
+    assert '../forms/data-sources/' in resp.text
+    assert '../workflows/data-sources/' not in resp.text
+    assert '../settings/data-sources/' not in resp.text
+    assert '../forms/blocks/' in resp.text
+    assert '../workflows/mail-templates/' in resp.text
+    assert '../settings/wscalls/' in resp.text
+    assert 'see all errors' in resp.text
 
     pub.cfg['admin-permissions'] = {}
     for part in ('forms', 'cards', 'workflows'):
@@ -47,11 +73,247 @@ def test_studio_home(pub):
         if part != 'workflows':
             resp = app.get('/backoffice/studio/')
             assert '../%s/' % part not in resp.text
+            assert '../forms/data-sources/' not in resp.text
+            assert '../workflows/data-sources/' in resp.text
+            assert '../settings/data-sources/' not in resp.text
         else:
             resp = app.get('/backoffice/studio/', status=403)  # totally closed
 
     resp = app.get('/backoffice/')
-    assert 'studio' not in resp.text
+    assert 'backoffice/studio' not in resp.text
+
+    # access to cards only (and settings)
+    pub.cfg['admin-permissions'] = {}
+    pub.cfg['admin-permissions'].update({'forms': ['x'], 'workflows': ['x']})
+    pub.write_cfg()
+    resp = app.get('/backoffice/studio/')
+    assert '../forms/' not in resp.text
+    assert '../cards/' in resp.text
+    assert '../workflows/' not in resp.text
+    assert '../settings/data-sources/' in resp.text
+    assert '../settings/wscalls/' in resp.text
+
+    # no access to settings
+    pub.cfg['admin-permissions'].update({'settings': ['x']})
+    pub.write_cfg()
+    resp = app.get('/backoffice/studio/')
+    assert '../forms/' not in resp.text
+    assert '../cards/' in resp.text
+    assert '../workflows/' not in resp.text
+    assert '../settings/' not in resp.text
+
+
+def test_studio_home_recent_errors(sql_pub):
+    pub = sql_pub
+    create_superuser(pub)
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    assert resp.text.count('logged-errors/') == 1
+
+    def new_error():
+        error = pub.loggederror_class()
+        error.summary = 'Lonely Logged Error'
+        error.exception_class = 'Exception'
+        error.exception_message = 'foo bar'
+        error.first_occurence_timestamp = datetime.datetime.now()
+        error.occurences_count = 17654032
+        error.store()
+        return error
+
+    errors = [new_error()]
+    resp = app.get('/backoffice/studio/')
+    assert resp.text.count('logged-errors/') == 2
+    assert 'logged-errors/%s/' % errors[0].id in resp
+
+    for i in range(5):
+        errors.append(new_error())
+    resp = app.get('/backoffice/studio/')
+    assert resp.text.count('logged-errors/') == 6
+    # five recent errors displayed
+    assert 'logged-errors/%s/' % errors[0].id not in resp
+    assert 'logged-errors/%s/' % errors[1].id in resp
+    assert 'logged-errors/%s/' % errors[2].id in resp
+    assert 'logged-errors/%s/' % errors[3].id in resp
+    assert 'logged-errors/%s/' % errors[4].id in resp
+    assert 'logged-errors/%s/' % errors[5].id in resp
+
+
+def test_studio_home_recent_changes(sql_pub):
+    pub = sql_pub
+    create_superuser(pub)
+
+    BlockDef.wipe()
+    CardDef.wipe()
+    NamedDataSource.wipe()
+    FormDef.wipe()
+    MailTemplate.wipe()
+    Workflow.wipe()
+    NamedWsCall.wipe()
+
+    objects = defaultdict(list)
+    for i in range(6):
+        for klass in [BlockDef, CardDef, NamedDataSource, FormDef, MailTemplate, Workflow, NamedWsCall]:
+            obj = klass()
+            obj.name = 'foo %s' % i
+            obj.store()
+            objects[klass.xml_root_node].append(obj)
+    for klass in [BlockDef, CardDef, NamedDataSource, FormDef, MailTemplate, Workflow, NamedWsCall]:
+        assert pub.snapshot_class.count(clause=[Equal('object_type', klass.xml_root_node)]) == 6
+        # 2 snapshots for this one, but will be displayed only once
+        objects[klass.xml_root_node][-1].name += ' bar'
+        objects[klass.xml_root_node][-1].store()
+        assert pub.snapshot_class.count(clause=[Equal('object_type', klass.xml_root_node)]) == 7
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    # too old
+    for i in range(5):
+        assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][i].id not in resp
+        assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
+        assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
+
+    # too old
+    assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][5].id not in resp
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id not in resp
+    # only 5 elements
+    assert 'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
+    assert (
+        'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id not in resp
+    )  # not this url
+    assert (
+        'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id
+        not in resp  # not this url
+    )
+    assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
+    assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][5].id in resp
+
+    pub.cfg['admin-permissions'] = {}
+    pub.cfg['admin-permissions'].update({'settings': ['x']})
+    pub.write_cfg()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    # no access to settings
+    for i in range(6):
+        assert (
+            'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
+    # too old
+    for i in range(5):
+        assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][i].id not in resp
+        assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id not in resp
+        assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
+    # too old
+    assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][5].id not in resp
+    # only 5 elements
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id in resp
+    assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
+    assert (
+        'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id
+        not in resp  # not this url
+    )
+    assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
+
+    pub.cfg['admin-permissions'] = {}
+    pub.cfg['admin-permissions'].update({'settings': ['x'], 'forms': ['x']})
+    pub.write_cfg()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    # no access to settings or forms
+    for i in range(6):
+        assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][i].id not in resp
+        assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
+    # too old
+    for i in range(4):
+        assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert (
+            'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
+    # too old
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][4].id not in resp
+    assert 'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][4].id not in resp
+    assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][4].id not in resp
+    # only 5 elements
+    assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][4].id in resp
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
+
+    pub.cfg['admin-permissions'] = {}
+    pub.cfg['admin-permissions'].update({'settings': ['x'], 'forms': ['x'], 'workflows': ['x']})
+    pub.write_cfg()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    # no access to settings, forms or workflows
+    for i in range(6):
+        assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][i].id not in resp
+        assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
+        assert (
+            'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
+        )
+        assert (
+            'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
+        )
+        assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
+    # too old
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][0].id not in resp
+    # only 5 elements
+    for i in range(1, 6):
+        assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id in resp
+
+    objects[CardDef.xml_root_node][5].remove_self()
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/studio/')
+    # too old
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][0].id not in resp
+    # only 4 elements, one was deleted
+    for i in range(1, 5):
+        assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id in resp
+        # deleted
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id not in resp
 
 
 def test_studio_workflows(pub):
diff --git a/wcs/backoffice/studio.py b/wcs/backoffice/studio.py
index 23ea909fc..509ca0f83 100644
--- a/wcs/backoffice/studio.py
+++ b/wcs/backoffice/studio.py
@@ -18,9 +18,16 @@ from quixote import get_publisher
 from quixote.directory import Directory
 
 from wcs.admin.logged_errors import LoggedErrorsDirectory
-from wcs.qommon import _, template
+from wcs.blocks import BlockDef
+from wcs.carddef import CardDef
+from wcs.data_sources import NamedDataSource
+from wcs.formdef import FormDef
+from wcs.mail_templates import MailTemplate
+from wcs.qommon import _, pgettext, template
 from wcs.qommon.backoffice.menu import html_top
 from wcs.qommon.form import get_response
+from wcs.workflows import Workflow
+from wcs.wscalls import NamedWsCall
 
 
 class StudioDirectory(Directory):
@@ -38,8 +45,38 @@ class StudioDirectory(Directory):
 
     def _q_index(self):
         self.html_top(_('Studio'))
+        extra_links = []
+        backoffice_root = get_publisher().get_backoffice_root()
+        object_types = []
+        if backoffice_root.is_accessible('forms'):
+            extra_links.append(('../forms/data-sources/', pgettext('studio', 'Data sources')))
+            extra_links.append(('../forms/blocks/', pgettext('studio', 'Field blocks')))
+            object_types += [NamedDataSource, BlockDef, FormDef]
+        elif backoffice_root.is_accessible('workflows'):
+            extra_links.append(('../workflows/data-sources/', pgettext('studio', 'Data sources')))
+            object_types += [NamedDataSource]
+        elif backoffice_root.is_accessible('settings'):
+            extra_links.append(('../settings/data-sources/', pgettext('studio', 'Data sources')))
+            object_types += [NamedDataSource]
+        if backoffice_root.is_accessible('workflows'):
+            extra_links.append(('../workflows/mail-templates/', pgettext('studio', 'Mail templates')))
+            object_types += [Workflow, MailTemplate]
+        if backoffice_root.is_accessible('settings'):
+            extra_links.append(('../settings/wscalls/', pgettext('studio', 'Webservice calls')))
+            object_types += [NamedWsCall]
+        if backoffice_root.is_accessible('cards'):
+            object_types += [CardDef]
+        context = {
+            'has_sidebar': False,
+            'extra_links': extra_links,
+            'recent_errors': LoggedErrorsDirectory.get_errors(offset=0, limit=5)[0],
+        }
+        if get_publisher().snapshot_class:
+            context['recent_objects'] = get_publisher().snapshot_class.get_recent_changes(
+                object_types=[ot.xml_root_node for ot in object_types]
+            )
         return template.QommonTemplateResponse(
-            templates=['wcs/backoffice/studio.html'], context={'has_sidebar': True}, is_django_native=True
+            templates=['wcs/backoffice/studio.html'], context=context, is_django_native=True
         )
 
     def is_accessible(self, user):
diff --git a/wcs/qommon/static/css/dc2/admin.scss b/wcs/qommon/static/css/dc2/admin.scss
index fb1ce4efb..b84744b29 100644
--- a/wcs/qommon/static/css/dc2/admin.scss
+++ b/wcs/qommon/static/css/dc2/admin.scss
@@ -1922,7 +1922,7 @@ ul#evolutions li.msg-out div.msg {
 div#studio {
 	background: url(studio.svg) bottom right no-repeat;
 	background-size: auto 90%;
-	width: 100%;
+	width: 99%;
 	height: 90%;
 }
 
@@ -1931,7 +1931,6 @@ a.button.button-paragraph {
 	box-sizing: border-box;
 	display: block;
 	max-width: 100%;
-	width: 40rem;
 	margin-bottom: 1rem;
 }
 
@@ -1950,6 +1949,19 @@ a.button.button-paragraph:hover p {
 	color: white;
 }
 
+div.paragraph {
+	background: white;
+	box-sizing: border-box;
+	border: 1px solid #386ede;
+	border-radius: 3px;
+	max-width: 100%;
+	padding: 5px 15px;
+	margin-bottom: 1rem;
+	a.logged-errors-all {
+		font-style: italic,
+	}
+}
+
 #main-content form#multi-actions {
 	padding: 0;
 }
diff --git a/wcs/snapshots.py b/wcs/snapshots.py
index e585d95b0..31cfbd4b1 100644
--- a/wcs/snapshots.py
+++ b/wcs/snapshots.py
@@ -182,7 +182,22 @@ class Snapshot:
             # else: keep serialization and ignore patch
             obj.store()
 
+    @classmethod
+    def get_recent_changes(cls, object_types):
+        elements = cls._get_recent_changes(object_types)
+        instances = []
+        for object_type, object_id in elements:
+            klass = cls.get_class(object_type)
+            instance = klass.get(object_id, ignore_errors=True)
+            if instance:
+                instances.append(instance)
+        return instances
+
     def get_object_class(self):
+        return Snapshot.get_class(self.object_type)
+
+    @classmethod
+    def get_class(cls, object_type):
         from wcs.blocks import BlockDef
         from wcs.carddef import CardDef
         from wcs.categories import BlockCategory, CardDefCategory, Category, WorkflowCategory
@@ -205,9 +220,9 @@ class Snapshot:
             WorkflowCategory,
             BlockCategory,
         ):
-            if klass.xml_root_node == self.object_type:
+            if klass.xml_root_node == object_type:
                 return klass
-        raise KeyError('no class for object type: %s' % self.object_type)
+        raise KeyError('no class for object type: %s' % object_type)
 
     def get_serialization(self, indented=True):
         # there is a complete serialization
diff --git a/wcs/sql.py b/wcs/sql.py
index 5a65411e8..961db8d51 100644
--- a/wcs/sql.py
+++ b/wcs/sql.py
@@ -3264,6 +3264,22 @@ class Snapshot(SqlMixin, wcs.snapshots.Snapshot):
             return None
         return cls.get(row[0])
 
+    @classmethod
+    def _get_recent_changes(cls, object_types):
+        conn, cur = get_connection_and_cursor()
+        sql_statement = '''SELECT object_type, object_id, MAX(timestamp) AS m
+                           FROM snapshots
+                           WHERE object_type IN %(object_types)s
+                           GROUP BY object_type, object_id
+                           ORDER BY m DESC
+                           LIMIT 5'''
+        parameters = {'object_types': tuple(object_types)}
+        cur.execute(sql_statement, parameters)
+        result = [(x, y) for x, y, z in cur.fetchall()]
+        conn.commit()
+        cur.close()
+        return result
+
 
 class LoggedError(SqlMixin, wcs.logged_errors.LoggedError):
     _table_name = 'loggederrors'
diff --git a/wcs/templates/wcs/backoffice/studio.html b/wcs/templates/wcs/backoffice/studio.html
index 1dca268e1..e6c552832 100644
--- a/wcs/templates/wcs/backoffice/studio.html
+++ b/wcs/templates/wcs/backoffice/studio.html
@@ -10,26 +10,46 @@
 {% endblock %}
 
 <div id="studio">
-{% if user.can_go_in_backoffice_forms %}
-<a class="button button-paragraph" href="../forms/">{% trans "Forms" context "studio" %}
-  <p>{% trans "Forms are typically used to collect user demands." %}</p>
-</a>
-{% endif %}
-{% if user.can_go_in_backoffice_cards %}
-<a class="button button-paragraph" href="../cards/">{% trans "Cards" context "studio" %}
-  <p>{% trans "Cards are used to store list of structured data." %}</p>
-</a>
-{% endif %}
-{% if user.can_go_in_backoffice_workflows %}
-<a class="button button-paragraph" href="../workflows/">{% trans "Workflows" context "studio" %}
-  <p>{% trans "Workflows are used to add custom behaviours or actions to forms and cards." %}</p>
-</a>
-{% endif %}
-</div>
-{% endblock %}
+  <div class="fx-grid--t3">
+    <div class="fx-grid--auto">
+      {% if user.can_go_in_backoffice_forms %}
+      <a class="button button-paragraph size--1-1" href="../forms/">{% trans "Forms" context "studio" %}
+        <p>{% trans "Forms are typically used to collect user demands." %}</p>
+      </a>
+      {% endif %}
+      {% if user.can_go_in_backoffice_cards %}
+      <a class="button button-paragraph size--1-1" href="../cards/">{% trans "Cards" context "studio" %}
+        <p>{% trans "Cards are used to store list of structured data." %}</p>
+      </a>
+      {% endif %}
+      {% if user.can_go_in_backoffice_workflows %}
+      <a class="button button-paragraph size--1-1" href="../workflows/">{% trans "Workflows" context "studio" %}
+        <p>{% trans "Workflows are used to add custom behaviours or actions to forms and cards." %}</p>
+      </a>
+      {% endif %}
+      {% for link, label in extra_links %}
+      <a class="button button-paragraph" href="{{ link }}">{{ label }}</a>
+      {% endfor %}
+    </div>
 
-{% block sidebar-content %}
-<ul id="sidebar-actions">
-  <li><a href="logged-errors/">{% trans "Logged Errors" %}</a></li>
-</ul>
+    <div class="paragraph">
+      <h3>{% trans "Recent changes" context "studio" %}</h3>
+      <ul>
+        {% for obj in recent_objects %}
+        <li><a href="{{ obj.get_admin_url }}">{{ obj.name }} ({{ obj.verbose_name }})</a></li>
+        {% endfor %}
+      </ul>
+    </div>
+
+    <div class="paragraph">
+      <h3>{% trans "Recent errors" context "studio" %}</h3>
+      <ul>
+        {% for error in recent_errors %}
+        <li><a href="logged-errors/{{ error.id }}/">{{ error.summary }}</a></li>
+        {% endfor %}
+        <li><a class="logged-errors-all" href="logged-errors/">({% trans "see all errors" context "studio" %})</a>
+      </ul>
+    </div>
+  </div>
+</div>
 {% endblock %}
-- 
2.34.1

