[
  {
    "path": ".dockerignore",
    "content": ".venv\n.vscode\n__pycache__/\n*.py[cod]\n*$py.class\nvenv\n.eggs\n.pytest_cache\n*.egg-info\n.DS_Store\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish Python Package\n\non:\n  release:\n    types: [created]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n        postgresql-version: [14, 15, 16, 17, 18]\n    steps:\n    - uses: actions/checkout@v6\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n    - uses: actions/cache@v5\n      name: Configure pip caching\n      with:\n        path: ~/.cache/pip\n        key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}\n        restore-keys: |\n          ${{ runner.os }}-pip-\n    - name: Install PostgreSQL\n      env:\n        POSTGRESQL_VERSION: ${{ matrix.postgresql-version }}\n      run: |\n        sudo sh -c 'echo \"deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main\" > /etc/apt/sources.list.d/pgdg.list'\n        wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -\n        sudo apt-get update\n        sudo apt-get -y install \"postgresql-$POSTGRESQL_VERSION\"\n    - name: Install dependencies\n      run: |\n        pip install -e . --group dev\n    - name: Run tests\n      env:\n        POSTGRESQL_VERSION: ${{ matrix.postgresql-version }}\n      run: |\n        export POSTGRESQL_PATH=\"/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/postgres\"\n        export INITDB_PATH=\"/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/initdb\"\n        export PYTHONPATH=\"pytest_plugins:test_project\"\n        pytest\n  deploy:\n    runs-on: ubuntu-latest\n    needs: [test]\n    steps:\n    - uses: actions/checkout@v6\n    - name: Set up Python\n      uses: actions/setup-python@v6\n      with:\n        python-version: \"3.12\"\n    - uses: actions/cache@v5\n      name: Configure pip caching\n      with:\n        path: ~/.cache/pip\n        key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}\n        restore-keys: |\n          ${{ runner.os }}-publish-pip-\n    - name: Install dependencies\n      run: |\n        pip install setuptools wheel twine\n    - name: Publish\n      env:\n        TWINE_USERNAME: __token__\n        TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}\n      run: |\n        python setup.py sdist bdist_wheel\n        twine upload dist/*\n\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.10\", \"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n        postgresql-version: [14, 15, 16, 17, 18]\n    steps:\n    - uses: actions/checkout@v6\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n    - uses: actions/cache@v5\n      name: Configure pip caching\n      with:\n        path: ~/.cache/pip\n        key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}\n        restore-keys: |\n          ${{ runner.os }}-pip-\n    - name: Install PostgreSQL\n      env:\n        POSTGRESQL_VERSION: ${{ matrix.postgresql-version }}\n      run: |\n        sudo sh -c 'echo \"deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main\" > /etc/apt/sources.list.d/pgdg.list'\n        wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -\n        sudo apt-get update\n        sudo apt-get -y install \"postgresql-$POSTGRESQL_VERSION\"\n    - name: Install dependencies\n      run: |\n        pip install -e . --group dev\n    - name: Run tests\n      env:\n        POSTGRESQL_VERSION: ${{ matrix.postgresql-version }}\n      run: |\n        export POSTGRESQL_PATH=\"/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/postgres\"\n        export INITDB_PATH=\"/usr/lib/postgresql/$POSTGRESQL_VERSION/bin/initdb\"\n        export PYTHONPATH=\"pytest_plugins:test_project\"\n        pytest\n    - name: Check formatting\n      run: black . --check\n\n"
  },
  {
    "path": ".gitignore",
    "content": ".venv\n.vscode\n__pycache__/\n*.py[cod]\n*$py.class\nvenv\n.eggs\n.pytest_cache\n*.egg-info\n.DS_Store\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "version: 2\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.11\"\n\nsphinx:\n  configuration: docs/conf.py\n\nformats:\n   - pdf\n   - epub\n\npython:\n   install:\n   - requirements: docs/requirements.txt\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.9\n\nWORKDIR /app\n\n# Set up the minimum structure needed to install\n# django_sql_dashboard's dependencies and the package itself\n# in development mode.\nCOPY setup.py README.md .\nRUN mkdir django_sql_dashboard && pip install -e '.[test]'\n\n# We need to have postgres installed in this container\n# because the automated test suite actually spins up\n# (and shuts down) a database inside the container.\nRUN apt-get update && apt-get install -y \\\n  postgresql postgresql-contrib \\\n  && rm -rf /var/lib/apt/lists/*\n\n# Install dependencies needed for editing documentation.\nCOPY docs/requirements.txt .\nRUN pip install -r requirements.txt\n\nARG GID=1000\nARG UID=1000\n\n# Set up a non-root user.  Aside from being best practice,\n# we also need to do this because the test suite refuses to\n# run as the root user.\nRUN groupadd -g ${GID} appuser && useradd -r -u ${UID} -g appuser appuser\n\nUSER appuser\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# django-sql-dashboard\n\n[![PyPI](https://img.shields.io/pypi/v/django-sql-dashboard.svg)](https://pypi.org/project/django-sql-dashboard/)\n[![Changelog](https://img.shields.io/github/v/release/simonw/django-sql-dashboard?include_prereleases&label=changelog)](https://github.com/simonw/django-sql-dashboard/releases)\n[![Tests](https://github.com/simonw/django-sql-dashboard/workflows/Test/badge.svg)](https://github.com/simonw/django-sql-dashboard/actions?query=workflow%3ATest)\n[![Documentation Status](https://readthedocs.org/projects/django-sql-dashboard/badge/?version=latest)](http://django-sql-dashboard.datasette.io/en/latest/?badge=latest)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/django-sql-dashboard/blob/main/LICENSE)\n\nDjango SQL Dashboard provides an authenticated interface for executing read-only SQL queries directly against your PostgreSQL database, bringing a useful subset of [Datasette](https://datasette.io/) to Django.\n\nApplications include ad-hoc analysis and debugging, plus the creation of reporting dashboards that can be shared with team members or published online.\n\nSee my blog for [more about this project](https://simonwillison.net/2021/May/10/django-sql-dashboard/), including [a video demo](https://www.youtube.com/watch?v=ausrmMZkPEY).\n\nFeatures include:\n\n- Safely run read-only one or more SQL queries against your database and view the results in your browser\n- Bookmark queries and share those links with other members of your team\n- Create [saved dashboards](https://django-sql-dashboard.datasette.io/en/latest/saved-dashboards.html) from your queries, with full control over who can view and edit them\n- [Named parameters](https://django-sql-dashboard.datasette.io/en/latest/sql.html#sql-parameters) such as `select * from entries where id = %(id)s` will be turned into form fields, allowing quick creation of interactive dashboards\n- Produce [bar charts](https://django-sql-dashboard.datasette.io/en/latest/widgets.html#bar-label-bar-quantity), [progress bars](https://django-sql-dashboard.datasette.io/en/latest/widgets.html#total-count-completed-count) and more from SQL queries, with the ability to easily create new [custom dashboard widgets](https://django-sql-dashboard.datasette.io/en/latest/widgets.html#custom-widgets) using the Django template system\n- Write SQL queries that safely construct and render [markdown](https://django-sql-dashboard.datasette.io/en/latest/widgets.html#markdown) and [HTML](https://django-sql-dashboard.datasette.io/en/latest/widgets.html#html)\n- Export the full results of a SQL query as a downloadable CSV or TSV file, using a combination of Django's [streaming HTTP response](https://docs.djangoproject.com/en/3.2/ref/request-response/#django.http.StreamingHttpResponse) mechanism and PostgreSQL [server-side cursors](https://www.psycopg.org/docs/usage.html#server-side-cursors) to efficiently stream large amounts of data without running out of resources\n- Copy and paste the results of SQL queries directly into tools such as Google Sheets or Excel\n- Uses Django's authentication system, so dashboard accounts can be granted using Django's Admin tools\n\n## Documentation\n\nFull documentation is at [django-sql-dashboard.datasette.io](https://django-sql-dashboard.datasette.io/)\n\n## Screenshot\n\n<img width=\"1018\" alt=\"Screenshot showing a SQL query that produces a table and one that produces a bar chart\" src=\"https://user-images.githubusercontent.com/9599/124050883-42ad2300-d9d0-11eb-83e6-44ad85f7ef64.png\">\n\n## Alternatives\n\n- [django-sql-explorer](https://github.com/groveco/django-sql-explorer) provides a related set of functionality that also works against database backends other than PostgreSQL\n"
  },
  {
    "path": "conftest.py",
    "content": "import pytest\nfrom django.contrib.auth.models import Permission\n\nfrom django_sql_dashboard.models import Dashboard\n\n\ndef pytest_collection_modifyitems(items):\n    \"\"\"Add django_db marker with databases to tests that need database access.\"\"\"\n    for item in items:\n        fixturenames = getattr(item, \"fixturenames\", ())\n        # Tests using client fixtures or dashboard_db need both databases\n        if any(f in fixturenames for f in (\"admin_client\", \"client\", \"dashboard_db\")):\n            item.add_marker(pytest.mark.django_db(databases=[\"default\", \"dashboard\"]))\n\n\n@pytest.fixture\ndef dashboard_db(settings):\n    settings.DATABASES[\"dashboard\"][\"OPTIONS\"] = {\n        \"options\": \"-c default_transaction_read_only=on -c statement_timeout=100\"\n    }\n\n\n@pytest.fixture\ndef execute_sql_permission():\n    return Permission.objects.get(\n        content_type__app_label=\"django_sql_dashboard\",\n        content_type__model=\"dashboard\",\n        codename=\"execute_sql\",\n    )\n\n\n@pytest.fixture\ndef saved_dashboard(dashboard_db):\n    dashboard = Dashboard.objects.create(\n        slug=\"test\",\n        title=\"Test dashboard\",\n        description=\"This [supports markdown](http://example.com/)\",\n        view_policy=\"public\",\n    )\n    dashboard.queries.create(sql=\"select 11 + 33\")\n    dashboard.queries.create(sql=\"select 22 + 55\")\n    return dashboard\n"
  },
  {
    "path": "django_sql_dashboard/__init__.py",
    "content": "urls = \"django_sql_dashboard.urls\"\n"
  },
  {
    "path": "django_sql_dashboard/admin.py",
    "content": "from html import escape\n\nfrom django.contrib import admin\nfrom django.utils.safestring import mark_safe\n\nfrom .models import Dashboard, DashboardQuery\n\n\nclass DashboardQueryInline(admin.TabularInline):\n    model = DashboardQuery\n    extra = 1\n\n    def has_change_permission(self, request, obj=None):\n        if obj is None:\n            return True\n        return obj.user_can_edit(request.user)\n\n    def get_readonly_fields(self, request, obj=None):\n        if not request.user.has_perm(\"django_sql_dashboard.execute_sql\"):\n            return (\"sql\",)\n        else:\n            return tuple()\n\n\n@admin.register(Dashboard)\nclass DashboardAdmin(admin.ModelAdmin):\n    list_display = (\"slug\", \"title\", \"owned_by\", \"view_policy\", \"view_dashboard\")\n    inlines = [\n        DashboardQueryInline,\n    ]\n    raw_id_fields = (\"owned_by\",)\n    fieldsets = (\n        (\n            None,\n            {\"fields\": (\"slug\", \"title\", \"description\", \"owned_by\", \"created_at\")},\n        ),\n        (\n            \"Permissions\",\n            {\"fields\": (\"view_policy\", \"edit_policy\", \"view_group\", \"edit_group\")},\n        ),\n    )\n\n    def view_dashboard(self, obj):\n        return mark_safe(\n            '<a href=\"{path}\">{path}</a>'.format(path=escape(obj.get_absolute_url()))\n        )\n\n    def save_model(self, request, obj, form, change):\n        if not obj.owned_by_id:\n            obj.owned_by = request.user\n        obj.save()\n\n    def has_change_permission(self, request, obj=None):\n        if obj is None:\n            return True\n        if request.user.is_superuser:\n            return True\n        return obj.user_can_edit(request.user)\n\n    def get_readonly_fields(self, request, obj):\n        readonly_fields = [\"created_at\"]\n        if not request.user.is_superuser:\n            readonly_fields.append(\"owned_by\")\n        return readonly_fields\n\n    def get_queryset(self, request):\n        if request.user.is_superuser:\n            # Superusers should be able to see all dashboards.\n            return super().get_queryset(request)\n        # Otherwise, show only the dashboards the user has edit access to.\n        return Dashboard.get_editable_by_user(request.user)\n"
  },
  {
    "path": "django_sql_dashboard/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass DjangoSqlDashboardConfig(AppConfig):\n    name = \"django_sql_dashboard\"\n    default_auto_field = \"django.db.models.AutoField\"\n"
  },
  {
    "path": "django_sql_dashboard/migrations/0001_initial.py",
    "content": "# Generated by Django 3.1.7 on 2021-03-13 04:32\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Dashboard\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"slug\", models.SlugField(unique=True)),\n                (\"title\", models.CharField(blank=True, max_length=128)),\n                (\"description\", models.TextField(blank=True)),\n            ],\n            options={\n                \"permissions\": [(\"execute_sql\", \"Can execute arbitrary SQL queries\")],\n            },\n        ),\n        migrations.CreateModel(\n            name=\"DashboardQuery\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"sql\", models.TextField()),\n                (\n                    \"dashboard\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"queries\",\n                        to=\"django_sql_dashboard.dashboard\",\n                    ),\n                ),\n            ],\n            options={\n                \"verbose_name_plural\": \"Dashboard queries\",\n                \"order_with_respect_to\": \"dashboard\",\n            },\n        ),\n    ]\n"
  },
  {
    "path": "django_sql_dashboard/migrations/0002_dashboard_permissions.py",
    "content": "# Generated by Django 3.1.7 on 2021-03-16 16:11\n\nimport django.db.models.deletion\nimport django.utils.timezone\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"django_sql_dashboard\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"created_at\",\n            field=models.DateTimeField(default=django.utils.timezone.now),\n        ),\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"edit_group\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"can_edit_dashboards\",\n                to=\"auth.group\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"edit_policy\",\n            field=models.CharField(\n                choices=[\n                    (\"private\", \"Private\"),\n                    (\"loggedin\", \"Logged-in users\"),\n                    (\"group\", \"Users in group\"),\n                    (\"staff\", \"Staff users\"),\n                    (\"superuser\", \"Superusers\"),\n                ],\n                default=\"private\",\n                max_length=10,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"owned_by\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"owned_dashboards\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"view_group\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"can_view_dashboards\",\n                to=\"auth.group\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"dashboard\",\n            name=\"view_policy\",\n            field=models.CharField(\n                choices=[\n                    (\"private\", \"Private\"),\n                    (\"public\", \"Public\"),\n                    (\"unlisted\", \"Unlisted\"),\n                    (\"loggedin\", \"Logged-in users\"),\n                    (\"group\", \"Users in group\"),\n                    (\"staff\", \"Staff users\"),\n                    (\"superuser\", \"Superusers\"),\n                ],\n                default=\"private\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "django_sql_dashboard/migrations/0003_update_metadata.py",
    "content": "# Generated by Django 3.1.7 on 2021-05-08 15:44\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"auth\", \"0012_alter_user_first_name_max_length\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"django_sql_dashboard\", \"0002_dashboard_permissions\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"edit_group\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"Group that can edit, for 'Users in group' policy\",\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"can_edit_dashboards\",\n                to=\"auth.group\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"edit_policy\",\n            field=models.CharField(\n                choices=[\n                    (\"private\", \"Private\"),\n                    (\"loggedin\", \"Logged-in users\"),\n                    (\"group\", \"Users in group\"),\n                    (\"staff\", \"Staff users\"),\n                    (\"superuser\", \"Superusers\"),\n                ],\n                default=\"private\",\n                help_text=\"Who can edit this dashboard\",\n                max_length=10,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"owned_by\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"User who owns this dashboard\",\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"owned_dashboards\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"view_group\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"Group that can view, for 'Users in group' policy\",\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"can_view_dashboards\",\n                to=\"auth.group\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"view_policy\",\n            field=models.CharField(\n                choices=[\n                    (\"private\", \"Private\"),\n                    (\"public\", \"Public\"),\n                    (\"unlisted\", \"Unlisted\"),\n                    (\"loggedin\", \"Logged-in users\"),\n                    (\"group\", \"Users in group\"),\n                    (\"staff\", \"Staff users\"),\n                    (\"superuser\", \"Superusers\"),\n                ],\n                default=\"private\",\n                help_text=\"Who can view this dashboard\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "django_sql_dashboard/migrations/0004_add_description_help_text.py",
    "content": "# Generated by Django 3.2.3 on 2021-05-25 10:58\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"django_sql_dashboard\", \"0003_update_metadata\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"dashboard\",\n            name=\"description\",\n            field=models.TextField(\n                blank=True, help_text=\"Optional description (Markdown allowed)\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "django_sql_dashboard/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "django_sql_dashboard/models.py",
    "content": "from django.conf import settings\nfrom django.db import models\nfrom django.urls import reverse\nfrom django.utils import timezone\n\n\nclass Dashboard(models.Model):\n    slug = models.SlugField(unique=True)\n    title = models.CharField(blank=True, max_length=128)\n    description = models.TextField(\n        blank=True, help_text=\"Optional description (Markdown allowed)\"\n    )\n    owned_by = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        null=True,\n        blank=True,\n        on_delete=models.SET_NULL,\n        related_name=\"owned_dashboards\",\n        help_text=\"User who owns this dashboard\",\n    )\n    created_at = models.DateTimeField(default=timezone.now)\n\n    class ViewPolicies(models.TextChoices):\n        PRIVATE = (\"private\", \"Private\")\n        PUBLIC = (\"public\", \"Public\")\n        UNLISTED = (\"unlisted\", \"Unlisted\")\n        LOGGEDIN = (\"loggedin\", \"Logged-in users\")\n        GROUP = (\"group\", \"Users in group\")\n        STAFF = (\"staff\", \"Staff users\")\n        SUPERUSER = (\"superuser\", \"Superusers\")\n\n    class EditPolicies(models.TextChoices):\n        PRIVATE = (\"private\", \"Private\")\n        LOGGEDIN = (\"loggedin\", \"Logged-in users\")\n        GROUP = (\"group\", \"Users in group\")\n        STAFF = (\"staff\", \"Staff users\")\n        SUPERUSER = (\"superuser\", \"Superusers\")\n\n    # Permissions\n    view_policy = models.CharField(\n        max_length=10,\n        choices=ViewPolicies.choices,\n        default=ViewPolicies.PRIVATE,\n        help_text=\"Who can view this dashboard\",\n    )\n    edit_policy = models.CharField(\n        max_length=10,\n        choices=EditPolicies.choices,\n        default=EditPolicies.PRIVATE,\n        help_text=\"Who can edit this dashboard\",\n    )\n    view_group = models.ForeignKey(\n        \"auth.Group\",\n        null=True,\n        blank=True,\n        on_delete=models.SET_NULL,\n        related_name=\"can_view_dashboards\",\n        help_text=\"Group that can view, for 'Users in group' policy\",\n    )\n    edit_group = models.ForeignKey(\n        \"auth.Group\",\n        null=True,\n        blank=True,\n        on_delete=models.SET_NULL,\n        related_name=\"can_edit_dashboards\",\n        help_text=\"Group that can edit, for 'Users in group' policy\",\n    )\n\n    def __str__(self):\n        return self.title or self.slug\n\n    def view_summary(self):\n        s = self.get_view_policy_display()\n        if self.view_policy == \"group\":\n            s += ' \"{}\"'.format(self.view_group)\n        return s\n\n    def get_absolute_url(self):\n        return reverse(\"django_sql_dashboard-dashboard\", args=[self.slug])\n\n    def get_edit_url(self):\n        return reverse(\"admin:django_sql_dashboard_dashboard_change\", args=(self.id,))\n\n    class Meta:\n        permissions = [(\"execute_sql\", \"Can execute arbitrary SQL queries\")]\n\n    def user_can_edit(self, user):\n        if not user:\n            return False\n        if self.owned_by == user:\n            return True\n        if self.edit_policy == self.EditPolicies.LOGGEDIN:\n            return True\n        if self.edit_policy == self.EditPolicies.STAFF and user.is_staff:\n            return True\n        if self.edit_policy == self.EditPolicies.SUPERUSER and user.is_superuser:\n            return True\n        if (\n            self.edit_policy == self.EditPolicies.GROUP\n            and self.edit_group\n            and self.edit_group.user_set.filter(pk=user.pk).exists()\n        ):\n            return True\n        return False\n\n    @classmethod\n    def get_editable_by_user(cls, user):\n        allowed_policies = [cls.EditPolicies.LOGGEDIN]\n        if user.is_staff:\n            allowed_policies.append(cls.EditPolicies.STAFF)\n        if user.is_superuser:\n            allowed_policies.append(cls.EditPolicies.SUPERUSER)\n        return (\n            cls.objects.filter(\n                models.Q(owned_by=user)\n                | models.Q(edit_policy__in=allowed_policies)\n                | models.Q(edit_policy=cls.EditPolicies.GROUP, edit_group__user=user)\n            )\n        ).distinct()\n\n    @classmethod\n    def get_visible_to_user(cls, user):\n        allowed_policies = [cls.ViewPolicies.PUBLIC, cls.ViewPolicies.LOGGEDIN]\n        if user.is_staff:\n            allowed_policies.append(cls.ViewPolicies.STAFF)\n        if user.is_superuser:\n            allowed_policies.append(cls.ViewPolicies.SUPERUSER)\n        return (\n            cls.objects.filter(\n                models.Q(owned_by=user)\n                | models.Q(view_policy__in=allowed_policies)\n                | models.Q(view_policy=cls.ViewPolicies.GROUP, view_group__user=user)\n            )\n            .annotate(\n                is_owner=models.ExpressionWrapper(\n                    models.Q(owned_by__exact=user.pk),\n                    output_field=models.BooleanField(),\n                )\n            )\n            .order_by(\"-is_owner\", \"slug\")\n        ).distinct()\n\n\nclass DashboardQuery(models.Model):\n    dashboard = models.ForeignKey(\n        Dashboard, related_name=\"queries\", on_delete=models.CASCADE\n    )\n    sql = models.TextField()\n\n    def __str__(self):\n        return self.sql\n\n    class Meta:\n        verbose_name_plural = \"Dashboard queries\"\n        order_with_respect_to = \"dashboard\"\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/_css.html",
    "content": "main {\n  margin-top: 1em;\n  font-family: Helvetica, sans-serif;\n}\nmain img {\n  height: 2em;\n}\ntable {\n  margin-top: 1em;\n  border-collapse: collapse;\n  border-spacing: 0;\n}\nth {\n  white-space: nowrap;\n  text-align: left;\n  padding: 4px;\n  padding-right: 1em;\n  border-right: 1px solid #eee;\n}\ntd {\n  border-top: 1px solid #aaa;\n  border-right: 1px solid #eee;\n  padding: 4px;\n  vertical-align: top;\n  white-space: pre-wrap;\n}\nspan.null {\n  color: #aaa;\n  font-weight: bold;\n}\npre.json {\n  margin: 0;\n}\n.btn {\n  font-weight: 400;\n  cursor: pointer;\n  text-align: center;\n  vertical-align: middle;\n  border: 1px solid #666;\n  padding: 0.5em 0.8em;\n  font-size: 0.9rem;\n  line-height: 1;\n  border-radius: 0.25rem;\n}\n.query-results {\n  border: 1px solid #666;\n  border-right: none;\n  margin: 1em 0;\n  padding: 1em;\n}\n.query-results textarea,\n.query-results pre.sql {\n  width: 95%;\n  border: 2px solid #666;\n  padding: 0.5em;\n}\n.pre-results textarea {\n  height: 4em;\n}\ndiv.query-parameters {\n  width: 80%;\n  display: grid;\n  grid-template-columns: min-content auto;\n  grid-gap: 0.5em;\n  align-items: center;\n  margin-bottom: 1em;\n}\ndiv.query-parameters input[type=\"text\"] {\n  border: 1px solid #666;\n  padding: 0.5em;\n}\ndiv.query-parameters label {\n  text-align: right;\n  font-weight: bold;\n  display: inline-block;\n  padding-right: 2em;\n}\n.completed-count-total-count {\n  width: 60%;\n  background-color: #efefef;\n  height: 3em;\n}\n.completed-count-total-count-bar {\n  background-color: blue;\n  height: 3em;\n}\n\n.save-dashboard-form p {\n  width: 80%;\n}\n.save-dashboard-form label {\n  text-align: left;\n  font-weight: bold;\n  display: inline-block;\n  width: 20%;\n}\n.save-dashboard-form input {\n  border: 1px solid #666;\n  padding: 0.5em;\n}\n.save-dashboard-form .errorlist {\n  color: red;\n}\n.save-dashboard-form .helptext {\n  color: #666;\n  white-space: nowrap;\n}\nul.dashboard-columns {\n  column-count: 2;\n}\nul.dashboard-columns li {\n  break-inside: avoid;\n  margin-bottom: 0.3em;\n}\nul.dashboard-columns li p {\n  margin: 0;\n  color: #666;\n  font-size: 0.8em;\n}\n@media (max-width: 800px) {\n  ul.dashboard-columns {\n    column-count: auto;\n  }\n}\n\nsvg.dropdown-menu-icon {\n  display: inline-block;\n  position: relative;\n  top: 2px;\n  cursor: pointer;\n  opacity: 0.8;\n  padding-left: 6px;\n}\n.dropdown-menu {\n  border: 1px solid #ccc;\n  border-radius: 4px;\n  line-height: 1.4;\n  font-size: 16px;\n  box-shadow: 2px 2px 2px #aaa;\n  background-color: #fff;\n  z-index: 1000;\n}\n.dropdown-menu ul,\n.dropdown-menu li {\n  list-style-type: none;\n  margin: 0;\n  padding: 0;\n}\n.dropdown-menu li {\n  border-bottom: 1px solid #ccc;\n}\n.dropdown-menu li:last-child {\n  border: none;\n}\n.dropdown-menu a:link,\n.dropdown-menu a:visited,\n.dropdown-menu a:hover,\n.dropdown-menu a:focus\n.dropdown-menu a:active {\n  text-decoration: none;\n  display: block;\n  padding: 4px 8px 2px 8px;\n  color: #222;\n  white-space: nowrap;\n}\n.dropdown-menu a:hover {\n  background-color: #eee;\n}\n.dropdown-menu .hook {\n  display: block;\n  position: absolute;\n  top: -5px;\n  left: 6px;\n  width: 0;\n  height: 0;\n  border-left: 5px solid transparent;\n  border-right: 5px solid transparent;\n  border-bottom: 5px solid #666;\n}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/_script.html",
    "content": "<script>\nvar svgCopyIcon = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>`;\n\nArray.from(document.querySelectorAll(\"pre.json\")).forEach((pre) => {\n  var svg = document.createElement(\"div\");\n  svg.innerHTML = svgCopyIcon;\n  svg = svg.querySelector(\"*\");\n  pre.style.position = \"relative\";\n  svg.style.position = \"absolute\";\n  svg.style.top = 0;\n  svg.style.right = 0;\n  svg.style.width = \"14px\";\n  svg.style.cursor = \"pointer\";\n  svg.addEventListener(\"click\", function () {\n    var input = document.createElement(\"input\");\n    input.setAttribute(\"type\", \"text\");\n    input.style.position = \"absolute\";\n    input.style.opacity = 0;\n    // Everything up to the last ] or }, to avoid broken\n    // JSON if the 'copied' text is still present\n    var json = pre.innerText.match(/^(.*[\\]\\}].*?$)$/gms)[0];\n    input.value = JSON.stringify(JSON.parse(json));\n    pre.appendChild(input);\n    input.select();\n    document.execCommand(\"copy\");\n    input.parentNode.removeChild(input);\n    // Show a 'copied' message then fade it out\n    var copied = document.createElement(\"span\");\n    copied.innerHTML = \"Copied\";\n    copied.style.position = \"absolute\";\n    copied.style.top = \"3ex\";\n    copied.style.right = 0;\n    copied.style.color = \"#666\";\n    copied.style.fontFamily = \"Helvetica, sans-serif\";\n    copied.style.fontSize = \"0.8em\";\n    copied.style.fontWeight = \"bold\";\n    copied.style.transition = \"opacity 1s\";\n    pre.appendChild(copied);\n    setTimeout(() => {\n      copied.parentNode.removeChild(copied);\n    }, 1500);\n    setTimeout(() => {\n      copied.style.opacity = 0;\n    }, 500);\n  });\n  pre.appendChild(svg);\n});\n\nfunction slugify(s) {\n  return s\n    .toLowerCase()\n    .replace(/[^-\\w\\s]/g, \"\") // remove non-alphanumerics\n    .trim()\n    .replace(/[-\\s]+/g, \"-\") // spaces to hyphens\n    .replace(/-+$/g, \"\"); // trim trailing hyphens\n}\n\nfunction setupSaveDashboardForm() {\n  var titleInput = document.querySelector(\"#id__save-title\");\n  var slugInput = document.querySelector(\"#id__save-slug\");\n  if (!titleInput) {\n    return;\n  }\n  // Scroll to form if there are errors\n  if (document.querySelector('.errorlist')) {\n    document.querySelector('#save-dashboard').scrollIntoView();\n  }\n  // Hide view group / edit group unless policies selected\n  var viewGroupContainer = document.querySelector(\n    \"#id__save-view_group\"\n  ).parentElement;\n  var editGroupContainer = document.querySelector(\n    \"#id__save-edit_group\"\n  ).parentElement;\n  var viewPolicySelect = document.querySelector(\n    \"#id__save-view_policy\"\n  );\n  var editPolicySelect = document.querySelector(\n    \"#id__save-edit_policy\"\n  );\n  const viewChange = () => {\n    if (viewPolicySelect.value == 'group') {\n      viewGroupContainer.style.display = 'block';\n    } else {\n      viewGroupContainer.style.display = 'none';\n    }\n  }\n  const editChange = () => {\n    if (editPolicySelect.value == 'group') {\n      editGroupContainer.style.display = 'block';\n    } else {\n      editGroupContainer.style.display = 'none';\n    }\n  }\n  viewPolicySelect.addEventListener('change', viewChange);\n  viewChange();\n  editPolicySelect.addEventListener('change', editChange);\n  editChange();\n\n  // Auto-fill slug from title\n  let slugManuallyEdited = false;\n  slugInput.addEventListener(\"change\", () => {\n    slugManuallyEdited = !!slugInput.value;\n  });\n  function titleEdited() {\n    if (slugManuallyEdited) {\n      return;\n    }\n    slugInput.value = slugify(titleInput.value);\n  }\n  titleInput.addEventListener(\"change\", titleEdited);\n  titleInput.addEventListener(\"focus\", titleEdited);\n  titleInput.addEventListener(\"keyup\", titleEdited);\n}\nwindow.addEventListener(\"load\", setupSaveDashboardForm);\n\nvar DROPDOWN_HTML = `<div class=\"dropdown-menu\">\n<div class=\"hook\"></div>\n<ul>\n  <li><a class=\"dropdown-count\" href=\"#\">Counts by value</a></li>\n  <li><a class=\"dropdown-count-distinct\" href=\"#\">Count distinct values</a></li>\n  <li><a class=\"dropdown-sort-asc\" href=\"#\">Sort ascending</a></li>\n  <li><a class=\"dropdown-sort-desc\" href=\"#\">Sort descending</a></li>\n</ul>\n</div>`;\n\nvar DROPDOWN_ICON_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n  <circle cx=\"12\" cy=\"12\" r=\"3\"></circle>\n  <path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>\n</svg>`;\n\n(function () {\n  function closeMenu() {\n    menu.style.display = \"none\";\n    menu.classList.remove(\"anim-scale-in\");\n  }\n  document.body.addEventListener(\"click\", (ev) => {\n    /* was this click outside the menu? */\n    var target = ev.target;\n    while (target && target != menu) {\n      target = target.parentNode;\n    }\n    if (!target) {\n      closeMenu();\n    }\n  });\n  function iconClicked(ev) {\n    ev.preventDefault();\n    ev.stopPropagation();\n    var th = ev.target;\n    while (th.nodeName != \"TH\") {\n      th = th.parentNode;\n    }\n    var rect = th.getBoundingClientRect();\n    var menuTop = rect.bottom + window.scrollY;\n    var menuLeft = rect.left + window.scrollX;\n    var column = th.getAttribute(\"data-column\");\n    [\n      [\"a.dropdown-count\", \"countUrl\"],\n      [\"a.dropdown-sort-asc\", \"sortAscUrl\"],\n      [\"a.dropdown-sort-desc\", \"sortDescUrl\"],\n      [\"a.dropdown-count-distinct\", \"countDistinctUrl\"]\n    ].forEach(([selector, datasetItem]) => {\n      var menuItem = menu.querySelector(selector);\n      if (th.dataset[datasetItem]) {\n        menuItem.href = th.dataset[datasetItem];\n        menuItem.parentNode.style.display = 'block';\n      } else {\n        menuItem.parentNode.style.display = 'none';\n      }\n    });\n    menu.style.position = \"absolute\";\n    menu.style.top = menuTop + 6 + \"px\";\n    menu.style.left = menuLeft + \"px\";\n    menu.style.display = \"block\";\n    menu.classList.add(\"anim-scale-in\");\n  }\n  var svg = document.createElement(\"div\");\n  svg.innerHTML = DROPDOWN_ICON_SVG;\n  svg = svg.querySelector(\"*\");\n  svg.classList.add(\"dropdown-menu-icon\");\n  var menu = document.createElement(\"div\");\n  menu.innerHTML = DROPDOWN_HTML;\n  menu = menu.querySelector(\"*\");\n  menu.style.position = \"absolute\";\n  menu.style.display = \"none\";\n  document.body.appendChild(menu);\n  var ths = Array.from(document.querySelectorAll(\"table th[data-count-url]\"));\n  ths.forEach((th) => {\n    var icon = svg.cloneNode(true);\n    icon.addEventListener(\"click\", iconClicked);\n    th.appendChild(icon);\n  });\n})();\n</script>\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/base.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>{% block title %}{% endblock %}</title>\n    <style>{% include \"django_sql_dashboard/_css.html\" %}</style>\n    {% block extra_head %}{% endblock %}\n  </head>\n  <body>\n    <main>\n      {% block content %}{% endblock %}\n    </main>\n  </body>\n</html>\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/dashboard.html",
    "content": "{% extends \"django_sql_dashboard/base.html\" %}\n{% load django_sql_dashboard %}\n\n{% block title %}{{ html_title }}{% endblock %}\n\n{% block content %}\n<h1>{{ title }}</h1>\n\n{% if too_long_so_use_post %}\n  <p style=\"background-color: pink; padding: 0.5em 1em 1em 1em; border: 2px solid red; margin-bottom: 1em\">\n    This SQL is too long to bookmark, so sharing a link to this page will not work for these queries.\n  </p>\n{% endif %}\n\n{% if unverified_sql_queries %}\n  <div style=\"background-color: pink; padding: 0.5em 1em 1em 1em; border: 2px solid red; margin-bottom: 1em\">\n    <h2 style=\"margin-top: 0.5em\">Unverified SQL</h2>\n    <p>The link you followed here included SQL that was missing its verification signatures.</p>\n    <p>If this link was provided to you by an untrusted source, they may be trying to trick you into executing queries that you do not want to execute.</p>\n    <p>Review these queries and copy and paste them once you have confirmed them:</p>\n    {% for query in unverified_sql_queries %}\n      <p><textarea>{{ query }}</textarea></p>\n    {% endfor %}\n  </div>\n{% endif %}\n<form action=\"{{ request.path }}\" method=\"POST\">\n  {% csrf_token %}\n  {% if query_results %}\n    <p>↓ <a href=\"#save-dashboard\">Save this dashboard</a> | <a href=\"{{ request.path }}\">Remove all queries</a></p>\n  {% endif %}\n  {% if parameter_values %}\n    <h3>Query parameters</h3>\n    <div class=\"query-parameters\">\n    {% for name, value in parameter_values %}\n        <label for=\"qp{{ forloop.counter }}\">{{ name }}</label>\n        <input type=\"text\" id=\"qp{{ forloop.counter }}\" name=\"{{ name }}\" value=\"{{ value }}\">\n    {% endfor %}\n    </div>\n    <input\n      class=\"btn\"\n      type=\"submit\"\n      value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\"\n    />\n  {% endif %}\n  {% for result in query_results %}\n    {% include result.templates with result=result %}\n  {% endfor %}\n  <p>Add {% if not query_results %}a{% else %}another{% endif %} query:</p>\n  <textarea\n    style=\"\n      width: 60%;\n      height: 10em;\n      border: 2px solid #666;\n      padding: 0.5em;\n    \"\n    name=\"sql\"\n  ></textarea>\n  <p>\n    <input\n      class=\"btn\"\n      type=\"submit\"\n      value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\"\n    />\n  </p>\n\n  {% if query_results %}\n    <h2 id=\"save-dashboard\">Save this dashboard</h2>\n    <p>Saved dashboards get their own URL, which can be bookmarked and shared with others.</p>\n    <div class=\"save-dashboard-form\">\n      {{ save_form.non_field_errors }}\n      {{ save_form.as_p }}\n      <p><input\n        class=\"btn\"\n        type=\"submit\"\n        name=\"_save\"\n        value=\"Save dashboard\"\n      /></p>\n    </div>\n  {% endif %}\n</form>\n\n{% if saved_dashboards %}\n  <h2>Saved dashboards</h2>\n  <ul class=\"dashboard-columns\">\n    {% for dashboard, can_edit in saved_dashboards %}\n      <li>\n        <a href=\"{{ dashboard.get_absolute_url }}\" title=\"{{ dashboard.description }}\">{{ dashboard }}</a>\n        <p>\n          By <strong>{{ dashboard.owned_by }}</strong>,\n          Visibility: {{ dashboard.view_summary }}\n          {% if can_edit %}\n            - <a href=\"{{ dashboard.get_edit_url }}\">edit</a>\n          {% endif %}\n        </p>\n      </li>\n    {% endfor %}\n  </ul>\n{% endif %}\n\n<h2>Available tables</h2>\n<ul class=\"dashboard-columns\">\n  {% for table in available_tables %}\n  <li>\n    <a href=\"?sql={% filter sign_sql|urlencode %}select count(*) from {{ table.name }}{% endfilter %}&sql={% autoescape off %}{% filter sign_sql|urlencode %}select {{ table.sql_columns }} from {{ table.name }}{% endfilter %}{% endautoescape %}\">{{ table.name }}</a>\n    <p>{{ table.columns }}</p>\n  </li>\n  {% endfor %}\n</ul>\n\n{% include \"django_sql_dashboard/_script.html\" %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/saved_dashboard.html",
    "content": "{% extends \"django_sql_dashboard/base.html\" %}\n{% load django_sql_dashboard %}\n\n{% block title %}{{ html_title }}{% endblock %}\n\n{% block extra_head %}\n{{ block.super }}\n{% if dashboard.view_policy == \"unlisted\" %}\n  <meta name=\"robots\" content=\"noindex\">\n{% endif %}\n{% endblock %}\n\n{% block content %}\n{% if user_can_execute_sql %}<p><a href=\"{% url 'django_sql_dashboard-index' %}\">Dashboard index</a></p>{% endif %}\n<h1>{% if dashboard.title %}{{ dashboard.title }}{% else %}{{ dashboard.slug }}{% endif %}</h1>\n{% if dashboard.description %}\n  {{ dashboard.description|sql_dashboard_markdown }}\n{% endif %}\n\n<p style=\"color: #666; font-size: 0.9em; margin-top: -1em\">\n  {% if user_owns_dashboard %}\n    Owned by you,\n  {% else %}\n    Owned by <strong>{{ dashboard.owned_by }}</strong>,\n  {% endif %}\n  visibility: {{ dashboard.view_summary }}\n  {% if user_can_edit_dashboard %}\n    - <a href=\"{{ dashboard.get_edit_url }}\">edit</a>\n  {% endif %}\n</p>\n\n<form action=\"{{ request.path }}\" method=\"GET\">\n  {% if parameter_values %}\n    <h3>Query parameters</h3>\n    <div class=\"query-parameters\">\n    {% for name, value in parameter_values %}\n        <label for=\"qp{{ forloop.counter }}\">{{ name }}</label>\n        <input type=\"text\" id=\"qp{{ forloop.counter }}\" name=\"{{ name }}\" value=\"{{ value }}\">\n    {% endfor %}\n    </div>\n    <input\n      class=\"btn\"\n      type=\"submit\"\n      value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\"\n    />\n  {% endif %}\n  {% for result in query_results %}\n    {% include result.templates with result=result %}\n  {% endfor %}\n</form>\n{% include \"django_sql_dashboard/_script.html\" %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/_base_widget.html",
    "content": "<div class=\"query-results\">\n  {% block widget_results %}{% endblock %}\n  <details><summary style=\"font-size: 0.7em; margin-bottom: 0.5em; cursor: pointer;\">SQL query</summary>\n  {% if saved_dashboard %}<pre class=\"sql\">{{ result.sql }}</pre>{% else %}<textarea\n  name=\"sql\"\n>{{ result.sql|default:\"\" }}</textarea>{% endif %}\n{% if not saved_dashboard %}<p>\n  <input\n    class=\"btn\"\n    type=\"submit\"\n    value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\"\n  />\n</p>{% endif %}\n</details>\n</div>\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/bar_label-bar_quantity.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% block widget_results %}\n<script\n  src=\"https://cdn.jsdelivr.net/npm/vega@5.19.1\"\n  integrity=\"sha384-dhyUMwHr1RzDslJIbzN+8UMRMobmqrmABSU3vFSBBnARl6XwbW37TVDCTQs+yEc5\"\n  crossorigin=\"anonymous\"\n></script>\n<script\n  src=\"https://cdn.jsdelivr.net/npm/vega-lite@5.0.0\"\n  integrity=\"sha384-nAwfbn/eKhGkpj7MbyqVuoGEyL/iSeZq4XJ1NNOxA9nT7eXfJuUQgpxYd27m1tAO\"\n  crossorigin=\"anonymous\"\n></script>\n<script\n  src=\"https://cdn.jsdelivr.net/npm/vega-embed@6.15.1\"\n  integrity=\"sha384-RvXMbul/5q2mGE4PXcky3u5+A/K3lk/jv+oizUX/InRPD9wELInOy6YwUxdk/tEu\"\n  crossorigin=\"anonymous\"\n></script>\n<div id=\"vis{{ result.index }}\"></div>\n{% with \"vis-data-\"|add:result.index as script_name %}\n{{ result.rows|json_script:script_name }}\n<script>\nvegaEmbed(\"#vis{{ result.index }}\", {\n  $schema: \"https://vega.github.io/schema/vega-lite/v5.json\",\n  description: \"A simple bar chart with embedded data.\",\n  data: {\n    values: JSON.parse(\n      document.getElementById(\"{{ script_name }}\").textContent\n    ),\n  },\n  mark: \"bar\",\n  encoding: {\n    x: {\n      field: \"bar_label\",\n      title: \"Label\",\n      type: \"nominal\",\n      axis: { labelAngle: 90 },\n      sort: null\n    },\n    y: { field: \"bar_quantity\", title: \"Quantity\", type: \"quantitative\" },\n  },\n});\n</script>\n{% endwith %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/big_number-label.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% block widget_results %}\n  {% for row in result.rows %}\n    <div class=\"big-number\">\n      <p><strong>{{ row.label }}</strong></p>\n      <h1>{{ row.big_number }}</h1>\n    </div>\n  {% endfor %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/completed_count-total_count.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% block widget_results %}\n  {% for row in result.rows %}\n    <div class=\"completed-count-total-count\">\n      <div class=\"completed-count-total-count-bar\" style=\"width: {% widthratio row.completed_count row.total_count 100 %}%\">&nbsp;</div>\n    </div>\n    <h2>{{ row.completed_count }} / {{ row.total_count }}: {% widthratio row.completed_count row.total_count 100 %}%</h2>\n  {% endfor %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/default.html",
    "content": "{% load django_sql_dashboard %}<div class=\"query-results\" id=\"query-results-{{ result.index }}\">\n  {% if saved_dashboard %}<details><summary style=\"cursor: pointer;\">SQL query</summary><pre class=\"sql\">{{ result.sql }}</pre>{% else %}<textarea\n    name=\"sql\"\n    rows=\"{{ result.textarea_rows }}\"\n  >{{ result.sql|default:\"\" }}</textarea>{% endif %}\n  {% if not saved_dashboard %}<p>\n    <input\n      class=\"btn\"\n      type=\"submit\"\n      value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\"\n    />\n  </p>{% else %}</details>{% endif %}\n  {% if result.truncated %}\n    <p class=\"results-truncated\">\n      Results were truncated\n      {% if user_can_export_data and not saved_dashboard %}\n        <input\n          class=\"btn\"\n          style=\"font-size: 0.6rem\"\n          type=\"submit\"\n          name=\"export_csv_{{ result.index }}\"\n          value=\"Export all as CSV\"\n        />\n        <input\n          class=\"btn\"\n          style=\"font-size: 0.6rem\"\n          type=\"submit\"\n          name=\"export_tsv_{{ result.index }}\"\n          value=\"Export all as TSV\"\n        />\n      {% endif %}\n    </p>\n  {% else %}\n    <p>{{ result.row_lists|length }} row{{ result.row_lists|length|pluralize }}</p>\n  {% endif %}\n  {% if result.error %}\n  <p style=\"background-color: pink; padding: 1em; margin: 1em 0\">\n    {{ result.error }}\n  </p>\n  {% endif %}\n  <table>\n    <thead>\n      <tr>\n        {% for column in result.column_details %}\n          <th alt=\"{{ column.name }}\"\n            {% if user_can_execute_sql and column.is_unambiguous and not too_long_so_use_post %}\n              data-count-url=\"{% url 'django_sql_dashboard-index' %}?sql={% filter sign_sql|urlencode %}select \"{{ column.name }}\", count(*) as n from ({{ result.sql|safe }}) as results group by \"{{ column.name }}\" order by n desc{% endfilter %}{{ result.extra_qs }}\"\n              data-sort-asc-url=\"{% url 'django_sql_dashboard-index' %}?sql={% filter sign_sql|urlencode %}{{ column.sort_sql|safe }}{% endfilter %}{{ result.extra_qs }}\"\n              data-sort-desc-url=\"{% url 'django_sql_dashboard-index' %}?sql={% filter sign_sql|urlencode %}{{ column.sort_desc_sql|safe }}{% endfilter %}{{ result.extra_qs }}\"\n              data-count-distinct-url=\"{% url 'django_sql_dashboard-index' %}?sql={% filter sign_sql|urlencode %}select count(distinct \"{{ column.name }}\") from ({{ result.sql|safe }}) as results{% endfilter %}{{ result.extra_qs }}\"\n            {% endif %}>{{ column.name }}</th>\n        {% endfor %}\n      </tr>\n    </thead>\n    <tbody>\n      {% for row in result.row_lists %}\n      <tr>\n        {% for item in row %}\n        <td>{% if item is None %}<span class=\"null\">- null -</span>{% else %}{{ item|format_cell }}{% endif %}</td>\n        {% endfor %}\n      </tr>\n      {% endfor %}\n    </tbody>\n  </table>\n  <details style=\"margin-top: 1em;\"><summary style=\"font-size: 0.7em; margin-bottom: 0.5em; cursor: pointer;\">Copy and export data</summary>\n    <textarea id=\"copyable-{{ result.index }}\" style=\"height: 10em\">{{ result|sql_dashboard_tsv }}</textarea>\n    {% if user_can_export_data and not saved_dashboard %}\n      <div class=\"export-buttons\">\n        <input\n          class=\"btn\"\n          type=\"submit\"\n          name=\"export_csv_{{ result.index }}\"\n          value=\"Export all as CSV\"\n        />\n        <input\n          class=\"btn\"\n          type=\"submit\"\n          name=\"export_tsv_{{ result.index }}\"\n          value=\"Export all as TSV\"\n        />\n      </div>\n    {% endif %}\n  </details>\n  <p>Duration: {{ result.duration_ms|floatformat:2 }}ms</p>\n  <!-- templates considered: {{ result.templates|join:\", \" }} -->\n  <script>\n  (function() {\n    var ta = document.querySelector(\"#copyable-{{ result.index }}\");\n    var button = document.createElement(\"button\");\n    button.className = \"copyable-copy-button btn\";\n    button.style.fontSize = '0.6em';\n    button.innerHTML = \"Copy to clipboard\";\n    button.onclick = (ev) => {\n      ev.preventDefault();\n      ta.select();\n      document.execCommand(\"copy\");\n      button.innerHTML = \"Copied!\";\n      setTimeout(() => {\n          button.innerHTML = \"Copy to clipboard\";\n      }, 1500);\n    };\n    var p = document.createElement('p');\n    p.style.marginTop = '0.2em';\n    ta.insertAdjacentElement(\"afterend\", p);\n    p.insertAdjacentElement(\"afterbegin\", button);\n  })();\n  </script>\n</div>\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/error.html",
    "content": "<div class=\"query-results query-error\">\n  {% if saved_dashboard %}\n    <pre class=\"sql\">{{ result.sql }}</pre>\n  {% else %}\n    <textarea name=\"sql\" rows=\"{{ result.textarea_rows }}\">{{ result.sql }}</textarea>\n  {% endif %}\n  <p>\n    <input class=\"btn\" type=\"submit\"\n      value=\"Run quer{% if query_results|length > 1 %}ies{% else %}y{% endif %}\">\n  </p>\n  <p class=\"error-message\" style=\"background-color: pink; padding: 1em; margin: 1em 0\">{{ result.error }}</p>\n</div>\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/html.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% load django_sql_dashboard %}\n\n{% block widget_results %}\n  {% for row in result.rows %}\n    {{ row.html|sql_dashboard_bleach }}\n  {% endfor %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/markdown.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% load django_sql_dashboard %}\n\n{% block widget_results %}\n  {% for row in result.rows %}\n    {{ row.markdown|sql_dashboard_markdown }}\n  {% endfor %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templates/django_sql_dashboard/widgets/wordcloud_count-wordcloud_word.html",
    "content": "{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% block widget_results %}\n<script\n  src=\"https://cdn.jsdelivr.net/npm/d3-cloud@1.2.5\"\n  integrity=\"sha384-QdJK9M8QwqLqENe8Vd/mQIIk/BUQCC3BLh+kqB+UPKbKBsvOmcnmZTikm9prWMeO\"\n  crossorigin=\"anonymous\"\n></script>\n<script\n  src=\"https://cdn.jsdelivr.net/npm/d3@6.7.0\"\n  integrity=\"sha384-ma33ZEb8L5emtidZhYJFZNIFdht2E8f5wHQMKQGom0aIx9rRKm86XXCjGxOISpM9\"\n  crossorigin=\"anonymous\"\n></script>\n<div id=\"wordcloud-{{ result.index }}\"></div>\n{% with \"wordcloud-data-\"|add:result.index as script_name %}\n{{ result.rows|json_script:script_name }}\n<script>\n(function() {\n  let wordcloudData = JSON.parse(\n    document.getElementById(\"{{ script_name }}\").textContent\n  );\n  var minScore = Math.min(...wordcloudData.map(w => w.wordcloud_count));\n  var maxScore = Math.max(...wordcloudData.map(w => w.wordcloud_count));\n  var fontScale = d3.scaleLinear()\n    .domain([minScore, maxScore])\n    .range([20, 100]);\n\n  function colors(s) {\n    return s.match(/.{6}/g).map(function(x) {\n      return \"#\" + x;\n    });\n  }\n  var fill = colors(\"1f77b4ff7f0e2ca02cd627289467bd8c564be377c27f7f7fbcbd2217becf\");\n\n  function draw(words) {\n    d3.select(\"#wordcloud-{{ result.index }}\").append(\"svg\")\n        .attr(\"width\", layout.size()[0])\n        .attr(\"height\", layout.size()[1])\n      .append(\"g\")\n        .attr(\"transform\", \"translate(\" + layout.size()[0] / 2 + \",\" + layout.size()[1] / 2 + \")\")\n      .selectAll(\"text\")\n        .data(words)\n      .enter().append(\"text\")\n        .style(\"font-size\", function(d) { return d.size + \"px\"; })\n        .style(\"font-family\", \"Impact\")\n        .style(\"fill\", function(d, i) { return fill[i % fill.length]; })\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"transform\", function(d) {\n          return \"translate(\" + [d.x, d.y] + \")rotate(\" + d.rotate + \")\";\n        })\n        .text(function(d) { return d.text; });\n  }\n  var wordData = wordcloudData.map(function(d) {\n    return {text: d.wordcloud_word, size: d.wordcloud_count};\n  });\n  var layout = d3.layout.cloud()\n    .size([document.querySelector('#wordcloud-{{ result.index }}').getBoundingClientRect().width, 500])\n    .words(wordData)\n    .rotate(function() { return (~~(Math.random() * 6) - 3) * 30; })\n    .padding(5)\n    .font(\"Impact\")\n    .fontSize(function(d) { return fontScale(d.size); })\n    .on(\"end\", draw);\n  layout.start()\n})();\n</script>\n{% endwith %}\n{% endblock %}\n"
  },
  {
    "path": "django_sql_dashboard/templatetags/__init__.py",
    "content": ""
  },
  {
    "path": "django_sql_dashboard/templatetags/django_sql_dashboard.py",
    "content": "import csv\nimport io\nimport json\n\nimport bleach\nimport markdown\nfrom django import template\nfrom django.utils.html import escape, urlize\nfrom django.utils.safestring import mark_safe\n\nfrom ..utils import sign_sql as sign_sql_original\n\nTAGS = [\n    \"a\",\n    \"abbr\",\n    \"acronym\",\n    \"b\",\n    \"blockquote\",\n    \"br\",\n    \"code\",\n    \"em\",\n    \"i\",\n    \"li\",\n    \"ol\",\n    \"strong\",\n    \"ul\",\n    \"pre\",\n    \"p\",\n    \"h1\",\n    \"h2\",\n    \"h3\",\n    \"h4\",\n    \"h5\",\n    \"h6\",\n]\nATTRIBUTES = {\"a\": [\"href\"]}\n\nregister = template.Library()\n\n\n@register.filter\ndef sign_sql(value):\n    return sign_sql_original(value)\n\n\n@register.filter\ndef sql_dashboard_bleach(value):\n    return mark_safe(\n        bleach.clean(\n            value,\n            tags=TAGS,\n            attributes=ATTRIBUTES,\n        )\n    )\n\n\n@register.filter\ndef sql_dashboard_markdown(value):\n    value = value or \"\"\n    return mark_safe(\n        bleach.linkify(\n            bleach.clean(\n                markdown.markdown(\n                    value,\n                    output_format=\"html5\",\n                ),\n                tags=TAGS,\n                attributes=ATTRIBUTES,\n            )\n        )\n    )\n\n\n@register.filter\ndef sql_dashboard_tsv(result):\n    writer = io.StringIO()\n    csv_writer = csv.writer(writer, delimiter=\"\\t\")\n    csv_writer.writerow(result[\"columns\"])\n    for row in result[\"row_lists\"]:\n        csv_writer.writerow(row)\n    return writer.getvalue().strip()\n\n\n@register.filter\ndef format_cell(value):\n    if isinstance(value, str) and value and value[0] in (\"{\", \"[\"):\n        try:\n            return mark_safe(\n                '<pre class=\"json\">{}</pre>'.format(\n                    escape(json.dumps(json.loads(value), indent=2))\n                )\n            )\n        except json.JSONDecodeError:\n            pass\n    return mark_safe(urlize(value, nofollow=True, autoescape=True))\n"
  },
  {
    "path": "django_sql_dashboard/urls.py",
    "content": "from django.urls import path\n\nfrom .views import dashboard, dashboard_json, dashboard_index\n\nurlpatterns = [\n    path(\"\", dashboard_index, name=\"django_sql_dashboard-index\"),\n    path(\"<slug>/\", dashboard, name=\"django_sql_dashboard-dashboard\"),\n    path(\"<slug>.json\", dashboard_json, name=\"django_sql_dashboard-dashboard_json\"),\n]\n"
  },
  {
    "path": "django_sql_dashboard/utils.py",
    "content": "import binascii\nimport json\nimport re\nimport urllib.parse\nfrom collections import namedtuple\n\nfrom django.core import signing\n\nSQL_SALT = \"django_sql_dashboard:query\"\n\nsigner = signing.Signer(salt=SQL_SALT)\n\n\ndef sign_sql(sql):\n    return signer.sign(sql)\n\n\ndef unsign_sql(signed_sql, try_object=False):\n    # Returns (sql, signature_verified)\n    # So we can handle broken signatures\n    # Usually this will be a regular string\n    try:\n        sql = signer.unsign(signed_sql)\n        return sql, True\n    except signing.BadSignature:\n        try:\n            value, bad_sig = signed_sql.rsplit(signer.sep, 1)\n            return value, False\n        except ValueError:\n            return signed_sql, False\n\n\nclass Row:\n    def __init__(self, values, columns):\n        self.values = values\n        self.columns = columns\n        self.zipped = dict(zip(columns, values))\n\n    def __getitem__(self, key):\n        if isinstance(key, int):\n            return self.values[key]\n        else:\n            return self.zipped[key]\n\n    def __repr__(self):\n        return json.dumps(self.zipped)\n\n\ndef displayable_rows(rows):\n    fixed = []\n    for row in rows:\n        fixed_row = []\n        for cell in row:\n            if isinstance(cell, (dict, list)):\n                cell = json.dumps(cell, default=str)\n            fixed_row.append(cell)\n        fixed.append(fixed_row)\n    return fixed\n\n\n_named_parameters_re = re.compile(r\"\\%\\(([^\\)]+)\\)s\")\n\n\ndef extract_named_parameters(sql):\n    params = _named_parameters_re.findall(sql)\n    # Validation step: after removing params, are there\n    # any single `%` symbols that will confuse psycopg2?\n    without_params = _named_parameters_re.sub(\"\", sql)\n    without_double_percents = without_params.replace(\"%%\", \"\")\n    if \"%\" in without_double_percents:\n        raise ValueError(r\"Found a single % character\")\n    return params\n\n\ndef check_for_base64_upgrade(queries):\n    if not queries:\n        return\n    # Strip of the timing bit if there is one\n    queries = [q.split(\":\")[0] for q in queries]\n    # If every query is base64-encoded JSON, return a new querystring\n    if not all(is_valid_base64_json(query) for query in queries):\n        return\n    # Need to decode these and upgrade them to ?sql= links\n    sqls = []\n    for query in queries:\n        sqls.append(sign_sql(json.loads(signing.b64_decode(query.encode()))))\n    return \"?\" + urllib.parse.urlencode({\"sql\": sqls}, True)\n\n\ndef is_valid_base64_json(s):\n    try:\n        json.loads(signing.b64_decode(s.encode()))\n        return True\n    except (json.JSONDecodeError, binascii.Error, UnicodeDecodeError):\n        return False\n\n\n_reserved_words = None\n\n\ndef postgresql_reserved_words(connection):\n    global _reserved_words\n    if _reserved_words is None:\n        with connection.cursor() as cursor:\n            cursor.execute(\"select word from pg_get_keywords() where catcode = 'R'\")\n            _reserved_words = [row[0] for row in cursor.fetchall()]\n    return _reserved_words\n\n\n_sort_re = re.compile('(^.*) order by \"[^\"]+\"( desc)?$', re.DOTALL)\n\n\ndef apply_sort(sql, sort_column, is_desc=False):\n    match = _sort_re.match(sql)\n    if match is not None:\n        sql = match.group(1)\n    else:\n        sql = \"select * from ({}) as results\".format(sql)\n    return sql + ' order by \"{}\"{}'.format(sort_column, \" desc\" if is_desc else \"\")\n"
  },
  {
    "path": "django_sql_dashboard/views.py",
    "content": "import csv\nimport hashlib\nimport re\nimport time\nfrom io import StringIO\nfrom urllib.parse import urlencode\n\nfrom django.conf import settings\nfrom django.contrib.auth.decorators import login_required\nfrom django.db import connections\nfrom django.db.utils import ProgrammingError\nfrom django.forms import CharField, ModelForm, Textarea\nfrom django.http.response import (\n    HttpResponseForbidden,\n    HttpResponseRedirect,\n    JsonResponse,\n    StreamingHttpResponse,\n)\nfrom django.shortcuts import get_object_or_404, render\nfrom django.utils.safestring import mark_safe\n\nfrom psycopg2.extensions import quote_ident\n\nfrom .models import Dashboard\nfrom .utils import (\n    apply_sort,\n    check_for_base64_upgrade,\n    displayable_rows,\n    extract_named_parameters,\n    postgresql_reserved_words,\n    sign_sql,\n    unsign_sql,\n)\n\n# https://github.com/simonw/django-sql-dashboard/issues/58\nMAX_REDIRECT_LENGTH = 1800\n\n\nclass SaveDashboardForm(ModelForm):\n    slug = CharField(required=False, label=\"URL\", help_text='For example \"daily-stats\"')\n\n    class Meta:\n        model = Dashboard\n        fields = (\n            \"title\",\n            \"slug\",\n            \"description\",\n            \"view_policy\",\n            \"view_group\",\n            \"edit_policy\",\n            \"edit_group\",\n        )\n        widgets = {\n            \"description\": Textarea(\n                attrs={\n                    \"placeholder\": \"Optional description, shown at the top of the dashboard page (Markdown allowed)\"\n                }\n            )\n        }\n\n\n@login_required\ndef dashboard_index(request):\n    if not request.user.has_perm(\"django_sql_dashboard.execute_sql\"):\n        return HttpResponseForbidden(\"You do not have permission to execute SQL\")\n    sql_queries = []\n    too_long_so_use_post = False\n    save_form = SaveDashboardForm(prefix=\"_save\")\n    if request.method == \"POST\":\n        # Is this an export?\n        if any(\n            k for k in request.POST.keys() if k.startswith(\"export_\")\n        ) and request.user.has_perm(\"django_sql_dashboard.execute_sql\"):\n            if not getattr(settings, \"DASHBOARD_ENABLE_FULL_EXPORT\", None):\n                return HttpResponseForbidden(\"The export feature is not enabled\")\n            return export_sql_results(request)\n        sqls = [sql for sql in request.POST.getlist(\"sql\") if sql.strip()]\n\n        saving = False\n        # How about a save?\n        if request.POST.get(\"_save-slug\"):\n            save_form = SaveDashboardForm(request.POST, prefix=\"_save\")\n            saving = True\n            if save_form.is_valid():\n                dashboard = save_form.save(commit=False)\n                dashboard.owned_by = request.user\n                dashboard.save()\n                for sql in sqls:\n                    dashboard.queries.create(sql=sql)\n                return HttpResponseRedirect(dashboard.get_absolute_url())\n\n        # Convert ?sql= into signed values and redirect as GET\n        other_pairs = [\n            (key, value)\n            for key, value in request.POST.items()\n            if key not in (\"sql\", \"csrfmiddlewaretoken\")\n            and not key.startswith(\"_save-\")\n        ]\n        signed_sqls = [sign_sql(sql) for sql in sqls if sql.strip()]\n        params = {\n            \"sql\": signed_sqls,\n        }\n        params.update(other_pairs)\n        redirect_path = request.path + \"?\" + urlencode(params, doseq=True)\n        # Is this short enough for us to redirect?\n        too_long_so_use_post = len(redirect_path) > MAX_REDIRECT_LENGTH\n        if not saving and not too_long_so_use_post:\n            return HttpResponseRedirect(redirect_path)\n        else:\n            sql_queries = sqls\n    unverified_sql_queries = []\n    for signed_sql in request.GET.getlist(\"sql\"):\n        sql, signature_verified = unsign_sql(signed_sql)\n        if signature_verified:\n            sql_queries.append(sql)\n        else:\n            unverified_sql_queries.append(sql)\n    if getattr(settings, \"DASHBOARD_UPGRADE_OLD_BASE64_LINKS\", None):\n        redirect_querystring = check_for_base64_upgrade(sql_queries)\n        if redirect_querystring:\n            return HttpResponseRedirect(request.path + redirect_querystring)\n    return _dashboard_index(\n        request,\n        sql_queries,\n        unverified_sql_queries=unverified_sql_queries,\n        too_long_so_use_post=too_long_so_use_post,\n        extra_context={\"save_form\": save_form},\n    )\n\n\ndef _dashboard_index(\n    request,\n    sql_queries,\n    unverified_sql_queries=None,\n    title=None,\n    description=None,\n    dashboard=None,\n    too_long_so_use_post=False,\n    template=\"django_sql_dashboard/dashboard.html\",\n    extra_context=None,\n    json_mode=False,\n):\n    query_results = []\n    alias = getattr(settings, \"DASHBOARD_DB_ALIAS\", \"dashboard\")\n    row_limit = getattr(settings, \"DASHBOARD_ROW_LIMIT\", None) or 100\n    connection = connections[alias]\n    reserved_words = postgresql_reserved_words(connection)\n    with connection.cursor() as tables_cursor:\n        tables_cursor.execute(\n            \"\"\"\n            with visible_tables as (\n              select table_name\n                from information_schema.tables\n                where table_schema = 'public'\n                order by table_name\n            ),\n            reserved_keywords as (\n              select word\n                from pg_get_keywords()\n                where catcode = 'R'\n            )\n            select\n              information_schema.columns.table_name,\n              array_to_json(array_agg(cast(column_name as text) order by ordinal_position)) as columns\n            from\n              information_schema.columns\n            join\n              visible_tables on\n              information_schema.columns.table_name = visible_tables.table_name\n            where\n              information_schema.columns.table_schema = 'public'\n            group by\n              information_schema.columns.table_name\n            order by\n              information_schema.columns.table_name\n        \"\"\"\n        )\n        fetched = tables_cursor.fetchall()\n        available_tables = [\n            {\n                \"name\": row[0],\n                \"columns\": \", \".join(row[1]),\n                \"sql_columns\": \", \".join(\n                    [\n                        '\"{}\"'.format(column) if column in reserved_words else column\n                        for column in row[1]\n                    ]\n                ),\n            }\n            for row in fetched\n        ]\n\n    parameters = []\n    sql_query_parameter_errors = []\n    for sql in sql_queries:\n        try:\n            extracted = extract_named_parameters(sql)\n            for p in extracted:\n                if p not in parameters:\n                    parameters.append(p)\n            sql_query_parameter_errors.append(False)\n        except ValueError as e:\n            if \"%\" in sql:\n                sql_query_parameter_errors.append(\n                    r\"Invalid query - try escaping single '%' as double '%%'\"\n                )\n            else:\n                sql_query_parameter_errors.append(str(e))\n    parameter_values = {\n        parameter: request.POST.get(parameter, request.GET.get(parameter, \"\"))\n        for parameter in parameters\n        if parameter != \"sql\"\n    }\n    extra_qs = \"&{}\".format(urlencode(parameter_values)) if parameter_values else \"\"\n    results_index = -1\n    if sql_queries:\n        for sql, parameter_error in zip(sql_queries, sql_query_parameter_errors):\n            results_index += 1\n            sql = sql.strip().rstrip(\";\")\n            base_error_result = {\n                \"index\": str(results_index),\n                \"sql\": sql,\n                \"textarea_rows\": min(5, len(sql.split(\"\\n\"))),\n                \"rows\": [],\n                \"row_lists\": [],\n                \"description\": [],\n                \"columns\": [],\n                \"column_details\": [],\n                \"truncated\": False,\n                \"extra_qs\": extra_qs,\n                \"error\": None,\n                \"templates\": [\"django_sql_dashboard/widgets/error.html\"],\n            }\n            if parameter_error:\n                query_results.append(\n                    dict(\n                        base_error_result,\n                        error=parameter_error,\n                    )\n                )\n                continue\n            if \";\" in sql:\n                query_results.append(\n                    dict(base_error_result, error=\"';' not allowed in SQL queries\")\n                )\n                continue\n            with connection.cursor() as cursor:\n                duration_ms = None\n                try:\n                    cursor.execute(\"BEGIN;\")\n                    start = time.perf_counter()\n                    # Running a SELECT prevents future SET TRANSACTION READ WRITE:\n                    cursor.execute(\"SELECT 1;\")\n                    cursor.fetchall()\n                    cursor.execute(sql, parameter_values)\n                    try:\n                        rows = list(cursor.fetchmany(row_limit + 1))\n                    except ProgrammingError as e:\n                        rows = [{\"statusmessage\": str(cursor.statusmessage)}]\n                    duration_ms = (time.perf_counter() - start) * 1000.0\n                except Exception as e:\n                    query_results.append(dict(base_error_result, error=str(e)))\n                else:\n                    templates = [\"django_sql_dashboard/widgets/default.html\"]\n                    columns = [c.name for c in cursor.description]\n                    template_name = (\"-\".join(sorted(columns))) + \".html\"\n                    if len(template_name) < 255:\n                        templates.insert(\n                            0,\n                            \"django_sql_dashboard/widgets/\" + template_name,\n                        )\n                    display_rows = displayable_rows(rows[:row_limit])\n                    column_details = [\n                        {\n                            \"name\": column,\n                            \"is_unambiguous\": columns.count(column) == 1,\n                            \"sort_sql\": apply_sort(sql, column),\n                            \"sort_desc_sql\": apply_sort(sql, column, True),\n                        }\n                        for column in columns\n                    ]\n                    query_results.append(\n                        {\n                            \"index\": str(results_index),\n                            \"sql\": sql,\n                            \"textarea_rows\": len(sql.split(\"\\n\")),\n                            \"rows\": [dict(zip(columns, row)) for row in display_rows],\n                            \"row_lists\": display_rows,\n                            \"description\": cursor.description,\n                            \"columns\": columns,\n                            \"column_details\": column_details,\n                            \"truncated\": len(rows) == row_limit + 1,\n                            \"extra_qs\": extra_qs,\n                            \"duration_ms\": duration_ms,\n                            \"templates\": templates,\n                        }\n                    )\n                finally:\n                    cursor.execute(\"ROLLBACK;\")\n    # Page title, composed of truncated SQL queries\n    html_title = \"SQL Dashboard\"\n    if sql_queries:\n        html_title = \"SQL: \" + \" [,] \".join(sql_queries)\n\n    if dashboard and dashboard.title:\n        html_title = dashboard.title\n\n    # Add named parameter values, if any exist\n    provided_values = {\n        key: value for key, value in parameter_values.items() if value.strip()\n    }\n    if provided_values:\n        if len(provided_values) == 1:\n            html_title += \": {}\".format(list(provided_values.values())[0])\n        else:\n            html_title += \": {}\".format(\n                \", \".join(\n                    \"{}={}\".format(key, value) for key, value in provided_values.items()\n                )\n            )\n\n    user_can_execute_sql = request.user.has_perm(\"django_sql_dashboard.execute_sql\")\n\n    saved_dashboards = []\n    if not dashboard:\n        # Only show saved dashboards on index page\n        saved_dashboards = [\n            (dashboard, dashboard.user_can_edit(request.user))\n            for dashboard in Dashboard.get_visible_to_user(request.user).select_related(\n                \"owned_by\", \"view_group\", \"edit_group\"\n            )\n        ]\n\n    if json_mode:\n        return JsonResponse(\n            {\n                \"title\": title or \"SQL Dashboard\",\n                \"queries\": [\n                    {\"sql\": r[\"sql\"], \"rows\": r[\"rows\"]} for r in query_results\n                ],\n            },\n            json_dumps_params={\n                \"indent\": 2,\n                \"default\": lambda o: (\n                    o.isoformat() if hasattr(o, \"isoformat\") else str(o)\n                ),\n            },\n        )\n\n    context = {\n        \"title\": title or \"SQL Dashboard\",\n        \"html_title\": html_title,\n        \"query_results\": query_results,\n        \"unverified_sql_queries\": unverified_sql_queries,\n        \"available_tables\": available_tables,\n        \"description\": description,\n        \"dashboard\": dashboard,\n        \"saved_dashboard\": bool(dashboard),\n        \"user_owns_dashboard\": dashboard and request.user == dashboard.owned_by,\n        \"user_can_edit_dashboard\": dashboard and dashboard.user_can_edit(request.user),\n        \"user_can_execute_sql\": user_can_execute_sql,\n        \"user_can_export_data\": getattr(settings, \"DASHBOARD_ENABLE_FULL_EXPORT\", None)\n        and user_can_execute_sql,\n        \"parameter_values\": parameter_values.items(),\n        \"too_long_so_use_post\": too_long_so_use_post,\n        \"saved_dashboards\": saved_dashboards,\n    }\n\n    if extra_context:\n        context.update(extra_context)\n\n    response = render(\n        request,\n        template,\n        context,\n    )\n    if request.user.is_authenticated:\n        response[\"cache-control\"] = \"private\"\n    response[\"Content-Security-Policy\"] = \"frame-ancestors 'self'\"\n    return response\n\n\ndef dashboard_json(request, slug):\n    disable_json = getattr(settings, \"DASHBOARD_DISABLE_JSON\", None)\n    if disable_json:\n        return HttpResponseForbidden(\"JSON export is disabled\")\n    return dashboard(request, slug, json_mode=True)\n\n\ndef dashboard(request, slug, json_mode=False):\n    dashboard = get_object_or_404(Dashboard, slug=slug)\n    # Can current user see it, based on view_policy?\n    view_policy = dashboard.view_policy\n    owner = dashboard.owned_by\n    denied = HttpResponseForbidden(\"You cannot access this dashboard\")\n    denied[\"cache-control\"] = \"private\"\n    if view_policy == Dashboard.ViewPolicies.PRIVATE:\n        if request.user != owner:\n            return denied\n    elif view_policy == Dashboard.ViewPolicies.LOGGEDIN:\n        if not request.user.is_authenticated:\n            return denied\n    elif view_policy == Dashboard.ViewPolicies.GROUP:\n        if (not request.user.is_authenticated) or not (\n            request.user == owner\n            or request.user.groups.filter(pk=dashboard.view_group_id).exists()\n        ):\n            return denied\n    elif view_policy == Dashboard.ViewPolicies.STAFF:\n        if (not request.user.is_authenticated) or (\n            request.user != owner and not request.user.is_staff\n        ):\n            return denied\n    elif view_policy == Dashboard.ViewPolicies.SUPERUSER:\n        if (not request.user.is_authenticated) or (\n            request.user != owner and not request.user.is_superuser\n        ):\n            return denied\n    return _dashboard_index(\n        request,\n        sql_queries=[query.sql for query in dashboard.queries.all()],\n        title=dashboard.title,\n        description=dashboard.description,\n        dashboard=dashboard,\n        template=\"django_sql_dashboard/saved_dashboard.html\",\n        json_mode=json_mode,\n    )\n\n\nnon_alpha_re = re.compile(r\"[^a-zA-Z0-9]\")\n\n\ndef export_sql_results(request):\n    export_key = [k for k in request.POST.keys() if k.startswith(\"export_\")][0]\n    _, format, sql_index = export_key.split(\"_\")\n    assert format in (\"csv\", \"tsv\")\n    sqls = request.POST.getlist(\"sql\")\n    sql = sqls[int(sql_index)]\n    parameter_values = {\n        parameter: request.POST.get(parameter, \"\")\n        for parameter in extract_named_parameters(sql)\n    }\n    alias = getattr(settings, \"DASHBOARD_DB_ALIAS\", \"dashboard\")\n    # Decide on filename\n    sql_hash = hashlib.sha256(sql.encode(\"utf-8\")).hexdigest()[:6]\n    filename = non_alpha_re.sub(\"-\", sql.lower()[:30]) + sql_hash\n\n    filename_plus_ext = filename + \".\" + format\n\n    connection = connections[alias]\n    connection.cursor()  # To initialize connection\n    cursor = connection.create_cursor(name=\"c\" + filename.replace(\"-\", \"_\"))\n\n    csvfile = StringIO()\n    csvwriter = csv.writer(\n        csvfile,\n        dialect={\n            \"csv\": csv.excel,\n            \"tsv\": csv.excel_tab,\n        }[format],\n    )\n\n    def read_and_flush():\n        csvfile.seek(0)\n        data = csvfile.read()\n        csvfile.seek(0)\n        csvfile.truncate()\n        return data\n\n    def rows():\n        try:\n            cursor.execute(sql, parameter_values)\n            done_header = False\n            while True:\n                records = cursor.fetchmany(size=2000)\n                if not done_header:\n                    csvwriter.writerow([r.name for r in cursor.description])\n                    yield read_and_flush()\n                    done_header = True\n                if not records:\n                    break\n                for record in records:\n                    csvwriter.writerow(record)\n                    yield read_and_flush()\n        finally:\n            cursor.close()\n\n    response = StreamingHttpResponse(\n        rows(),\n        content_type={\n            \"csv\": \"text/csv\",\n            \"tsv\": \"text/tab-separated-values\",\n        }[format],\n    )\n    response[\"Content-Disposition\"] = 'attachment; filename=\"' + filename_plus_ext + '\"'\n    return response\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: \"2\"\nservices:\n  app:\n    build: .\n    links:\n      - db\n    volumes:\n      - .:/app\n    environment:\n      - DATABASE_URL=postgres://appuser:test123@db/test_project\n      - DJANGO_SETTINGS_MODULE=config.settings_interactive\n      - PYTHONUNBUFFERED=yup\n    working_dir: /app\n    entrypoint: [\"./test_project/wait-for-postgres.sh\"]\n    ports:\n      - \"${APP_PORT:-8000}:${APP_PORT:-8000}\"\n    command: bash -c \"python test_project/manage.py migrate && python test_project/manage.py runserver 0.0.0.0:${APP_PORT:-8000}\"\n  docs:\n    build: .\n    volumes:\n      - .:/app\n    working_dir: /app/docs\n    ports:\n      - \"${DOCS_PORT:-8001}:${DOCS_PORT:-8001}\"\n    command: make SPHINXOPTS=\"--host 0.0.0.0 --port ${DOCS_PORT:-8001}\" livehtml\n  db:\n    # Note that this database is only used when we use\n    # test_project interactively; automated tests spin up\n    # their own database inside the app container.\n    image: postgres:13-alpine\n    environment:\n      - POSTGRES_PASSWORD=test123\n      - POSTGRES_USER=appuser\n      - POSTGRES_DB=test_project\n"
  },
  {
    "path": "docs/.gitignore",
    "content": "_build\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nSPHINXPROJ    = sqlite-utils\nSOURCEDIR     = .\nBUILDDIR      = _build\n\n# Put it first so that \"make\" without argument is like \"make help\".\nhelp:\n\t@$(SPHINXBUILD) -M help \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\n.PHONY: help Makefile\n\n# Catch-all target: route all unknown targets to Sphinx using the new\n# \"make mode\" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).\n%: Makefile\n\t@$(SPHINXBUILD) -M $@ \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(O)\n\nlivehtml:\n\tsphinx-autobuild -b html \"$(SOURCEDIR)\" \"$(BUILDDIR)\" $(SPHINXOPTS) $(0)\n"
  },
  {
    "path": "docs/conf.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\nfrom subprocess import PIPE, Popen\n\n# This file is execfile()d with the current directory set to its\n# containing dir.\n#\n# Note that not all possible configuration values are present in this\n# autogenerated file.\n#\n# All configuration values have a default; values that are commented out\n# serve to show the default.\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n#\n# import os\n# import sys\n# sys.path.insert(0, os.path.abspath('.'))\n\n\n# -- General configuration ------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n#\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom\n# ones.\nextensions = [\"myst_parser\"]\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# The suffix(es) of source filenames.\n# You can specify multiple suffix as a list of string:\n#\n# source_suffix = ['.rst', '.md']\nsource_suffix = \".rst\"\n\n# The master toctree document.\nmaster_doc = \"index\"\n\n# General information about the project.\nproject = \"django-sql-dashboard\"\ncopyright = \"2021, Simon Willison\"\nauthor = \"Simon Willison\"\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\n# The short X.Y version.\npipe = Popen(\"git describe --tags --always\", stdout=PIPE, shell=True)\ngit_version = pipe.stdout.read().decode(\"utf8\")\n\nif git_version:\n    version = git_version.rsplit(\"-\", 1)[0]\n    release = git_version\nelse:\n    version = \"\"\n    release = \"\"\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n#\n# This is also used if you do content translation via gettext catalogs.\n# Usually you set \"language\" from the command line for these cases.\nlanguage = None\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This patterns also effect to html_static_path and html_extra_path\nexclude_patterns = [\"_build\", \"Thumbs.db\", \".DS_Store\"]\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = \"sphinx\"\n\n# If true, `todo` and `todoList` produce output, else they produce nothing.\ntodo_include_todos = False\n\n\n# -- Options for HTML output ----------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\n#\nhtml_theme = \"sphinx_rtd_theme\"\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\n#\n# html_theme_options = {}\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = [\"_static\"]\n\n# Custom sidebar templates, must be a dictionary that maps document names\n# to template names.\n#\n# This is required for the alabaster theme\n# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars\nhtml_sidebars = {\n    \"**\": [\n        \"relations.html\",  # needs 'show_related': True theme option to display\n        \"searchbox.html\",\n    ]\n}\n\n\n# -- Options for HTMLHelp output ------------------------------------------\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = \"django-sql-dashboard-doc\"\n\n\n# -- Options for LaTeX output ---------------------------------------------\n\nlatex_elements = {\n    # The paper size ('letterpaper' or 'a4paper').\n    #\n    # 'papersize': 'letterpaper',\n    # The font size ('10pt', '11pt' or '12pt').\n    #\n    # 'pointsize': '10pt',\n    # Additional stuff for the LaTeX preamble.\n    #\n    # 'preamble': '',\n    # Latex figure (float) alignment\n    #\n    # 'figure_align': 'htbp',\n}\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title,\n#  author, documentclass [howto, manual, or own class]).\nlatex_documents = [\n    (\n        master_doc,\n        \"django-sql-dashboard.tex\",\n        \"django-sql-dashboard documentation\",\n        \"Simon Willison\",\n        \"manual\",\n    )\n]\n\n\n# -- Options for manual page output ---------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [\n    (\n        master_doc,\n        \"django-sql-dashboard\",\n        \"django-sql-dashboard documentation\",\n        [author],\n        1,\n    )\n]\n\n\n# -- Options for Texinfo output -------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (\n        master_doc,\n        \"django-sql-dashboard\",\n        \"django-sql-dashboard documentation\",\n        author,\n        \"django-sql-dashboard\",\n        \"Django app for building dashboards using raw SQL queries\",\n        \"Miscellaneous\",\n    )\n]\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "# Contributing\n\nTo contribute to this library, first checkout the code. Use [uv](https://github.com/astral-sh/uv) to run the tests:\n\n    cd django-sql-dashboard\n    uv run pytest\n\n## Generating new migrations\n\nTo generate migrations for model changes:\n\n    cd test_project\n    uv run ./manage.py makemigrations\n\n## Code style\n\nThis library uses [Black](https://github.com/psf/black) for code formatting. You can run it like this:\n\n    uv run black .\n\n## Documentation\n\nDocumentation for this project uses [MyST](https://myst-parser.readthedocs.io/) - it is written in Markdown and rendered using Sphinx.\n\nTo build the documentation locally, run the following:\n\n    cd docs\n    uv run --with-requirements requirements.txt make livehtml\n\nThis will start a live preview server, using [sphinx-autobuild](https://pypi.org/project/sphinx-autobuild/).\n\n## Using Docker Compose\n\nIf you're familiar with Docker--or even if you're not--you may want to consider using our optional Docker Compose setup.\n\nAn advantage of this approach is that it relieves you of setting up any dependencies, such as ensuring that you have the proper version of Python and Postgres and so forth.  On the downside, however, it does require you to familiarize yourself with Docker, which, while relatively easy to use, still has its own learning curve.\n\nTo try out the Docker Compose setup, you will first want to [get Docker][] and [install Docker Compose][].\n\nThen, after checking out the code, run the following:\n\n```\ncd django-sql-dashboard\ndocker-compose build\n```\n\nAt this point, you can start editing code.  To run any development tools such as `pytest` or `black`, just prefix everything with `docker-compose run app`.  For instance, to run the test suite, run:\n\n```\ndocker-compose run app python pytest\n```\n\nIf this is a hassle, you can instead run a bash shell inside your container:\n\n```\ndocker-compose run app bash\n```\n\nAt this point, you'll be in a bash shell inside your container, and can run development tools directly.\n\n[get Docker]: https://docs.docker.com/get-docker/\n[install Docker Compose]: https://docs.docker.com/compose/install/\n\n### Using the dashboard interactively\n\nThe Docker Compose setup is configured to run a simple test project that you can use to tinker with the dashboard interactively.\n\nTo use it, run:\n\n```\ndocker-compose up\n```\n\nThen, in a separate terminal, run:\n\n```\ndocker-compose run app python test_project/manage.py createsuperuser\n```\n\nYou will now be prompted to enter details about a new superuser. Once you've done that, you can visit the example app's dashboard at http://localhost:8000/.  After entering the credentials for the superuser you just created, you will be able to tinker with the dashboard.\n\n### Editing the documentation\n\nRunning `docker-compose up` also starts the documentation system's live preview server.  You can visit it at http://localhost:8001/.\n\n### Changing the default ports\n\nIf you are already using ports 8000 and/or 8001 for other things, you can change them.  To do this, create a file in the repository root called `.env` and populate it with the following:\n\n```\nAPP_PORT=9000\nDOCS_PORT=9001\n```\n\nYou can change the above port values to whatever makes sense for your setup.\n\nOnce you next run `docker-compose up` again, the services will be running on the ports you specified in `.env`.\n\n### Changing the default UID and GID\n\nThe default settings assume that the user id (UID) and group id (GID) of the account you're using to develop are both 1000.  This is likely to be the case, since that's the UID/GID of the first non-root account on most systems.  However, if your account doesn't match this, you can customize the container to use a different UID/GID.\n\nFor instance, if your UID and GID are 1001, you can build your container with the following arguments:\n\n```\ndocker-compose build --build-arg UID=1001 --build-arg GID=1001\n```\n\n### Updating\n\nThe project's Python dependencies are all baked into the container image, which means that whenever they change (or to be safe, whenever you `git pull` new changes to the codebase), you will want to run:\n\n```\ndocker-compose build\n```\n\nYou will also want to restart `docker-compose up`.\n\n### Cleaning up\n\nIf you somehow get your Docker Compose setup into a broken state, or you decide that you never use Docker Compose again, you can clean everything up by running:\n\n```\ndocker-compose down -v\n```\n"
  },
  {
    "path": "docs/index.md",
    "content": "Django SQL Dashboard\n--------------------\n\n```{toctree}\n---\nmaxdepth: 3\n---\nsetup\nsql\nsaved-dashboards\nwidgets\nsecurity\ncontributing\n```\n\n```{include} ../README.md\n```\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "sphinx_rtd_theme\nsphinx-autobuild\nmyst-parser\n"
  },
  {
    "path": "docs/saved-dashboards.md",
    "content": "# Saved dashboards\n\nA set of SQL queries can be used to create a saved dashboard. Saved dashboards have URLs and support permissions, so you can specify which users are allowed to see which dashboard.\n\nYou can create a saved dashboard from the interactive dashboard interface (at `/dashboard/`) - execute some queries, then scroll down to the \"Save this dashboard\" form.\n\n## View permissions\n\nThe following viewing permission policies are available:\n\n- `private`: Only the user who created (owns) the dashboard can view\n- `public`: Any user can view\n- `unlisted`: Any user can view, but they need to know the URL (this feature is not complete)\n- `loggedin`: Any logged-in user can view\n- `group`: Any user who is a member of the `view_group` attached to the dashboard can view\n- `staff`: Any user who is staff can view\n- `superuser`: Any user who is a superuser can view\n\n(edit_permissions)=\n\n## Edit permissions\n\nThe edit policy controls which users are allowed to edit a dashboard - defaulting to the user who created that dashboard.\n\nEditing currently takes place through the Django Admin - so only users who are staff members with access to that interface will be able to edit their dashboards.\n\nThe full list of edit policy options are:\n\n- `private`: Only the user who created (owns) the dashboard can edit\n- `loggedin`: Any logged-in user can edit\n- `group`: Any user who is a member of the `edit_group` attached to the dashboard can edit\n- `staff`: Any user who is staff can edit\n- `superuser`: Any user who is a superuser can edit\n\nDashboards belong to the user who created them. Only Django super-users can re-assign ownership of dashboards to other users.\n\n## JSON export\n\nIf your dashboard is called `/dashboards/demo/` you can add `.json` to get `/dashboards/demo.json` which will return a JSON representation of the dashboard.\n\nThe JSON format looks something like this:\n\n```json\n{\n  \"title\": \"Tag word cloud\",\n  \"queries\": [\n    {\n      \"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\",\n      \"rows\": [\n        {\n          \"wordcloud_word\": \"python\",\n          \"wordcloud_count\": 826\n        },\n        {\n          \"wordcloud_word\": \"javascript\",\n          \"wordcloud_count\": 604\n        },\n        {\n          \"wordcloud_word\": \"django\",\n          \"wordcloud_count\": 529\n        },\n        {\n          \"wordcloud_word\": \"security\",\n          \"wordcloud_count\": 402\n        },\n        {\n          \"wordcloud_word\": \"datasette\",\n          \"wordcloud_count\": 331\n        },\n        {\n          \"wordcloud_word\": \"projects\",\n          \"wordcloud_count\": 282\n        }\n      ],\n    }\n  ]\n}\n```\n\nSet the `DASHBOARD_DISABLE_JSON` setting to `True` to disable this feature.\n"
  },
  {
    "path": "docs/security.md",
    "content": "# Security\n\nAllowing people to execute their own SQL directly against your database is risky business!\n\nThe safest way to use this tool is to create a read-only replica of your PostgreSQL database with a read-only role that enforces a statement time-limit for executed queries. Different database providers have different mechanisms for doing this - consult your hosting provider's documentation.\n\nYou should only provide access to this tool to people you trust. Malicious users may be able to negatively affect the performance of your servers through constructing SQL queries that deliberately consume large amounts of resources.\n\nConfigured correctly, Django SQL Dashboard uses a number of measures to keep your data and your database server safe:\n\n- I strongly recommend creating a dedicated PostgreSQL role for accessing your database with read-only permissions granted to an allow-list of tables. PostgreSQL has extremely robust, well tested permissions which this tool can take full advantage of.\n- Likewise, configuring a PostgreSQL-enforced query time limit can reduce the risk of expensive queries affecting the performance of the rest of your site.\n- Setting up a read-only reporting replica for use with this tool can provide even stronger isolation from other site traffic.\n- Your allow-list of tables should not include tables with sensitive information. Django's auth_user table contains password hashes, and the django_session table contains user session information. Neither should be exposed using this tool.\n- Access to the dashboard is controlled by Django's permissions system, which means you can limit access to trusted team members.\n- SQL queries can be passed to the dashboard using a ?sql= query string parameter - but this parameter needs to be signed before it will be executed. This should prevent attempts to trick you into executing malevolent SQL queries by sending you crafted links - while still allowing your team to create links to queries that can be securely shared.\n- Any time a user views a dashboard page while logged in, `Cache-Control: private` is set on the response to ensure the authenticated dashboard will not be stored in any intermediary HTTP caches\n"
  },
  {
    "path": "docs/setup.md",
    "content": "# Installation and configuration\n\n## Install using pip\n\nInstall this library using `pip`:\n\n    $ pip install django-sql-dashboard\n\n### Run migrations\n\nThe migrations create tables that store dashboards and queries:\n\n    $ ./manage.py migrate\n\n## Configuration\n\nAdd `\"django_sql_dashboard\"` to your `INSTALLED_APPS` in `settings.py`.\n\nAdd the following to your `urls.py`:\n\n```python\nfrom django.urls import path, include\nimport django_sql_dashboard\n\nurlpatterns = [\n    # ...\n    path(\"dashboard/\", include(django_sql_dashboard.urls)),\n]\n```\n\n## Setting up read-only PostgreSQL credentials\n\nThe safest way to use this tool is against a dedicated read-only replica of your database - see [security](./security) for more details.\n\nCreate a new PostgreSQL user or role that is limited to read-only SELECT access to a specific list of tables.\n\nIf your read-only role is called `my-read-only-role`, you can grant access using the following SQL (executed as a privileged user):\n\n```sql\nGRANT USAGE ON SCHEMA PUBLIC TO \"my-read-only-role\";\n```\nThis grants that role the ability to see what tables exist. You then need to grant `SELECT` access to specific tables like this:\n```sql\nGRANT SELECT ON TABLE\n    public.locations_location,\n    public.locations_county,\n    public.django_content_type,\n    public.django_migrations\nTO \"my-read-only-role\";\n```\nThink carefully about which tables you expose to the dashboard - in particular, you should avoid exposing tables that contain sensitive data such as `auth_user` or `django_session`.\n\nIf you do want to expose `auth_user` - which can be useful if you want to join other tables against it to see details of the user that created another record - you can grant access to specific columns like so:\n```sql\nGRANT SELECT(\n  id, last_login, is_superuser, username, first_name,\n  last_name, email, is_staff, is_active, date_joined\n) ON auth_user TO \"my-read-only-role\";\n```\nThis will allow queries against everything except for the `password` column.\n\nNote that if you use this pattern the query `select * from auth_user` will return a \"permission denied\" error. You will need to explicitly list the columns you would like to see from that table instead, for example `select id, username, date_joined from auth_user`.\n\n## Configuring the \"dashboard\" database alias\n\nDjango SQL Dashboard defaults to executing all queries using the `\"dashboard\"` Django database alias.\n\nYou can define this `\"dashboard\"` database alias in `settings.py`. Your `DATABASES` section should look something like this:\n\n```python\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"NAME\": \"mydb\",\n        \"USER\": \"read_write_user\",\n        \"PASSWORD\": \"read_write_password\",\n        \"HOST\": \"dbhost.example.com\",\n        \"PORT\": \"5432\",\n    },\n    \"dashboard\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"NAME\": \"mydb\",\n        \"USER\": \"read_only_user\",\n        \"PASSWORD\": \"read_only_password\",\n        \"HOST\": \"dbhost.example.com\",\n        \"PORT\": \"5432\",\n        \"OPTIONS\": {\n            \"options\": \"-c default_transaction_read_only=on -c statement_timeout=100\"\n        },\n    },\n}\n```\nIn addition to the read-only user and password, pay attention to the `\"OPTIONS\"` section: this sets a statement timeout of 100ms - queries that take longer than that will be terminated with an error message. It also sets it so transactions will be read-only by default, as an extra layer of protection should your read-only user have more permissions that you intended.\n\nNow visit `/dashboard/` as a staff user to start trying out the dashboard.\n\n### Danger mode: configuration without a read-only database user\n\nSome hosting environments such as Heroku charge extra for the ability to create read-only database users. For smaller projects with dashboard access only made available to trusted users it's possible to configure this tool without a read-only account, using the following options:\n\n```python\n    # ...\n    \"dashboard\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"USER\": \"read_write_user\",\n        # ...\n        \"OPTIONS\": {\n            \"options\": \"-c default_transaction_read_only=on -c statement_timeout=100\"\n        },\n    },\n```\nThe `-c default_transaction_read_only=on` option here should prevent accidental writes from being executed, but note that dashboard users in this configuration will be able to access _all tables_ including tables that might contain sensitive information. Only use this trick if you are confident you fully understand the implications!\n\n### dj-database-url and django-configurations\n\nIf you are using [dj-database-url](https://github.com/jacobian/dj-database-url) or [django-configurations](https://github.com/jazzband/django-configurations) _(with `database` extra requirement)_, your `DATABASES` section should look something like this:\n\n```python\nimport dj_database_url\n\n# ...\n\nDATABASES = {\n    \"default\": dj_database_url.config(env=\"DATABASE_URL\"),\n    \"dashboard\": dj_database_url.config(env=\"DATABASE_DASHBOARD_URL\"),\n}\n```\n\nYou can define the two database url variables in your environment like this:\n\n```ini\nDATABASE_URL=postgresql://read_write_user:read_write_password@dbhost.example.com:5432/mydb\nDATABASE_DASHBOARD_URL=postgresql://read_write_user:read_write_password@dbhost.example.com:5432/mydb?options=-c%20default_transaction_read_only%3Don%20-c%20statement_timeout%3D100\n```\n\n## Django permissions\n\nAccess to the `/dashboard/` interface is controlled by the Django permissions system. To grant a Django user or group access, grant them the `django_sql_dashboard.execute_sql` permission. This is displayed in the admin interface as:\n\n    django_sql_dashboard | dashboard | Can execute arbitrary SQL queries\n\nDashboard editing is currently handled by the Django admin interface. This means a user needs to have **staff** status (allowing them access to the Django admin interface) in order to edit one of their saved dashboards.\n\nThe regular Django permission for \"can edit dashboard\" is ignored. Instead, a permission system that is specific to Django SQL Dashboard is used to control edit permissions. See {ref}`edit_permissions` for details.\n\n## Additional settings\n\nYou can customize the following settings in Django's `settings.py` module:\n\n- `DASHBOARD_DB_ALIAS = \"db_alias\"` - which database alias to use for executing these queries. Defaults to `\"dashboard\"`.\n- `DASHBOARD_ROW_LIMIT = 1000` - the maximum number of rows that can be returned from a query. This defaults to 100.\n- `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.\n- `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.\n- `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`.\n\n## Custom templates\n\nThe templates used by `django-sql-dashboard` extend a base template called `django_sql_dashboard/base.html`, which provides Django template blocks named `title` and `content`. You can customize the appearance of your dashboard installation by providing your own version of this base template in your own configured `templates/` directory.\n"
  },
  {
    "path": "docs/sql.md",
    "content": "# Running SQL queries\n\nVisit `/dashboard/` to get started. This interface allows you to execute one or more PostgreSQL SQL queries.\n\nResults will be displayed below each query, limited to a maxmim of 100 rows.\n\nThe queries you have executed are encoded into the URL of the page. This means you can bookmark queries and share those links with other people who can access your dashboard.\n\nNote that the queries in the URL are signed using Django's `SECRET_KEY` setting. This means that changing you secret will break your bookmarked URLs.\n\n## SQL parameters\n\nIf your SQL query contains `%(name)s` parameters, `django-sql-dashboard` will convert those into form fields on the page and allow users to submit values for them. These will be correctly quoted and escaped in the SQL query.\n\nGiven the following SQL query:\n\n```\nselect * from blog_entry where slug = %(slug)s\n```\nA form field called `slug` will be displayed, and the user will be able to use that to search for blog entries with that given slug.\n\nHere's a more advanced example:\n\n```sql\nselect * from location\nwhere state_id = cast(%(state_id)s as integer)\nand name ilike '%%' || %(search)s || '%%';\n```\nHere a form will be displayed with `state_id` and `search` fields.\n\nThe values provided by the user will always be treated like strings - so in this example the `state_id` is cast to integer in order to be matched with an integer column.\n\nAny `%` characters - for example in the `ilike` query above - need to be escaped by providing them twice: `%%`."
  },
  {
    "path": "docs/widgets.md",
    "content": "# Widgets\n\nSQL queries default to displaying as a table. Other forms of display - called widgets - are also available, and are selected based on the names of the columns returned by the query.\n\n## Bar chart: bar_label, bar_quantity\n\nA query that returns columns called `bar_label` and `bar_quantity` will be rendered as a simple bar chart, using [Vega-Lite](https://vega.github.io/vega-lite/).\n\n![A bar chart produced by this widget](bar_label-bar_quantity.png)\n\nBar chart live demo: [simonwillison.net/dashboard/by-month/](https://simonwillison.net/dashboard/by-month/)\n\nSQL example:\n\n```sql\nselect\n  county.name as bar_label,\n  count(*) as bar_quantity\nfrom location\n  join county on county.id = location.county_id\ngroup by county.name\norder by count(*) desc limit 10\n```\n\nOr using a static list of values:\n\n```sql\nSELECT * FROM (\n    VALUES (1, 'one'), (2, 'two'), (3, 'three')\n) AS t (bar_quantity, bar_label);\n```\n\n## Big number: big_number, label\n\nIf you want to display the results as a big number accompanied by a label, you can do so by returning `big_number` and `label` columns from your query, for example.\n\n```sql\nselect 'Number of states' as label, count(*) as big_number from states;\n```\n\n![Output of the big number widget](big_number-label.png)\n\nBig number live demo: [simonwillison.net/dashboard/big-numbers-demo/](https://simonwillison.net/dashboard/big-numbers-demo/)\n\n## Progress bar: completed_count, total_count\n\nTo display a progress bar, return columns `total_count` and `completed_count`.\n\n```sql\nselect 1203 as total_count, 755 as completed_count;\n```\n\nThis SQL pattern can be useful for constructing progress bars:\n```sql\nselect (\n  select count(*) from task\n) as total_count, (\n  select count(*) from task where resolved_at is not null\n) as completed_count\n```\n![Output of the progress bar widget](completed_count-total_count.png)\n\nProgress bar live demo: [simonwillison.net/dashboard/progress-bar-demo/](https://simonwillison.net/dashboard/progress-bar-demo/)\n\n## Word cloud: wordcloud_count, wordcloud_word\n\nTo display a word cloud, return a column `wordcloud_word` containing words with a corresponding `wordcloud_count` column with the frequency of those words.\n\nThis example generates word clouds for article body text:\n```sql\nwith words as (\n  select\n    lower(\n      (regexp_matches(body, '\\w+', 'g'))[1]\n    ) as word\n  from\n    articles\n)\nselect\n  word as wordcloud_word,\n  count(*) as wordcloud_count\nfrom\n  words\ngroup by\n  word\norder by\n  count(*) desc\n```\n\nHere's a fun variant that uses PostgreSQL's built-in stemming algorithm to first remove common stop words:\n\n```sql\nwith words as (\n  select\n    lower(\n      (regexp_matches(to_tsvector('english', body)::text, '[a-z]+', 'g'))[1]\n    ) as word\n  from\n    articles\n)\nselect\n  word as wordcloud_word,\n  count(*) as wordcloud_count\nfrom\n  words\ngroup by\n  word\norder by\n  count(*) desc\n```\n\n![Output of the word cloud widget](wordcloud_count-wordcloud_word.png)\n\nWord cloud live demo: [simonwillison.net/dashboard/tag-cloud/](https://simonwillison.net/dashboard/tag-cloud/)\n\n## markdown\n\nReturn a single column called `markdown` to render the contents as Markdown, for example:\n\n```sql\nselect '# Number of states: ' || count(*) as markdown from states;\n```\n\n## html\n\nReturn a single column called `html` to render the contents directly as HTML. This HTML is filtered using [Bleach](https://github.com/mozilla/bleach) so the only tags allowed are `a[href]`, `abbr`, `acronym`, `b`, `blockquote`, `code`, `em`, `i`, `li`, `ol`, `strong`, `ul`, `pre`, `p`, `h1`, `h2`, `h3`, `h4`, `h5`, `h6`.\n\n```sql\nselect '<h1>Number of states: ' || count(*) || '</h1>' as html from states;\n```\n\n# Custom widgets\n\nYou can define your own custom widgets by creating templates with special names.\n\nDecide on the column names that you wish to customize for, then sort them alphabetically and join them with hyphens to create your template name.\n\nFor example, you could define a widget that handles results returned as `placename`, `geojson` by creating a template called `geojson-placename.html`.\n\nSave that in one of your template directories as `django_sql_dashboard/widgets/geojson-placename.html`.\n\nAny SQL query that returns exactly the columns `placename` and `geojson` will now be rendered by your custom template file.\n\nWithin your custom template you will have access to a template variable called `result` with the following keys:\n\n- `result.sql` - the SQL query that is being displayed\n- `rows` - a list of rows, where each row is a dictionary mapping columns to their values\n- `row_lists` - a list of rows, where each row is a list of the values in that row\n- `description` - the psycopg2 cursor description\n- `columns` - a list of string column names\n- `column_details` - a list of `{\"name\": column_name, \"is_unambiguous\": True or False}` dictionaries - `is_unambiguous` is `False` if multiple columns of the same name are returned by this query\n- `truncated` - boolean, specifying whether the results were truncated (at 100 items) or not\n- `extra_qs` - extra parameters for the page encoded as a query string fragment - so if the page was loaded with `state_id=5` then `extra_qs` would be `&state_id=5`. You can use this to assemble links to further queries, like the \"Count\" column links in the default table view.\n- `duration_ms` - how long the query took, in floating point milliseconds\n- `templates` - a list of templates that were considered for rendering this widget\n\nThe easiest way to define your custom widget template is to extend the `django_sql_dashboard/widgets/_base_widget.html` base template.\n\nHere is the full implementation of the `big_number`, `label` widget that is included with Django SQL Dashboard, in the `django_sql_dashboard/widgets/big_number-label.html` template file:\n\n```html+django\n{% extends \"django_sql_dashboard/widgets/_base_widget.html\" %}\n\n{% block widget_results %}\n  {% for row in result.rows %}\n    <div class=\"big-number\">\n      <p><strong>{{ row.label }}</strong></p>\n      <h1>{{ row.big_number }}</h1>\n    </div>\n  {% endfor %}\n{% endblock %}\n```\n\nYou can find more examples of widget templates in the [templates/django_sql_dashboard/widgets](https://github.com/simonw/django-sql-dashboard/tree/main/django_sql_dashboard/templates/django_sql_dashboard/widgets) directory.\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"django-sql-dashboard\"\nversion = \"1.2\"\ndescription = \"Django app for building dashboards using raw SQL queries\"\nreadme = \"README.md\"\nlicense = \"Apache-2.0\"\nauthors = [\n    { name = \"Simon Willison\" }\n]\nrequires-python = \">=3.10\"\ndependencies = [\n    \"Django>=4.2\",\n    \"markdown\",\n    \"bleach\",\n]\n\n[project.urls]\nDocumentation = \"https://django-sql-dashboard.datasette.io/\"\nIssues = \"https://github.com/simonw/django-sql-dashboard/issues\"\nCI = \"https://github.com/simonw/django-sql-dashboard/actions\"\nChangelog = \"https://github.com/simonw/django-sql-dashboard/releases\"\nHomepage = \"https://github.com/simonw/django-sql-dashboard\"\n\n[dependency-groups]\ndev = [\n    \"black>=22.3.0\",\n    \"psycopg2\",\n    \"pytest\",\n    \"pytest-django>=4.11.1\",\n    \"pytest-pythonpath\",\n    \"dj-database-url\",\n    \"testing.postgresql\",\n    \"beautifulsoup4\",\n    \"html5lib\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"django_sql_dashboard\"]\n\n[tool.isort]\nprofile = \"black\"\nmulti_line_output = 3\n\n[tool.pytest.ini_options]\nDJANGO_SETTINGS_MODULE = \"config.settings\"\npythonpath = [\"test_project\", \"pytest_plugins\"]\naddopts = \"-p pytest_use_postgresql\"\n"
  },
  {
    "path": "pytest_plugins/__init__.py",
    "content": ""
  },
  {
    "path": "pytest_plugins/pytest_use_postgresql.py",
    "content": "import os\nimport pytest\nfrom dj_database_url import parse\nfrom django.conf import settings\nfrom testing.postgresql import Postgresql\n\npostgres = os.environ.get(\"POSTGRESQL_PATH\")\ninitdb = os.environ.get(\"INITDB_PATH\")\n_POSTGRESQL = Postgresql(postgres=postgres, initdb=initdb)\n\n\n@pytest.hookimpl(tryfirst=True)\ndef pytest_load_initial_conftests(early_config, parser, args):\n    os.environ[\"DJANGO_SETTINGS_MODULE\"] = early_config.getini(\"DJANGO_SETTINGS_MODULE\")\n    settings.DATABASES[\"default\"] = parse(_POSTGRESQL.url())\n    settings.DATABASES[\"dashboard\"] = parse(_POSTGRESQL.url())\n\n\ndef pytest_unconfigure(config):\n    _POSTGRESQL.stop()\n"
  },
  {
    "path": "test_project/config/__init__.py",
    "content": ""
  },
  {
    "path": "test_project/config/asgi.py",
    "content": "import os\n\nfrom django.core.asgi import get_asgi_application\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"config.settings\")\n\napplication = get_asgi_application()\n"
  },
  {
    "path": "test_project/config/settings.py",
    "content": "import os\nfrom pathlib import Path\n\nimport dj_database_url\n\nBASE_DIR = Path(__file__).resolve().parent.parent\n\n\nSECRET_KEY = \"local-testing-insecure-secret\"\nDEBUG = True\n\nALLOWED_HOSTS = [\"*\"]\n\n\n# Application definition\n\nINSTALLED_APPS = [\n    \"django.contrib.admin\",\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"django_sql_dashboard\",\n    \"extra_models\",\n]\n\nMIDDLEWARE = [\n    \"django.middleware.security.SecurityMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n]\n\nROOT_URLCONF = \"config.urls\"\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n            ],\n        },\n    },\n]\n\nWSGI_APPLICATION = \"config.wsgi.application\"\n\n\nDATABASES = {\n    \"default\": dj_database_url.config(),\n}\n\n# Password validation\n# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators\n\nAUTH_PASSWORD_VALIDATORS = [\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\",\n    },\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\",\n    },\n]\n\n\n# Internationalization\n# https://docs.djangoproject.com/en/3.1/topics/i18n/\n\nLANGUAGE_CODE = \"en-us\"\n\nTIME_ZONE = \"UTC\"\n\nUSE_I18N = True\n\nUSE_L10N = True\n\nUSE_TZ = True\n\n\n# Static files (CSS, JavaScript, Images)\n# https://docs.djangoproject.com/en/3.1/howto/static-files/\n\nSTATIC_URL = \"/static/\"\n\nLOGIN_URL = \"/admin/login/\"\n"
  },
  {
    "path": "test_project/config/settings_interactive.py",
    "content": "# Normally test_project is used as scaffolding for\n# django_sql_dashboard's automated tests. However, it can\n# be useful during development to have a sample project to\n# tinker with interactively. These Django settings can be\n# useful when we want to do that.\n\nfrom .settings import *\n\n# Just have our dashboard use the exact same credentials for\n# our database, there's no need to bother with read-only\n# permissions when using test_project interactively.\nDATABASES[\"dashboard\"] = DATABASES[\"default\"]\n"
  },
  {
    "path": "test_project/config/urls.py",
    "content": "from django.contrib import admin\nfrom django.urls import include, path\nfrom django.views.generic.base import RedirectView\n\nimport django_sql_dashboard\n\n\nurlpatterns = [\n    path(\"dashboard/\", include(django_sql_dashboard.urls)),\n    path(\"admin/\", admin.site.urls),\n    path(\"\", RedirectView.as_view(url=\"/dashboard/\")),\n]\n"
  },
  {
    "path": "test_project/config/wsgi.py",
    "content": "import os\n\nfrom django.core.wsgi import get_wsgi_application\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"config.settings\")\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "test_project/extra_models/models.py",
    "content": "from django.db import models\n\n\nclass Switch(models.Model):\n    name = models.SlugField()\n    on = models.BooleanField(default=False)\n\n    class Meta:\n        db_table = \"switches\"\n"
  },
  {
    "path": "test_project/manage.py",
    "content": "#!/usr/bin/env python\n\"\"\"Django's command-line utility for administrative tasks.\"\"\"\nimport os\nimport sys\n\n\ndef main():\n    \"\"\"Run administrative tasks.\"\"\"\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"config.settings\")\n    try:\n        from django.core.management import execute_from_command_line\n    except ImportError as exc:\n        raise ImportError(\n            \"Couldn't import Django. Are you sure it's installed and \"\n            \"available on your PYTHONPATH environment variable? Did you \"\n            \"forget to activate a virtual environment?\"\n        ) from exc\n    execute_from_command_line(sys.argv)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "test_project/test_dashboard.py",
    "content": "import urllib.parse\n\nimport pytest\nfrom bs4 import BeautifulSoup\nfrom django.core import signing\nfrom django.db import connections\n\nfrom django_sql_dashboard.utils import SQL_SALT, is_valid_base64_json, sign_sql\n\n\ndef test_dashboard_submit_sql(admin_client, dashboard_db):\n    # Test full flow of POST submitting new SQL, having it signed\n    # and having it redirect to the results page\n    get_response = admin_client.get(\"/dashboard/\")\n    assert get_response.status_code == 200\n    assert get_response[\"Content-Security-Policy\"] == \"frame-ancestors 'self'\"\n    sql = \"select 14 + 33\"\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": sql,\n            \"_save-title\": \"\",\n            \"_save-slug\": \"\",\n            \"_save-description\": \"\",\n            \"_save-view_policy\": \"private\",\n            \"_save-view_group\": \"\",\n            \"_save-edit_policy\": \"private\",\n            \"_save-edit_group\": \"\",\n        },\n    )\n    assert response.status_code == 302\n    # Should redirect to ?sql=signed-value\n    bits = urllib.parse.parse_qs(response.url.split(\"?\")[1])\n    assert set(bits.keys()) == {\"sql\"}\n    signed_sql = bits[\"sql\"][0]\n    assert signed_sql == sign_sql(sql)\n    # GET against this new location should return correct result\n    get_response = admin_client.get(response.url)\n    assert get_response.status_code == 200\n    assert b\"47\" in get_response.content\n\n\ndef test_invalid_signature_shows_warning(admin_client, dashboard_db):\n    response1 = admin_client.post(\"/dashboard/\", {\"sql\": \"select 1 + 1\"})\n    signed_sql = urllib.parse.parse_qs(response1.url.split(\"?\")[1])[\"sql\"][0]\n    # Break the signature and load the page\n    response2 = admin_client.get(\n        \"/dashboard/?\" + urllib.parse.urlencode({\"sql\": signed_sql[:-1]})\n    )\n    html = response2.content.decode(\"utf-8\")\n    assert \">Unverified SQL<\" in html\n    assert \"<textarea>select 1 + 1</textarea>\" in html\n\n\ndef test_dashboard_upgrade_old_base64_links(admin_client, dashboard_db, settings):\n    old_signed = signing.dumps(\"select 1 + 1\", salt=SQL_SALT)\n    assert is_valid_base64_json(old_signed.split(\":\")[0])\n    # Should do nothing without setting\n    assert admin_client.get(\"/dashboard/?sql=\" + old_signed).status_code == 200\n    # With setting should redirect\n    settings.DASHBOARD_UPGRADE_OLD_BASE64_LINKS = True\n    response = admin_client.get(\"/dashboard/?sql=\" + old_signed)\n    assert response.status_code == 302\n    assert response.url == \"/dashboard/?\" + urllib.parse.urlencode(\n        {\"sql\": sign_sql(\"select 1 + 1\")}\n    )\n\n\ndef test_dashboard_upgrade_does_not_break_regular_pages(\n    admin_client, dashboard_db, settings\n):\n    # With setting should redirect\n    settings.DASHBOARD_UPGRADE_OLD_BASE64_LINKS = True\n    response = admin_client.get(\"/dashboard/\")\n    assert response.status_code == 200\n\n\ndef test_saved_dashboard(client, admin_client, dashboard_db, saved_dashboard):\n    assert admin_client.get(\"/dashboard/test2/\").status_code == 404\n    response = admin_client.get(\"/dashboard/test/\")\n    assert response.status_code == 200\n    assert b\"44\" in response.content\n    assert b\"77\" in response.content\n    assert b\"data-count-url\" in response.content\n    # And test markdown support\n    assert (\n        b'<a href=\"http://example.com/\" rel=\"nofollow\">supports markdown</a>'\n        in response.content\n    )\n\n\ndef test_many_long_column_names(admin_client, dashboard_db):\n    # https://github.com/simonw/django-sql-dashboard/issues/23\n    columns = [\"column{}\".format(i) for i in range(200)]\n    sql = \"select \" + \", \".join(\n        \"'{}' as {}\".format(column, column) for column in columns\n    )\n    response = admin_client.post(\"/dashboard/\", {\"sql\": sql}, follow=True)\n    assert response.status_code == 200\n\n\n@pytest.mark.parametrize(\n    \"sql,expected_error\",\n    (\n        (\n            \"select * from not_a_table\",\n            'relation \"not_a_table\" does not exist\\nLINE 1: select * from not_a_table\\n                      ^',\n        ),\n        (\n            \"select 'foo' like 'f%'\",\n            r\"Invalid query - try escaping single '%' as double '%%'\",\n        ),\n        (\n            \"select '% completed'\",\n            r\"Invalid query - try escaping single '%' as double '%%'\",\n        ),\n    ),\n)\ndef test_dashboard_sql_errors(admin_client, sql, expected_error):\n    response = admin_client.post(\"/dashboard/\", {\"sql\": sql}, follow=True)\n    assert response.status_code == 200\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    div = soup.select(\".query-results\")[0]\n    assert div[\"class\"] == [\"query-results\", \"query-error\"]\n    assert div.select(\".error-message\")[0].text.strip() == expected_error\n\n\n@pytest.mark.parametrize(\n    \"sql,expected_columns,expected_rows\",\n    (\n        (\"select 'abc' as one, 'bcd' as one\", [\"one\", \"one\"], [[\"abc\", \"bcd\"]]),\n        (\"select ARRAY[1, 2, 3]\", [\"array\"], [[\"[\\n  1,\\n  2,\\n  3\\n]\"]]),\n        (\n            \"select ARRAY[TIMESTAMP WITH TIME ZONE '2004-10-19 10:23:54+02']\",\n            [\"array\"],\n            [['[\\n  \"2004-10-19 08:23:54+00:00\"\\n]']],\n        ),\n    ),\n)\ndef test_dashboard_sql_queries(admin_client, sql, expected_columns, expected_rows):\n    response = admin_client.post(\"/dashboard/\", {\"sql\": sql}, follow=True)\n    assert response.status_code == 200\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    div = soup.select(\".query-results\")[0]\n    columns = [th.text.split(\" [\")[0] for th in div.findAll(\"th\")]\n    trs = div.find(\"tbody\").findAll(\"tr\")\n    rows = [[td.text for td in tr.findAll(\"td\")] for tr in trs]\n    assert columns == expected_columns\n    assert rows == expected_rows\n\n\ndef test_dashboard_uses_post_if_sql_is_too_long(admin_client):\n    # Queries longer than 1800 characters do not redirect to GET\n    short_sql = \"select %(start)s::integer + \"\n    long_sql = \"select %(start)s::integer + \" + \"+\".join([\"1\"] * 1801)\n    assert (\n        admin_client.post(\"/dashboard/\", {\"sql\": short_sql, \"start\": 100}).status_code\n        == 302\n    )\n    response = admin_client.post(\"/dashboard/\", {\"sql\": long_sql, \"start\": 100})\n    assert response.status_code == 200\n    assert b\"1901\" in response.content\n    # And should not have 'count' links\n    assert b\"data-count-url=\" not in response.content\n\n\n@pytest.mark.parametrize(\n    \"path,sqls,args,expected_title\",\n    (\n        (\"/dashboard/\", [], None, \"SQL Dashboard\"),\n        (\"/dashboard/\", [\"select 1\"], None, \"SQL: select 1\"),\n        (\n            \"/dashboard/\",\n            [\"select %(name)s\"],\n            {\"name\": \"test\"},\n            \"SQL: select %(name)s: test\",\n        ),\n        (\n            \"/dashboard/\",\n            [\"select %(name)s, %(age)s\"],\n            {\"name\": \"test\", \"age\": 5},\n            \"SQL: select %(name)s, %(age)s: name=test, age=5\",\n        ),\n        (\"/dashboard/\", [\"select 1\", \"select 2\"], None, \"SQL: select 1 [,] select 2\"),\n        (\"/dashboard/test/\", [], None, \"Test dashboard\"),\n        (\"/dashboard/test/\", [], {\"name\": \"claire\"}, \"Test dashboard: claire\"),\n    ),\n)\ndef test_dashboard_html_title(\n    admin_client, saved_dashboard, path, args, sqls, expected_title\n):\n    saved_dashboard.queries.create(sql=\"select %(name)s\")\n    args = args or {}\n    if sqls:\n        args[\"sql\"] = sqls\n        response = admin_client.post(path, args, follow=True)\n    else:\n        response = admin_client.get(path, data=args)\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    assert soup.find(\"title\").text == expected_title\n\n\ndef test_saved_dashboard_errors_sql_not_in_textarea(admin_client, saved_dashboard):\n    saved_dashboard.queries.create(sql=\"this is bad\")\n    response = admin_client.get(\"/dashboard/test/\")\n    html = response.content.decode(\"utf-8\")\n    assert '<pre class=\"sql\">this is bad</pre>' in html\n\n\ndef test_dashboard_show_available_tables(admin_client):\n    response = admin_client.get(\"/dashboard/\")\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    lis = soup.find(\"ul\").findAll(\"li\")\n    details = [\n        {\n            \"table\": li.find(\"a\").text,\n            \"columns\": li.find(\"p\").text,\n            \"href\": li.find(\"a\")[\"href\"],\n        }\n        for li in lis\n        if li.find(\"a\").text.startswith(\"django_sql_dashboard\")\n        or li.find(\"a\").text == \"switches\"\n    ]\n    # Decode the href in each one into a SQL query\n    for detail in details:\n        href = detail.pop(\"href\")\n        detail[\"href_sql\"] = urllib.parse.parse_qs(href)[\"sql\"][0].rsplit(\":\", 1)[0]\n    assert details == [\n        {\n            \"table\": \"django_sql_dashboard_dashboard\",\n            \"columns\": \"id, slug, title, description, created_at, edit_group_id, edit_policy, owned_by_id, view_group_id, view_policy\",\n            \"href_sql\": \"select id, slug, title, description, created_at, edit_group_id, edit_policy, owned_by_id, view_group_id, view_policy from django_sql_dashboard_dashboard\",\n        },\n        {\n            \"table\": \"django_sql_dashboard_dashboardquery\",\n            \"columns\": \"id, sql, dashboard_id, _order\",\n            \"href_sql\": \"select id, sql, dashboard_id, _order from django_sql_dashboard_dashboardquery\",\n        },\n        {\n            \"table\": \"switches\",\n            \"columns\": \"id, name, on\",\n            \"href_sql\": 'select id, name, \"on\" from switches',\n        },\n    ]\n"
  },
  {
    "path": "test_project/test_dashboard_permissions.py",
    "content": "from enum import Enum\n\nimport pytest\nfrom bs4 import BeautifulSoup\nfrom django.contrib.auth.models import Group, User\n\nfrom django_sql_dashboard.models import Dashboard\n\n\ndef test_anonymous_user_redirected_to_login(client):\n    response = client.get(\"/dashboard/?sql=select+1\")\n    assert response.status_code == 302\n    assert response.url == \"/admin/login/?next=/dashboard/%3Fsql%3Dselect%2B1\"\n\n\ndef test_superusers_allowed(admin_client, dashboard_db):\n    response = admin_client.get(\"/dashboard/\")\n    assert response.status_code == 200\n    assert b\"<title>SQL Dashboard</title>\" in response.content\n\n\ndef test_must_have_execute_sql_permission(\n    client, django_user_model, dashboard_db, execute_sql_permission\n):\n    not_staff = django_user_model.objects.create(username=\"not_staff\")\n    staff_no_permisssion = django_user_model.objects.create(\n        username=\"staff_no_permission\", is_staff=True\n    )\n    staff_with_permission = django_user_model.objects.create(\n        username=\"staff_with_permission\", is_staff=True\n    )\n    staff_with_permission.user_permissions.add(execute_sql_permission)\n    assert staff_with_permission.has_perm(\"django_sql_dashboard.execute_sql\")\n    client.force_login(not_staff)\n    assert client.get(\"/dashboard/\").status_code == 403\n    client.force_login(staff_no_permisssion)\n    assert client.get(\"/dashboard/\").status_code == 403\n    client.force_login(staff_with_permission)\n    assert client.get(\"/dashboard/\").status_code == 200\n\n\ndef test_user_without_execute_sql_permission_does_not_see_count_links_on_saved_dashboard(\n    client, django_user_model, execute_sql_permission, dashboard_db\n):\n    dashboard = Dashboard.objects.create(slug=\"test\", view_policy=\"public\")\n    dashboard.queries.create(sql=\"select 11 + 34\")\n    user = django_user_model.objects.create(username=\"regular\")\n    client.force_login(user)\n    response = client.get(\"/dashboard/test/\")\n    assert response.status_code == 200\n    html = response.content.decode(\"utf-8\")\n    assert \"data-count-url=\" not in html\n    # If the user DOES have that permission they get the count links\n    user.user_permissions.add(execute_sql_permission)\n    response = client.get(\"/dashboard/test/\")\n    html = response.content.decode(\"utf-8\")\n    assert \"data-count-url=\" in html\n\n\ndef test_saved_dashboard_anonymous_users_denied_by_default(client, dashboard_db):\n    dashboard = Dashboard.objects.create(slug=\"test\")\n    dashboard.queries.create(sql=\"select 11 + 34\")\n    response = client.get(\"/dashboard/test/\")\n    assert response.status_code == 403\n\n\nclass UserType(Enum):\n    owner = 1\n    anon = 2\n    loggedin = 3\n    groupmember = 4\n    staff = 5\n    superuser = 6\n\n\nall_user_types = (\n    UserType.owner,\n    UserType.anon,\n    UserType.loggedin,\n    UserType.groupmember,\n    UserType.staff,\n    UserType.superuser,\n)\n\n\n@pytest.mark.parametrize(\n    \"view_policy,user_types_who_can_see\",\n    (\n        (\"private\", (UserType.owner,)),\n        (\"public\", all_user_types),\n        (\"unlisted\", all_user_types),\n        (\n            \"loggedin\",\n            (\n                UserType.owner,\n                UserType.loggedin,\n                UserType.groupmember,\n                UserType.staff,\n                UserType.superuser,\n            ),\n        ),\n        (\"group\", (UserType.owner, UserType.groupmember)),\n        (\"staff\", (UserType.owner, UserType.staff, UserType.superuser)),\n        (\"superuser\", (UserType.owner, UserType.superuser)),\n    ),\n)\ndef test_saved_dashboard_view_permissions(\n    client,\n    dashboard_db,\n    view_policy,\n    user_types_who_can_see,\n    django_user_model,\n):\n    users = {\n        UserType.owner: django_user_model.objects.create(username=\"owner\"),\n        UserType.anon: None,\n        UserType.loggedin: django_user_model.objects.create(username=\"loggedin\"),\n        UserType.groupmember: django_user_model.objects.create(username=\"groupmember\"),\n        UserType.staff: django_user_model.objects.create(\n            username=\"staff\", is_staff=True\n        ),\n        UserType.superuser: django_user_model.objects.create(\n            username=\"superuser\", is_staff=True, is_superuser=True\n        ),\n    }\n    group = Group.objects.create(name=\"view-group\")\n    users[UserType.groupmember].groups.add(group)\n    Dashboard.objects.create(\n        slug=\"dash\",\n        owned_by=users[UserType.owner],\n        view_policy=view_policy,\n        view_group=group,\n    )\n    for user_type, user in users.items():\n        if user is not None:\n            client.force_login(user)\n        else:\n            client.logout()\n        response = client.get(\"/dashboard/dash/\")\n        if user_type in user_types_who_can_see:\n            assert response.status_code == 200\n        else:\n            assert response.status_code == 403\n        if user is not None:\n            assert response[\"cache-control\"] == \"private\"\n\n\ndef test_unlisted_dashboard_has_meta_robots(client, dashboard_db):\n    dashboard = Dashboard.objects.create(slug=\"unlisted\", view_policy=\"unlisted\")\n    dashboard.queries.create(sql=\"select 11 + 34\")\n    response = client.get(\"/dashboard/unlisted/\")\n    assert response.status_code == 200\n    assert b'<meta name=\"robots\" content=\"noindex\">' in response.content\n    dashboard.view_policy = \"public\"\n    dashboard.save()\n    response2 = client.get(\"/dashboard/unlisted/\")\n    assert response2.status_code == 200\n    assert b'<meta name=\"robots\" content=\"noindex\">' not in response2.content\n\n\n@pytest.mark.parametrize(\n    \"dashboard,expected,expected_if_staff,expected_if_superuser\",\n    (\n        (\"owned_by_user\", True, True, True),\n        (\"owned_by_other_private\", False, False, False),\n        (\"owned_by_other_public\", True, True, True),\n        (\"owned_by_other_unlisted\", False, False, False),\n        (\"owned_by_other_loggedin\", True, True, True),\n        (\"owned_by_other_group_not_member\", False, False, False),\n        (\"owned_by_other_group_member\", True, True, True),\n        (\"owned_by_other_staff\", False, True, True),\n        (\"owned_by_other_superuser\", False, False, True),\n    ),\n)\ndef test_get_visible_to_user(\n    db, dashboard, expected, expected_if_staff, expected_if_superuser\n):\n    user = User.objects.create(username=\"test\")\n    other = User.objects.create(username=\"other\")\n    group_member = Group.objects.create(name=\"group_member\")\n    user.groups.add(group_member)\n    group_not_member = Group.objects.create(name=\"group_not_member\")\n    Dashboard.objects.create(slug=\"owned_by_user\", owned_by=user, view_policy=\"private\")\n    Dashboard.objects.create(\n        slug=\"owned_by_other_private\", owned_by=other, view_policy=\"private\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_public\", owned_by=other, view_policy=\"public\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_unlisted\", owned_by=other, view_policy=\"unlisted\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_loggedin\", owned_by=other, view_policy=\"loggedin\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_group_not_member\",\n        owned_by=other,\n        view_policy=\"group\",\n        view_group=group_not_member,\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_group_member\",\n        owned_by=other,\n        view_policy=\"group\",\n        view_group=group_member,\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_staff\", owned_by=other, view_policy=\"staff\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_superuser\", owned_by=other, view_policy=\"superuser\"\n    )\n    visible_dashboards = set(\n        Dashboard.get_visible_to_user(user).values_list(\"slug\", flat=True)\n    )\n    if expected:\n        assert (\n            dashboard in visible_dashboards\n        ), \"Expected user to be able to see {}\".format(dashboard)\n    else:\n        assert (\n            dashboard not in visible_dashboards\n        ), \"Expected user not to be able to see {}\".format(dashboard)\n    user.is_staff = True\n    user.save()\n    visible_dashboards = set(\n        Dashboard.get_visible_to_user(user).values_list(\"slug\", flat=True)\n    )\n    if expected_if_staff:\n        assert (\n            dashboard in visible_dashboards\n        ), \"Expected staff user to be able to see {}\".format(dashboard)\n    else:\n        assert (\n            dashboard not in visible_dashboards\n        ), \"Expected staff user not to be able to see {}\".format(dashboard)\n    user.is_superuser = True\n    user.save()\n    visible_dashboards = set(\n        Dashboard.get_visible_to_user(user).values_list(\"slug\", flat=True)\n    )\n    if expected_if_superuser:\n        assert (\n            dashboard in visible_dashboards\n        ), \"Expected super user to be able to see {}\".format(dashboard)\n    else:\n        assert (\n            dashboard not in visible_dashboards\n        ), \"Expected super user not to be able to see {}\".format(dashboard)\n\n\ndef test_get_visible_to_user_no_dupes(db):\n    owner = User.objects.create(username=\"owner\", is_staff=True)\n    group = Group.objects.create(name=\"group\")\n    for i in range(3):\n        group.user_set.add(User.objects.create(username=\"user{}\".format(i)))\n    Dashboard.objects.create(\n        owned_by=owner,\n        slug=\"example\",\n        view_policy=\"public\",\n        view_group=group,\n    )\n    dashboards = list(\n        Dashboard.get_visible_to_user(owner).values_list(\"slug\", flat=True)\n    )\n    # This used to return [\"example\", \"example\", \"example\"]\n    # Until I fixed https://github.com/simonw/django-sql-dashboard/issues/90\n    assert dashboards == [\"example\"]\n\n\n@pytest.mark.parametrize(\n    \"dashboard,expected,expected_if_staff,expected_if_superuser\",\n    (\n        (\"owned_by_user\", True, True, True),\n        (\"owned_by_other_private\", False, False, False),\n        (\"owned_by_other_loggedin\", True, True, True),\n        (\"owned_by_other_group_not_member\", False, False, False),\n        (\"owned_by_other_group_member\", True, True, True),\n        (\"owned_by_other_staff\", False, True, True),\n        (\"owned_by_other_superuser\", False, False, True),\n    ),\n)\ndef test_user_can_edit(\n    db, client, dashboard, expected, expected_if_staff, expected_if_superuser\n):\n    user = User.objects.create(username=\"test\")\n    other = User.objects.create(username=\"other\")\n    group_member = Group.objects.create(name=\"group_member\")\n    user.groups.add(group_member)\n    group_not_member = Group.objects.create(name=\"group_not_member\")\n    Dashboard.objects.create(slug=\"owned_by_user\", owned_by=user, edit_policy=\"private\")\n    Dashboard.objects.create(\n        slug=\"owned_by_other_private\", owned_by=other, edit_policy=\"private\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_loggedin\", owned_by=other, edit_policy=\"loggedin\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_group_not_member\",\n        owned_by=other,\n        edit_policy=\"group\",\n        edit_group=group_not_member,\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_group_member\",\n        owned_by=other,\n        edit_policy=\"group\",\n        edit_group=group_member,\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_staff\", owned_by=other, edit_policy=\"staff\"\n    )\n    Dashboard.objects.create(\n        slug=\"owned_by_other_superuser\", owned_by=other, edit_policy=\"superuser\"\n    )\n    dashboard_obj = Dashboard.objects.get(slug=dashboard)\n    dashboard_obj.queries.create(sql=\"select 1 + 1\")\n    assert dashboard_obj.user_can_edit(user) == expected\n    if dashboard != \"owned_by_other_staff\":\n        # This test doesn't make sense for the 'staff' one, they cannot access admin\n        # https://github.com/simonw/django-sql-dashboard/issues/44#issuecomment-835653787\n        can_edit_using_admin = can_user_edit_using_admin(client, user, dashboard_obj)\n        assert can_edit_using_admin == expected\n        if can_edit_using_admin:\n            # Check that they cannot edit the SQL queries, because they do not\n            # have the execute_sql permisssion\n            assert not user.has_perm(\"django_sql_dashboard.execute_sql\")\n            html = get_admin_change_form_html(client, user, dashboard_obj)\n            soup = BeautifulSoup(html, \"html5lib\")\n            assert soup.select(\"td.field-sql p\")[0].text == \"select 1 + 1\"\n\n    user.is_staff = True\n    user.save()\n    assert dashboard_obj.user_can_edit(user) == expected_if_staff\n    assert can_user_edit_using_admin(client, user, dashboard_obj) == expected_if_staff\n\n    # Confirm that staff user can see the correct dashboards listed\n    client.force_login(user)\n    dashboard_change_list_response = client.get(\n        \"/admin/django_sql_dashboard/dashboard/\"\n    )\n    change_list_soup = BeautifulSoup(dashboard_change_list_response.content, \"html5lib\")\n    visible_in_change_list = [\n        a.text for a in change_list_soup.select(\"th.field-slug a\")\n    ]\n    assert set(visible_in_change_list) == {\n        \"owned_by_other_staff\",\n        \"owned_by_other_group_member\",\n        \"owned_by_other_loggedin\",\n        \"owned_by_user\",\n    }\n\n    # Promote to superuser\n    user.is_superuser = True\n    user.save()\n    assert dashboard_obj.user_can_edit(user) == expected_if_superuser\n    assert can_user_edit_using_admin(client, user, dashboard_obj)\n\n\ndef get_admin_change_form_html(client, user, dashboard):\n    # Only staff can access the admin:\n    original_is_staff = user.is_staff\n    user.is_staff = True\n    user.save()\n    client.force_login(user)\n    response = client.get(dashboard.get_edit_url())\n    if not original_is_staff:\n        user.is_staff = False\n        user.save()\n    return response.content.decode(\"utf-8\")\n\n\ndef can_user_edit_using_admin(client, user, dashboard):\n    return (\n        '<input type=\"text\" name=\"title\" class=\"vTextField\" maxlength=\"128\" id=\"id_title\">'\n        in get_admin_change_form_html(client, user, dashboard)\n    )\n\n\ndef test_superuser_can_reassign_ownership(client, db):\n    user = User.objects.create(username=\"test\", is_staff=True)\n    dashboard = Dashboard.objects.create(\n        slug=\"dashboard\", owned_by=user, view_policy=\"private\", edit_policy=\"private\"\n    )\n    client.force_login(user)\n    response = client.get(dashboard.get_edit_url())\n    assert (\n        b'<div class=\"readonly\">test</div>' in response.content\n        or b'<div class=\"readonly\"><a href=\"/admin/auth/user/' in response.content\n    )\n    assert b'<input type=\"text\" name=\"owned_by\" value=\"' not in response.content\n    user.is_superuser = True\n    user.save()\n    response2 = client.get(dashboard.get_edit_url())\n    assert b'<input type=\"text\" name=\"owned_by\" value=\"' in response2.content\n\n\ndef test_no_link_to_index_on_saved_dashboard_for_logged_out_user(client, db):\n    dashboard = Dashboard.objects.create(\n        slug=\"dashboard\",\n        owned_by=User.objects.create(username=\"test\", is_staff=True),\n        view_policy=\"public\",\n    )\n    response = client.get(dashboard.get_absolute_url())\n    assert b'<a href=\"/dashboard/\">' not in response.content\n"
  },
  {
    "path": "test_project/test_docs.py",
    "content": "import pytest\nimport pathlib\nimport re\n\ndocs_dir = pathlib.Path(__file__).parent.parent / \"docs\"\nwidgets_md = (docs_dir / \"widgets.md\").read_text()\nwidget_templates_dir = (\n    pathlib.Path(__file__).parent.parent\n    / \"django_sql_dashboard\"\n    / \"templates\"\n    / \"django_sql_dashboard\"\n    / \"widgets\"\n)\nheader_re = re.compile(r\"^## (.*)\", re.M)\nheaders = [\n    bit.split(\":\")[-1].strip().replace(\", \", \"-\")\n    for bit in header_re.findall(widgets_md)\n]\n\n\n@pytest.mark.parametrize(\n    \"template\",\n    [\n        t\n        for t in widget_templates_dir.glob(\"*.html\")\n        if t.stem not in (\"default\", \"error\") and not t.stem.startswith(\"_\")\n    ],\n)\ndef test_widgets_are_documented(template):\n    assert template.stem in headers, \"Widget {} is not documented\".format(template.stem)\n"
  },
  {
    "path": "test_project/test_export.py",
    "content": "import pytest\n\n\ndef test_export_requires_setting(admin_client, dashboard_db):\n    for key in (\"export_csv_0\", \"export_tsv_0\"):\n        response = admin_client.post(\n            \"/dashboard/\",\n            {\n                \"sql\": \"SELECT 'hello' as label, * FROM generate_series(0, 10000)\",\n                key: \"1\",\n            },\n        )\n        assert response.status_code == 403\n\n\ndef test_no_export_on_saved_dashboard(\n    admin_client, dashboard_db, settings, saved_dashboard\n):\n    settings.DASHBOARD_ENABLE_FULL_EXPORT = True\n    response = admin_client.get(\"/dashboard/test/\")\n    assert response.status_code == 200\n    assert b'<pre class=\"sql\">select 22 + 55</pre>' in response.content\n    assert b\"Export all as CSV\" not in response.content\n\n\ndef test_export_csv(admin_client, dashboard_db, settings):\n    settings.DASHBOARD_ENABLE_FULL_EXPORT = True\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"SELECT 'hello' as label, * FROM generate_series(0, 10000)\",\n            \"export_csv_0\": \"1\",\n        },\n    )\n    body = b\"\".join(response.streaming_content)\n    assert body.startswith(\n        b\"label,generate_series\\r\\nhello,0\\r\\nhello,1\\r\\nhello,2\\r\\n\"\n    )\n    assert body.endswith(b\"hello,9998\\r\\nhello,9999\\r\\nhello,10000\\r\\n\")\n    assert response[\"Content-Type\"] == \"text/csv\"\n    content_disposition = response[\"Content-Disposition\"]\n    assert content_disposition.startswith(\n        'attachment; filename=\"select--hello--as-label'\n    )\n    assert content_disposition.endswith('.csv\"')\n\n\ndef test_export_tsv(admin_client, dashboard_db, settings):\n    settings.DASHBOARD_ENABLE_FULL_EXPORT = True\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"SELECT 'hello' as label, * FROM generate_series(0, 10000)\",\n            \"export_tsv_0\": \"1\",\n        },\n    )\n    body = b\"\".join(response.streaming_content)\n    assert body.startswith(\n        b\"label\\tgenerate_series\\r\\nhello\\t0\\r\\nhello\\t1\\r\\nhello\\t2\\r\\n\"\n    )\n    assert body.endswith(b\"hello\\t9998\\r\\nhello\\t9999\\r\\nhello\\t10000\\r\\n\")\n    assert response[\"Content-Type\"] == \"text/tab-separated-values\"\n    content_disposition = response[\"Content-Disposition\"]\n    assert content_disposition.startswith(\n        'attachment; filename=\"select--hello--as-label'\n    )\n    assert content_disposition.endswith('.tsv\"')\n\n\n@pytest.mark.parametrize(\"json_disabled\", (False, True))\ndef test_export_json(admin_client, saved_dashboard, settings, json_disabled):\n    if json_disabled:\n        settings.DASHBOARD_DISABLE_JSON = True\n\n    response = admin_client.get(\"/dashboard/test.json\")\n    if json_disabled:\n        assert response.status_code == 403\n        return\n    assert response.status_code == 200\n    assert response[\"Content-Type\"] == \"application/json\"\n    assert response.json() == {\n        \"title\": \"Test dashboard\",\n        \"queries\": [\n            {\"sql\": \"select 11 + 33\", \"rows\": [{\"?column?\": 44}]},\n            {\"sql\": \"select 22 + 55\", \"rows\": [{\"?column?\": 77}]},\n        ],\n    }\n"
  },
  {
    "path": "test_project/test_parameters.py",
    "content": "from urllib.parse import urlencode\n\nimport pytest\nfrom django.core import signing\n\nfrom django_sql_dashboard.models import Dashboard\nfrom django_sql_dashboard.utils import sign_sql\n\n\ndef test_parameter_form(admin_client, dashboard_db):\n    response = admin_client.get(\n        \"/dashboard/?\"\n        + urlencode(\n            {\n                \"sql\": signed_sql(\n                    [\n                        \"select %(foo)s as foo, %(bar)s as bar\",\n                        \"select select %(foo)s as foo, select %(baz)s as baz\",\n                    ]\n                )\n            },\n            doseq=True,\n        )\n    )\n    assert response.status_code == 200\n    html = response.content.decode(\"utf-8\")\n    # Form should have three form fields\n    for fragment in (\n        '<label for=\"qp1\">foo</label>',\n        '<input type=\"text\" id=\"qp1\" name=\"foo\" value=\"\">',\n        '<label for=\"qp2\">bar</label>',\n        '<input type=\"text\" id=\"qp2\" name=\"bar\" value=\"\">',\n        '<label for=\"qp3\">baz</label>',\n        '<input type=\"text\" id=\"qp3\" name=\"baz\" value=\"\">',\n    ):\n        assert fragment in html\n\n\ndef test_parameters_applied(admin_client, dashboard_db):\n    response = admin_client.get(\n        \"/dashboard/?\"\n        + urlencode(\n            {\n                \"sql\": signed_sql(\n                    [\n                        \"select %(foo)s || '!' as exclaim\",\n                        \"select %(foo)s || '! ' || %(bar)s || '!!' as double_exclaim\",\n                    ]\n                ),\n                \"foo\": \"FOO\",\n                \"bar\": \"BAR\",\n            },\n            doseq=True,\n        )\n    )\n    assert response.status_code == 200\n    html = response.content.decode(\"utf-8\")\n    assert \"<td>FOO!</td>\" in html\n    assert \"<td>FOO! BAR!!</td>\" in html\n\n\ndef signed_sql(queries):\n    return [sign_sql(sql) for sql in queries]\n"
  },
  {
    "path": "test_project/test_save_dashboard.py",
    "content": "from django_sql_dashboard.models import Dashboard\n\n\ndef test_save_dashboard(admin_client, dashboard_db):\n    assert Dashboard.objects.count() == 0\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"select 1 + 1\",\n            \"_save-slug\": \"one\",\n            \"_save-view_policy\": \"private\",\n            \"_save-edit_policy\": \"private\",\n        },\n    )\n    assert response.status_code == 302\n    # Should redirect to new dashboard\n    assert response.url == \"/dashboard/one/\"\n    dashboard = Dashboard.objects.first()\n    assert dashboard.slug == \"one\"\n    assert list(dashboard.queries.values_list(\"sql\", flat=True)) == [\"select 1 + 1\"]\n"
  },
  {
    "path": "test_project/test_utils.py",
    "content": "import pytest\n\nfrom django_sql_dashboard.utils import apply_sort, is_valid_base64_json\n\n\n@pytest.mark.parametrize(\n    \"input,expected\",\n    (\n        (\n            \"InNlbGVjdCAlKG5hbWUpcyBhcyBuYW1lLCB0b19jaGFyKGRhdGVfdHJ1bmMoJ21vbnRoJywgY3JlYXRlZCksICdZWVlZLU1NJykgYXMgYmFyX2xhYmVsLFxyXG5jb3VudCgqKSBhcyBiYXJfcXVhbnRpdHkgZnJvbSBibG9nX2VudHJ5IGdyb3VwIGJ5IGJhcl9sYWJlbCBvcmRlciBieSBjb3VudCgqKSBkZXNjIg\",\n            True,\n        ),\n        (\"InNlbGVjdCAlKG5hbWUpcyBhcyBuYW1lLCB0\", False),\n        (\"Not valid\", False),\n        (\"InNlbGVjdCAlKG5hbWUpcyBhcyBuYW1lLCB0\", False),\n    ),\n)\ndef test_is_valid_base64_json(input, expected):\n    assert is_valid_base64_json(input) == expected\n\n\n@pytest.mark.parametrize(\n    \"sql,sort_column,is_desc,expected_sql\",\n    (\n        (\n            \"select * from foo\",\n            \"bar\",\n            False,\n            'select * from (select * from foo) as results order by \"bar\"',\n        ),\n        (\n            \"select * from foo\",\n            \"bar\",\n            True,\n            'select * from (select * from foo) as results order by \"bar\" desc',\n        ),\n        (\n            'select * from (select * from foo) as results order by \"bar\" desc',\n            \"bar\",\n            False,\n            'select * from (select * from foo) as results order by \"bar\"',\n        ),\n        (\n            'select * from (select * from foo) as results order by \"bar\"',\n            \"bar\",\n            True,\n            'select * from (select * from foo) as results order by \"bar\" desc',\n        ),\n    ),\n)\ndef test_apply_sort(sql, sort_column, is_desc, expected_sql):\n    assert apply_sort(sql, sort_column, is_desc) == expected_sql\n"
  },
  {
    "path": "test_project/test_widgets.py",
    "content": "from urllib.parse import parse_qsl\n\nimport pytest\nfrom bs4 import BeautifulSoup\nfrom django.core import signing\n\nfrom django_sql_dashboard.utils import unsign_sql\n\n\ndef test_default_widget(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"\"\"\n            SELECT * FROM (\n                VALUES (1, 'one', 4.5), (2, 'two', 3.6), (3, 'three', 4.1)\n            ) AS t (id, name, size)\"\"\"\n        },\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    soup = BeautifulSoup(html, \"html5lib\")\n    assert soup.find(\"textarea\").text == (\n        \"SELECT * FROM (\\n\"\n        \"                VALUES (1, 'one', 4.5), (2, 'two', 3.6), (3, 'three', 4.1)\\n\"\n        \"            ) AS t (id, name, size)\"\n    )\n    # Copyable area:\n    assert soup.select(\"textarea#copyable-0\")[0].text == (\n        \"id\\tname\\tsize\\n\" \"1\\tone\\t4.5\\n\" \"2\\ttwo\\t3.6\\n\" \"3\\tthree\\t4.1\"\n    )\n\n\ndef test_default_widget_pretty_prints_json(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"\"\"\n            select json_build_object('hello', json_build_array(1, 2, 3)) as json\n            \"\"\"\n        },\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    soup = BeautifulSoup(html, \"html5lib\")\n    trs = soup.select(\"table tbody tr\")\n    assert str(trs[0].find(\"td\")) == (\n        '<td><pre class=\"json\">{\\n'\n        '  \"hello\": [\\n'\n        \"    1,\\n\"\n        \"    2,\\n\"\n        \"    3\\n\"\n        \"  ]\\n\"\n        \"}</pre></td>\"\n    )\n\n\n@pytest.mark.parametrize(\n    \"sql,expected\",\n    (\n        (\"SELECT * FROM generate_series(0, 5)\", \"6 rows</p>\"),\n        (\"SELECT 'hello'\", \"1 row</p>\"),\n        (\"SELECT * FROM generate_series(0, 1000)\", \"Results were truncated\"),\n    ),\n)\ndef test_default_widget_shows_row_count_or_truncated_message(\n    admin_client, dashboard_db, sql, expected\n):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": sql},\n        follow=True,\n    )\n    assert expected in response.content.decode(\"utf-8\")\n\n\ndef test_default_widget_column_count_links(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"\"\"\n            SELECT * FROM (\n                VALUES (1, %(label)s, 4.5), (2, 'two', 3.6), (3, 'three', 4.1)\n            ) AS t (id, name, size)\"\"\",\n            \"label\": \"LABEL\",\n        },\n        follow=True,\n    )\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    # Check that first link\n    th = soup.select(\"thead th\")[0]\n    assert th[\"data-count-url\"]\n    querystring = th[\"data-count-url\"].split(\"?\")[1]\n    bits = dict(parse_qsl(querystring))\n    assert unsign_sql(bits[\"sql\"])[0] == (\n        'select \"id\", count(*) as n from (SELECT * FROM (\\n'\n        \"                VALUES (1, %(label)s, 4.5), \"\n        \"(2, 'two', 3.6), (3, 'three', 4.1)\\n\"\n        \"            ) AS t (id, name, size))\"\n        ' as results group by \"id\" order by n desc'\n    )\n    assert bits[\"label\"] == \"LABEL\"\n\n\n@pytest.mark.parametrize(\n    \"sql,should_have_count_links\",\n    (\n        (\"SELECT 1 AS id, 2 AS id\", False),\n        (\"SELECT 1 AS id, 2 AS id2\", True),\n    ),\n)\ndef test_default_widget_no_count_links_for_ambiguous_columns(\n    admin_client, dashboard_db, sql, should_have_count_links\n):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": sql},\n        follow=True,\n    )\n    soup = BeautifulSoup(response.content, \"html5lib\")\n    ths_with_data_count_url = soup.select(\"th[data-count-url]\")\n    if should_have_count_links:\n        assert len(ths_with_data_count_url)\n    else:\n        assert not len(ths_with_data_count_url)\n\n\ndef test_big_number_widget(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": \"select 'Big' as label, 10801 * 5 as big_number\"},\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    assert (\n        '    <div class=\"big-number\">\\n'\n        \"      <p><strong>Big</strong></p>\\n\"\n        \"      <h1>54005</h1>\\n\"\n        \"    </div>\"\n    ) in html\n\n\n@pytest.mark.parametrize(\n    \"sql,expected\",\n    (\n        (\n            \"select '# Foo\\n\\n## Bar [link](/)' as markdown\",\n            '<h1>Foo</h1>\\n<h2>Bar <a href=\"/\" rel=\"nofollow\">link</a></h2>',\n        ),\n        (\"select null as markdown\", \"\"),\n    ),\n)\ndef test_markdown_widget(admin_client, dashboard_db, sql, expected):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": sql},\n        follow=True,\n    )\n    assert response.status_code == 200\n    html = response.content.decode(\"utf-8\")\n    assert expected in html\n\n\ndef test_html_widget(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\n            \"sql\": \"select '<h1>Hi</h1><script>alert(\\\"evil\\\")</script><p>There<br>And</p>' as markdown\"\n        },\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    assert (\n        \"<h1>Hi</h1>\\n\"\n        '&lt;script&gt;alert(\"evil\")&lt;/script&gt;\\n'\n        \"<p>There<br>And</p>\"\n    ) in html\n\n\ndef test_bar_chart_widget(admin_client, dashboard_db):\n    sql = \"\"\"\n    SELECT * FROM (\n        VALUES (1, 'one'), (2, 'two'), (3, 'three')\n    ) AS t (bar_quantity, bar_label);\n    \"\"\"\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": sql},\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    assert (\n        '<script id=\"vis-data-0\" type=\"application/json\">'\n        '[{\"bar_quantity\": 1, \"bar_label\": \"one\"}, '\n        '{\"bar_quantity\": 2, \"bar_label\": \"two\"}, '\n        '{\"bar_quantity\": 3, \"bar_label\": \"three\"}]</script>'\n    ) in html\n    assert '$schema: \"https://vega.github.io/schema/vega-lite/v5.json\"' in html\n\n\ndef test_progress_bar_widget(admin_client, dashboard_db):\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": \"select 100 as total_count, 72 as completed_count\"},\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    assert \"<h2>72 / 100: 72%</h2>\" in html\n    assert 'width: 72%\">&nbsp;</div>' in html\n\n\ndef test_word_cloud_widget(admin_client, dashboard_db):\n    sql = \"\"\"\n    select * from (\n      values ('one', 1), ('two', 2), ('three', 3)\n    ) as t (wordcloud_word, wordcloud_count);\n    \"\"\"\n    response = admin_client.post(\n        \"/dashboard/\",\n        {\"sql\": sql},\n        follow=True,\n    )\n    html = response.content.decode(\"utf-8\")\n    assert (\n        '<script id=\"wordcloud-data-0\" type=\"application/json\">[{\"wordcloud_word\"'\n        in html\n    )\n"
  },
  {
    "path": "test_project/wait-for-postgres.sh",
    "content": "#!/bin/sh\n# wait-for-postgres.sh\n\nset -e\n\nuntil psql $DATABASE_URL -c '\\q'; do\n  >&2 echo \"Postgres is unavailable - sleeping\"\n  sleep 1\ndone\n\nexec \"$@\"\n"
  }
]