diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e544f50..5e1ab85 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,8 +9,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] - postgresql-version: [10, 11, 12, 13] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + postgresql-version: [12, 13, 14, 15, 16] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -50,7 +50,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: "3.10" + python-version: "3.12" - uses: actions/cache@v2 name: Configure pip caching with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f37db08..c86b176 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,8 +7,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] - postgresql-version: [10, 11, 12, 13] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + postgresql-version: [12, 13, 14, 15, 16] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -39,6 +39,7 @@ jobs: run: | export POSTGRESQL_PATH="/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/postgres" export INITDB_PATH="/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/initdb" + export PYTHONPATH="pytest_plugins:test_project" pytest - name: Check formatting run: black . --check diff --git a/django_sql_dashboard/migrations/0001_initial.py b/django_sql_dashboard/migrations/0001_initial.py index 621e1c4..8614a15 100644 --- a/django_sql_dashboard/migrations/0001_initial.py +++ b/django_sql_dashboard/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/django_sql_dashboard/migrations/0002_dashboard_permissions.py b/django_sql_dashboard/migrations/0002_dashboard_permissions.py index 1ea68af..9582ac0 100644 --- a/django_sql_dashboard/migrations/0002_dashboard_permissions.py +++ b/django_sql_dashboard/migrations/0002_dashboard_permissions.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("django_sql_dashboard", "0001_initial"), diff --git a/django_sql_dashboard/migrations/0003_update_metadata.py b/django_sql_dashboard/migrations/0003_update_metadata.py index 423eb0e..135a555 100644 --- a/django_sql_dashboard/migrations/0003_update_metadata.py +++ b/django_sql_dashboard/migrations/0003_update_metadata.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - dependencies = [ ("auth", "0012_alter_user_first_name_max_length"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/django_sql_dashboard/migrations/0004_add_description_help_text.py b/django_sql_dashboard/migrations/0004_add_description_help_text.py index 67b4998..f991299 100644 --- a/django_sql_dashboard/migrations/0004_add_description_help_text.py +++ b/django_sql_dashboard/migrations/0004_add_description_help_text.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("django_sql_dashboard", "0003_update_metadata"), ] diff --git a/django_sql_dashboard/urls.py b/django_sql_dashboard/urls.py index ac37a1f..3bc2eec 100644 --- a/django_sql_dashboard/urls.py +++ b/django_sql_dashboard/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from .views import dashboard, dashboard_index +from .views import dashboard, dashboard_json, dashboard_index urlpatterns = [ path("", dashboard_index, name="django_sql_dashboard-index"), path("/", dashboard, name="django_sql_dashboard-dashboard"), + path(".json", dashboard_json, name="django_sql_dashboard-dashboard_json"), ] diff --git a/django_sql_dashboard/views.py b/django_sql_dashboard/views.py index c51bcef..f56bd6b 100644 --- a/django_sql_dashboard/views.py +++ b/django_sql_dashboard/views.py @@ -13,6 +13,7 @@ from django.http.response import ( HttpResponseForbidden, HttpResponseRedirect, + JsonResponse, StreamingHttpResponse, ) from django.shortcuts import get_object_or_404, render @@ -137,6 +138,7 @@ def _dashboard_index( too_long_so_use_post=False, template="django_sql_dashboard/dashboard.html", extra_context=None, + json_mode=False, ): query_results = [] alias = getattr(settings, "DASHBOARD_DB_ALIAS", "dashboard") @@ -329,6 +331,22 @@ def _dashboard_index( ) ] + if json_mode: + return JsonResponse( + { + "title": title or "SQL Dashboard", + "queries": [ + {"sql": r["sql"], "rows": r["rows"]} for r in query_results + ], + }, + json_dumps_params={ + "indent": 2, + "default": lambda o: o.isoformat() + if hasattr(o, "isoformat") + else str(o), + }, + ) + context = { "title": title or "SQL Dashboard", "html_title": html_title, @@ -362,7 +380,14 @@ def _dashboard_index( return response -def dashboard(request, slug): +def dashboard_json(request, slug): + disable_json = getattr(settings, "DASHBOARD_DISABLE_JSON", None) + if disable_json: + return HttpResponseForbidden("JSON export is disabled") + return dashboard(request, slug, json_mode=True) + + +def dashboard(request, slug, json_mode=False): dashboard = get_object_or_404(Dashboard, slug=slug) # Can current user see it, based on view_policy? view_policy = dashboard.view_policy @@ -398,6 +423,7 @@ def dashboard(request, slug): description=dashboard.description, dashboard=dashboard, template="django_sql_dashboard/saved_dashboard.html", + json_mode=json_mode, ) diff --git a/docs/saved-dashboards.md b/docs/saved-dashboards.md index 8058f1a..d8343d6 100644 --- a/docs/saved-dashboards.md +++ b/docs/saved-dashboards.md @@ -33,3 +33,48 @@ The full list of edit policy options are: - `superuser`: Any user who is a superuser can edit Dashboards belong to the user who created them. Only Django super-users can re-assign ownership of dashboards to other users. + +## JSON export + +If your dashboard is called `/dashboards/demo/` you can add `.json` to get `/dashboards/demo.json` which will return a JSON representation of the dashboard. + +The JSON format looks something like this: + +```json +{ + "title": "Tag word cloud", + "queries": [ + { + "sql": "select \"tag\" as wordcloud_word, count(*) as wordcloud_count from (select blog_tag.tag from blog_entry_tags join blog_tag on blog_entry_tags.tag_id = blog_tag.id\r\nunion all\r\nselect blog_tag.tag from blog_blogmark_tags join blog_tag on blog_blogmark_tags.tag_id = blog_tag.id\r\nunion all\r\nselect blog_tag.tag from blog_quotation_tags join blog_tag on blog_quotation_tags.tag_id = blog_tag.id) as results where tag != 'quora' group by \"tag\" order by wordcloud_count desc", + "rows": [ + { + "wordcloud_word": "python", + "wordcloud_count": 826 + }, + { + "wordcloud_word": "javascript", + "wordcloud_count": 604 + }, + { + "wordcloud_word": "django", + "wordcloud_count": 529 + }, + { + "wordcloud_word": "security", + "wordcloud_count": 402 + }, + { + "wordcloud_word": "datasette", + "wordcloud_count": 331 + }, + { + "wordcloud_word": "projects", + "wordcloud_count": 282 + } + ], + } + ] +} +``` + +Set the `DASHBOARD_DISABLE_JSON` setting to `True` to disable this feature. diff --git a/docs/setup.md b/docs/setup.md index 68970c9..c97f0ec 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -151,6 +151,7 @@ You can customize the following settings in Django's `settings.py` module: - `DASHBOARD_ROW_LIMIT = 1000` - the maximum number of rows that can be returned from a query. This defaults to 100. - `DASHBOARD_UPGRADE_OLD_BASE64_LINKS` - prior to version 0.8a0 SQL URLs used base64-encoded JSON. If you set this to `True` any hits that include those old URLs will be automatically redirected to the upgraded new version. Use this if you have an existing installation of `django-sql-dashboard` that people already have saved bookmarks for. - `DASHBOARD_ENABLE_FULL_EXPORT` - set this to `True` to enable the full results CSV/TSV export feature. It defaults to `False`. Enable this feature only if you are confident that the database alias you are using does not have write permissions to anything. +- `DASHBOARD_DISABLE_JSON` - set to `True` to disable the feature where `/dashboard/name-of-dashboard.json` provides a JSON representation of the dashboard. This defaults to `False`. ## Custom templates diff --git a/pytest_plugins/__init__.py b/pytest_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pytest_use_postgresql.py b/pytest_plugins/pytest_use_postgresql.py similarity index 99% rename from pytest_use_postgresql.py rename to pytest_plugins/pytest_use_postgresql.py index 42b5ac0..cc8ee5e 100644 --- a/pytest_use_postgresql.py +++ b/pytest_plugins/pytest_use_postgresql.py @@ -1,5 +1,4 @@ import os - import pytest from dj_database_url import parse from django.conf import settings diff --git a/test_project/test_export.py b/test_project/test_export.py index 19c483c..47a0ecc 100644 --- a/test_project/test_export.py +++ b/test_project/test_export.py @@ -1,3 +1,6 @@ +import pytest + + def test_export_requires_setting(admin_client, dashboard_db): for key in ("export_csv_0", "export_tsv_0"): response = admin_client.post( @@ -62,3 +65,23 @@ def test_export_tsv(admin_client, dashboard_db, settings): 'attachment; filename="select--hello--as-label' ) assert content_disposition.endswith('.tsv"') + + +@pytest.mark.parametrize("json_disabled", (False, True)) +def test_export_json(admin_client, saved_dashboard, settings, json_disabled): + if json_disabled: + settings.DASHBOARD_DISABLE_JSON = True + + response = admin_client.get("/dashboard/test.json") + if json_disabled: + assert response.status_code == 403 + return + assert response.status_code == 200 + assert response["Content-Type"] == "application/json" + assert response.json() == { + "title": "Test dashboard", + "queries": [ + {"sql": "select 11 + 33", "rows": [{"?column?": 44}]}, + {"sql": "select 22 + 55", "rows": [{"?column?": 77}]}, + ], + }