[
  {
    "path": ".dockerignore",
    "content": "node_modules\n"
  },
  {
    "path": ".editorconfig",
    "content": "# http://editorconfig.org\n\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\nindent_style = space\nindent_size = 4\n\n[*.py]\nmax_line_length = 120\n\n[*.toml]\nindent_size = 2\n\n[*.yaml]\nindent_size = 2\n\n[*.yml]\nindent_size = 2\n"
  },
  {
    "path": ".eslintignore",
    "content": "/explorer/static/js/src/pivot.js\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\nname: \"CodeQL\"\n\npermissions:\n  actions: read\n  contents: read\n  security-events: write\n\non:\n  push:\n    branches:\n     - master\n     - support/3.x\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches:\n     - master\n     - support/3.x\n  schedule:\n    - cron: '0 2 * * 5'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        # Override automatic language detection by changing the below list\n        # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']\n        language: ['python', 'javascript']\n        # Learn more...\n        # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n      with:\n        # We must fetch at least the immediate parents so that if this is\n        # a pull request then we can checkout the head.\n        fetch-depth: 2\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v3\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v3\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v3\n"
  },
  {
    "path": ".github/workflows/docs.yml",
    "content": "name: Docs\n\non: [push, pull_request]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\n\njobs:\n  docs:\n    runs-on: ubuntu-latest\n    name: docs\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: 3.9\n      - run: python -m pip install -r docs/requirements.txt\n      - name: Build docs\n        run: |\n          cd docs\n          sphinx-build -b html -n -d _build/doctrees . _build/html\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "name: Ruff\n\non:\n  push:\n  pull_request:\n\njobs:\n  ruff:\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Run ruff\n      run: pipx run ruff check --output-format=github explorer\n"
  },
  {
    "path": ".github/workflows/publish-pypi.yml",
    "content": "name: Publish Python 🐍 distributions 📦 to pypi\n\non:\n  release:\n    types:\n      - published\n\njobs:\n  build-n-publish:\n    name: Build and publish Python 🐍 distributions 📦 to pypi\n    runs-on: ubuntu-latest\n    environment:\n      name: pypi\n      url: https://pypi.org/p/django-sql-explorer\n    permissions:\n      id-token: write\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v5\n      with:\n        python-version: '3.12'\n\n    - uses: actions/setup-node@v4\n      with:\n        node-version-file: '.nvmrc'\n    - name: Install dependencies\n      run: npm install\n    - name: Build client\n      run: npm run build\n\n    - name: Install pypa/build\n      run: >-\n        python -m\n        pip install\n        build\n        --user\n    - name: Build a binary wheel and a source tarball\n      run: >-\n        python -m\n        build\n        --sdist\n        --wheel\n        --outdir dist/\n        .\n\n    - name: Publish distribution 📦 to PyPI\n      if: startsWith(github.ref, 'refs/tags')\n      uses: pypa/gh-action-pypi-publish@release/v1\n"
  },
  {
    "path": ".github/workflows/publish-test.yml",
    "content": "name: Publish Python 🐍 distributions 📦 to TestPyPI\n\non:\n  push:\n    branches:\n      - master\n      - support/3.x\n\njobs:\n  build-n-publish:\n    name: Build and publish Python 🐍 distributions 📦 to TestPyPI\n    runs-on: ubuntu-latest\n    environment:\n      name: test\n      url: https://test.pypi.org/p/django-sql-explorer\n    permissions:\n      id-token: write\n    steps:\n    - uses: actions/checkout@v4\n    - name: Set up Python\n      uses: actions/setup-python@v5\n      with:\n        python-version: '3.12'\n\n    - name: Install pypa/build\n      run: >-\n        python -m\n        pip install\n        build\n        --user\n    - uses: actions/setup-node@v4\n      with:\n        node-version-file: '.nvmrc'\n    - name: Install npm dependencies\n      run: npm install\n    - name: Build client\n      run: npm run build\n\n    - name: Build a binary wheel and a source tarball\n      run: >-\n        python -m\n        build\n        --sdist\n        --wheel\n        --outdir dist/\n        .\n\n    - name: Publish distribution 📦 to Test PyPI\n      uses: pypa/gh-action-pypi-publish@release/v1\n      with:\n        repository-url: https://test.pypi.org/legacy/\n        skip_existing: true\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Tests\n\non:\n  push:\n    branches:\n     - master\n     - support/3.x\n  pull_request:\n\nconcurrency:\n  group: ${{ github.head_ref || github.run_id }}\n  cancel-in-progress: true\n\njobs:\n  tests:\n    name: Python ${{ matrix.python-version }}\n    runs-on: ubuntu-22.04\n\n    strategy:\n      matrix:\n        python-version:\n          - '3.10'\n          - '3.11'\n          - '3.12'\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: pip\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version-file: '.nvmrc'\n\n      - name: Install dependencies\n        run: |\n          npm install\n          python -m pip install --upgrade pip setuptools wheel\n          python -m pip install --upgrade 'tox>=4.0.0rc3'\n\n      - name: Build client\n        run: npm run build\n\n      - name: Run tox targets for ${{ matrix.python-version }}\n        run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)\n\n      - name: Upload coverage data\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage-data-${{ matrix.python-version }}\n          path: '.coverage*'\n          include-hidden-files: true\n\n  coverage:\n    name: Coverage\n    runs-on: ubuntu-22.04\n    needs: tests\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install dependencies\n        run: |\n          npm install\n          python -m pip install --upgrade coverage[toml]\n      - name: Build client\n        run: npm run build\n\n      - name: Download data\n        uses: actions/download-artifact@v4\n        with:\n          pattern: coverage-data-*\n          merge-multiple: true\n\n      - name: Combine coverage\n        run: |\n          python -m coverage combine\n          python -m coverage html --skip-covered --skip-empty\n          python -m coverage report\n\n      - name: Upload HTML report\n        uses: actions/upload-artifact@v4\n        with:\n          name: html-report\n          path: htmlcov\n"
  },
  {
    "path": ".gitignore",
    "content": "/.idea/\n*.pyc\n*.db\n/project/\n/dist\n*.egg-info\n.DS_Store\n/build\n*#\n*~\n.coverage*\n/htmlcov/\n*.orig\ntmp\nvenv/\n.venv/\n.tox/\nnode_modules/\nexplorer/static/\n# Sphinx documentation\ndocs/_build/\n.env\ntst\ntst2\nuser_dbs/*\ntmp2\nchinook.sqlite\nmodel_data.json\ntst1\ntst1-journal\ntst2\ncoverage-data-*\n"
  },
  {
    "path": ".nvmrc",
    "content": "20.15.1\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "ci:\n  autofix_commit_msg: |\n    ci: auto fixes from pre-commit hooks\n\n    for more information, see https://pre-commit.ci\n  autofix_prs: false\n  autoupdate_commit_msg: \"ci: pre-commit autoupdate\"\n  autoupdate_schedule: monthly\n\ndefault_language_version:\n  python: python3.12\n\nrepos:\n  - repo: https://github.com/asottile/pyupgrade\n    rev: v3.16.0\n    hooks:\n      - id: pyupgrade\n        args: [\"--py38-plus\"]\n\n  - repo: https://github.com/adamchainz/django-upgrade\n    rev: \"1.19.0\"\n    hooks:\n      - id: django-upgrade\n        args: [--target-version, \"3.2\"]\n\n  - repo: https://github.com/asottile/yesqa\n    rev: v1.5.0\n    hooks:\n      - id: yesqa\n\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.6.0\n    hooks:\n      - id: check-merge-conflict\n      - id: mixed-line-ending\n\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: \"v0.5.0\"\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n      - id: ruff-format\n\n  - repo: https://github.com/remastr/pre-commit-django-check-migrations\n    rev: v0.1.0\n    hooks:\n      - id: check-migrations-created\n        args: [--manage-path=manage.py]\n        additional_dependencies: [django==4.1]\n\n  - repo: https://github.com/rstcheck/rstcheck\n    rev: v6.2.0\n    hooks:\n      - id: rstcheck\n        additional_dependencies:\n          - sphinx==6.1.3\n          - tomli==2.0.1\n\n  - repo: https://github.com/pre-commit/mirrors-prettier\n    rev: v3.0.0\n    hooks:\n      - id: prettier\n"
  },
  {
    "path": ".readthedocs.yaml",
    "content": "# Read the Docs configuration file\n# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details\nversion: 2\n\nbuild:\n  os: ubuntu-22.04\n  tools:\n    python: \"3.11\"\n\nsphinx:\n  configuration: docs/conf.py\n  fail_on_warning: false\n\nformats:\n  - epub\n  - pdf\n\npython:\n  install:\n    - requirements: docs/requirements.txt\n"
  },
  {
    "path": "AUTHORS",
    "content": "The following people have contributed to django-sql-explorer:\n\n- Chris Clark\n- Mark Walker\n- Lee Brooks\n- Artyom Chernyakov\n- Rodney Hawkins\n- Dane Hillard\n- Wojtek Jurkowlaniec\n- Lee Kagiso\n- Phil Krylov\n- Grant McConnaughey\n- Josh Miller\n- Jens Nistler\n- Pietro Pilolli\n- David Sanders\n- Anton Shutik\n- Nick Spacek\n- Stanislav Tarazevich\n- Ming Hsien Tseng\n- Jared Proffitt\n- Brad Melin\n- Dara Adib\n- Moe Elias\n- Illia Volochii\n- Amir Abedi\n- Christian Clauss\n- Shiyan Shirani\n- Calum Smith\n- Steven Luoma\n\nA full list of contributors can be found on Github; https://github.com/explorerhq/sql-explorer/graphs/contributors\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Build stage\nFROM python:3.12.4 as builder\n\nWORKDIR /app\n\n# Install system dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    curl \\\n    build-essential \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Install Python dependencies\nCOPY requirements /app/requirements\nRUN pip install --no-cache-dir -r requirements/dev.txt\n\n# Install NVM and Node.js\nRUN mkdir /usr/local/.nvm\nENV NVM_DIR /usr/local/.nvm\n# This should match the version referenced below in the Run stage, and in entrypoint.sh\nENV NODE_VERSION 20.15.1\n\nCOPY package.json package-lock.json /app/\n\nRUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash \\\n    && . \"$NVM_DIR/nvm.sh\" \\\n    && nvm install ${NODE_VERSION} \\\n    && nvm use v${NODE_VERSION} \\\n    && nvm alias default v${NODE_VERSION} \\\n    && npm install\n\n\n# Runtime stage\nFROM python:3.12.4\n\nWORKDIR /app\n\n# Copy Python environment from builder\nCOPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages\nCOPY --from=builder /usr/local/bin /usr/local/bin\n\n# Copy Node.js environment from builder\nCOPY --from=builder /usr/local/.nvm /usr/local/.nvm\nENV NVM_DIR /usr/local/.nvm\n\n# The version in this path should match the version referenced above in the Run stage, and in entrypoint.sh\nENV PATH $NVM_DIR/versions/node/v20.15.1/bin:$PATH\n\nCOPY --from=builder /app/node_modules /app/node_modules\n\nCOPY . /app\n\n# Run migrations and create initial data\nRUN python manage.py migrate && \\\n    python manage.py shell <<ORM\nfrom django.contrib.auth.models import User\nu = User.objects.filter(username='admin').first()\nif not u:\n    u = User(username='admin')\n    u.set_password('admin')\n    u.is_superuser = True\n    u.is_staff = True\n    u.save()\n\nfrom explorer.models import Query\nqueries = Query.objects.all().count()\nif queries == 0:\n    q = Query(sql='select * from explorer_query;', title='Sample Query')\n    q.save()\nORM\n\n# Copy and set permissions for the entrypoint script\nCOPY entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\n# Expose the ports the app runs on\nEXPOSE 8000 5173\n\n# Health check\nHEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \\\n    CMD curl -f http://localhost:8000/ || exit 1\n\n# Set the entrypoint\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "HISTORY.rst",
    "content": "==========\nChange Log\n==========\n\nThis document records all notable changes to `SQL Explorer <https://github.com/explorerhq/sql-explorer>`_.\nThis project adheres to `Semantic Versioning <https://semver.org/>`_.\n\n`5.3.0`_ (2024-09-24)\n===========================\n* `#664`_: Improvements to the AI SQL Assistant:\n\n  - Table Annotations: Write persistent table annotations with descriptive information that will get injected into the\n    prompt for the assistant. For example, if a table is commonly joined to another table through a non-obvious foreign\n    key, you can tell the assistant about it in plain english, as an annotation to that table. Every time that table is\n    deemed 'relevant' to an assistant request, that annotation will be included alongside the schema and sample data.\n  - Few-Shot Examples: Using the small checkbox on the bottom-right of any saved queries, you can designate certain\n    queries as 'few shot examples\". When making an assistant request, any designated few-shot examples that reference\n    the same tables as your assistant request will get included as 'reference sql' in the prompt for the LLM.\n  - Autocomplete / multiselect when selecting tables info to send to the SQL Assistant. Much easier and more keyboard\n    focused.\n  - Relevant tables are added client-side visually, in real time, based on what's in the SQL editor and/or any tables\n    mentioned in the assistant request. The dependency on sql_metadata is therefore removed, as server-side SQL parsing\n    is no longer necessary.\n  - Ability to view Assistant request/response history.\n  - Improved system prompt that emphasizes the particular SQL dialect being used.\n  - Addresses issue #657.\n\n* `#660`_: Userspace connection migration.\n\n  - This should be an invisible change, but represents a significant refactor of how connections function. Instead of a\n    weird blend of DatabaseConnection models and underlying Django models (which were the original Explorer\n    connections), this migrates all connections to DatabaseConnection models and implements proper foreign keys to them\n    on the Query and QueryLog models. A data migration creates new DatabaseConnection models based on the configured\n    settings.EXPLORER_CONNECTIONS. Going forward, admins can create new Django-backed DatabaseConnection models by\n    registering the connection in EXPLORER_CONNECTIONS, and then creating a DatabaseConnection model using the Django\n    admin or the user-facing /connections/new/ form, and entering the Django DB alias and setting the connection type\n    to \"Django Connection\".\n  - The Query.connection and QueryLog.connection fields are deprecated and will be removed in a future release. They\n    are kept around in this release in case there is an unforeseen issue with the migration. Preserving the fields for\n    now ensures there is no data loss in the event that a rollback to an earlier version is required.\n\n* Fixed a bug when validating connections to uploaded files. Also added basic locking when downloading files from S3.\n\n* On-boarding UI; if no connections or queries are created, the UI walks the user through it a bit.\n\n* Keyboard shortcut for formatting the SQL in the editor.\n\n  - Cmd+Shift+F (Windows: Ctrl+Shift+F)\n  - The format button has been moved tobe a small icon towards the bottom-right of the SQL editor.\n\n* `#675`_ - fail gracefully when building the schema if a particular table cant be accessed by the connection\n\n`5.2.0`_ (2024-08-19)\n===========================\n* `#651`_: Ability to append an upload to a previously uploaded file/sqlite DB as a new table\n\n  * Good cache busting and detection of file changes on uploads\n  * Significant documentation improvements to uploads and connections\n  * Separate the upload UI from the 'add connection' UI, as they are materially different\n  * Fix a small bug with bar chart generation, when values are null\n  * Ability to refresh a connection's schema and data (if it's an upload) from the connections list view\n\n* `#659`_: Search all queries, even if the header is collapsed. Addresses issue #464 (partially) and #658 (fully).\n* `#662`_: Refactored dockerfile to use non-root directories. Addresses issue #661.\n\n\n`5.1.1`_ (2024-07-30)\n===========================\n* `#654`_: Bugfix: Parameterized query does not work for viewers\n* `#653`_: Bugfix: Schema search not visible anymore\n* Bugfix: Error messages in query.html were floating in the wrong spot\n* `#555`_: Prevent queries with many thousands of results from being punishingly slow. The number of data points in\n  the chart now matches the number of data points in the preview pane.\n\n`5.1.0`_ (2024-07-30)\n===========================\nMajor improvements:\n\n* `#647`_: Upload json files as data sources (in addition to CSV and SQLite files). Both 'normal'\n  json files, and files structured as a list of json objects (one json object per line) are supported.\n* `#643`_: Addresses #640 (Snowflake support). Additionally, supports an \"extras\" field on the\n  userspace DatabaseConnection object, which allows for arbitrary additional connection\n  params to get added. This allows engine-specific (or just more obscure) settings to\n  get injected into the connection.\n* `#644`_: Dockerfile and docker-compose to run the test_project. Replaces the old start.sh script.\n\nMinor improvements:\n\n* `#647`_: In the schema explorer, clicking on a field name copies it to the clipboard\n* `#647`_: Charts are limited to a maximum of 10 series. This significantly speeds up rendering\n  of 'wide' result-sets when charts are enabled.\n* `#645`_: Removed pie charts, added bar charts. Replaced Seaborn with Matplotlib\n  because it's much lighter weight. Pie charts were overly finicky to get working.\n  Bars are more useful. Will look to continue to expand charting in the future.\n* `#643`_: After uploading a csv/json/etc, the resulting connection is highlighted in the\n  connection list, making it much clearer what happened.\n* `#643`_: Fixed some bugs in user connection stuff in general, and improved the UI.\n\nBugfixes and internal improvements:\n\n* `#647`_: Robustness to the user uploads feature, in terms of the UI, error handling and logging, and test coverage.\n* `#648`_: Backwards migration for 0016_alter_explorervalue_key.py\n* `#649`_: Use a more reliable source of the static files URL\n* `#635`_: Improved test coverage in tox, so that base requirements are properly used.\n  This would have prevented (for example) issue 631. Additionally, introduced a test\n  to verify that migrations are always generated, which would have prevented #633.\n* `#636`_: Output rendering bugfix.\n* `#567`_: Upgrade translate tags in templates to more modern style.\n\n`5.0.2`_ (2024-07-3)\n===========================\n* `#633`_: Missing migration\n* CSS tweaks to tighten up the Query UI\n\n`5.0.1`_ (2024-06-26)\n===========================\n* `#631`_: Pandas is only required if EXPLORER_USER_UPLOADS_ENABLED is True\n\n`5.0.0`_ (2024-06-25)\n===========================\n\n* Manage DB connections via the UI (and/or Django Admin). Set EXPLORER_DB_CONNECTIONS_ENABLED\n  to True in settings to enable user-facing connection management.\n* Upload CSV or SQLite DBs directly, to create additional connections.\n  This functionality has additional dependencies which can be installed with\n  the 'uploads' extra (e.g. pip install django-sql-explorer[uploads]). Then set EXPLORER_USER_UPLOADS_ENABLED\n  to True, and make sure S3_BUCKET is also set up.\n* The above functionality is managed by a new license, restricting the\n  ability of 3rd parties resell SQL Explorer (commercial usage is absolutely\n  still permitted).\n* Query List home page is sortable\n* Select all / deselect all with AI assistant\n* Assistant tests run reliably in CI/CD\n* Introduced some branding and styling improvements\n\n\n`4.3.0`_ (2024-05-27)\n===========================\n\n* Keyboard shortcut to show schema hints (cmd+S / ctrl+S -- note that is a capital\n  \"S\" so the full kbd commands is cmd+shift+s)\n* DB-managed LLM prompts (editable in django admin)\n* Versioned .js bundles (for cache busting)\n* Automatically populate assistant responses that contain code into the editor\n* `#616`_: Update schema/assistant tables/autocomplete on connection drop-down change\n* `#618`_: Import models so that migrations are properly understood by Django\n* `#619`_: Get CSRF from DOM (instead of cookie) if CSRF_USE_SESSIONS is set\n\n`4.2.0`_ (2024-04-26)\n===========================\n* `#609`_: Tracking should be opt-in and not use the SECRET_KEY\n* `#610`_: Import error (sql_metadata) with 4.1 version\n* `#612`_: Accessing the database during app initialization\n* Regex-injection vulnerability\n* Improved assistant UI\n\n`4.1.0`_ (2024-04-23)\n===========================\n* SQL Assistant: Built in query help via OpenAI (or LLM of choice), with relevant schema\n  automatically injected into the prompt. Enable by setting EXPLORER_AI_API_KEY.\n* Anonymous usage telemetry. Disable by setting EXPLORER_ENABLE_ANONYMOUS_STATS to False.\n* Refactor pip requirements to make 'extras' more robust and easier to manage.\n* `#592`_: Support user models with no email fields\n* `#594`_: Eliminate <script> tags to prevent potential Content Security Policy issues.\n\n`4.0.2`_ (2024-02-06)\n===========================\n* Add support for Django 5.0. Drop support for Python < 3.10.\n* Basic code completion in the editor!\n* Front-end must be built with Vite if installing from source.\n* `#565`_: Front-end modernization. CodeMirror 6. Bootstrap5. Vite-based build\n* `#566`_: Django 5 support & tests\n* `#537`_: S3 signature version support\n* `#562`_: Record and show whether the last run of each query was successful\n* `#571`_: Replace isort and flake8 with Ruff (linting)\n\n`4.0.0.beta1`_ (2024-02-01)\n===========================\n* Yanked due to a packaging version issue\n\n`3.2.1`_ (2023-07-13)\n=====================\n* `#539`_: Test for SET PASSWORD\n* `#544`_: Fix `User` primary key reference\n\n`3.2.0`_ (2023-05-17)\n=====================\n* `#533`_: CSRF token httponly support + s3 destination for async results\n\n`3.1.1`_ (2023-02-27)\n=====================\n* `#529`_: Added ``makemigrations --check`` pre-commit hook\n* `#528`_: Add missing migration\n\n`3.1.0`_ (2023-02-25)\n=====================\n* `#520`_: Favorite queries\n* `#519`_: Add labels to params like ``$$paramName|label:defaultValue$$``\n* `#517`_: Pivot export\n\n* `#524`_: ci: pre-commit autoupdate\n* `#523`_: ci: ran pre-commit on all files for ci bot integration\n* `#522`_: ci: coverage update\n* `#521`_: ci: Adding django 4.2 to the test suite\n\n`3.0.1`_ (2022-12-16)\n=====================\n* `#515`_: Fix for running without optional packages\n\n`3.0`_ (2022-12-15)\n===================\n* Add support for Django >3.2 and drop support for <3.2\n* Add support for Python 3.9, 3.10 and 3.11 and drop support for <3.8\n* `#496`_: Document breakage of \"Format\" button due to ``CSRF_COOKIE_HTTPONLY`` (`#492`_)\n* `#497`_: Avoid execution of parameterised queries when viewing query\n* `#498`_: Change sql blacklist functionality from regex to sqlparse\n* `#500`_: Form display in popup now requires sanitize: false flag\n* `#501`_: Updated celery support\n* `#504`_: Added pre-commit hooks\n* `#505`_: Feature/more s3 providers\n* `#506`_: Check sql blacklist on execution as well as save\n* `#508`_: Conditionally import optional packages\n\n`2.5.0`_ (2022-10-09)\n=====================\n* `#494`_: Fixes Security hole in blacklist for MySQL (`#490`_)\n* `#488`_: docs: Fix a few typos\n* `#481`_: feat: Add pie and line chart tabs to query result preview\n* `#478`_: feat: Improved templates to make easier to customize (Fix `#477`_)\n\n\n`2.4.2`_ (2022-08-30)\n=====================\n* `#484`_: Added ``DEFAULT_AUTO_FIELD`` (Fix `#483`_)\n* `#475`_: Add ``SET`` to blacklisted keywords\n\n`2.4.1`_ (2022-03-10)\n=====================\n* `#471`_: Fix extra white space in description and SQL fields.\n\n`2.4.0`_ (2022-02-10)\n=====================\n* `#470`_: Upgrade JS/CSS versions.\n\n`2.3.0`_ (2021-07-24)\n=====================\n* `#450`_: Added Russian translations.\n* `#449`_: Translates expression for duration\n\n`2.2.0`_ (2021-06-14)\n=====================\n* Updated docs theme to `furo`_\n* `#445`_: Added ``EXPLORER_NO_PERMISSION_VIEW`` setting to allow override of the \"no permission\" view (Fix `#440`_)\n* `#444`_: Updated structure of the settings docs (Fix `#443`_)\n\n`2.1.3`_ (2021-05-14)\n=====================\n* `#442`_: ``GET`` params passed to the fullscreen view (Fix `#433`_)\n* `#441`_: Include BOM in CSV export (Fix `#430`_)\n\n`2.1.2`_ (2021-01-19)\n=====================\n* `#431`_: Fix for hidden SQL panel on a new query\n\n`2.1.1`_ (2021-01-19)\n=====================\nMistake in release\n\n`2.1.0`_ (2021-01-13)\n=====================\n\n* **BREAKING CHANGE**: ``request`` object now passed to ``EXPLORER_PERMISSION_CHANGE`` and ``EXPLORER_PERMISSION_VIEW`` (`#417`_ to fix `#396`_)\n\nMajor Changes\n\n* `#413`_: Static assets now served directly from the application, not CDN. (`#418`_ also)\n* `#414`_: Better blacklist checking - Fix `#371`_ and `#412`_\n* `#415`_: Fix for MySQL following change for Oracle in `#337`_\n\nMinor Changes\n\n* `#370`_: Get the CSRF cookie name from django instead of a hardcoded value\n* `#410`_ and `#416`_: Sphinx docs\n* `#420`_: Formatting change in templates\n* `#424`_: Collapsable SQL panel\n* `#425`_: Ensure a `Query` object contains SQL\n\n\n`2.0.0`_ (2020-10-09)\n=====================\n\n* **BREAKING CHANGE**: #403: Dropping support for EOL `Python 2.7 <https://www.python.org/doc/sunset-python-2/>`_ and `3.5 <https://pythoninsider.blogspot.com/2020/10/python-35-is-no-longer-supported.html>`_\n\nMajor Changes\n\n* `#404`_: Add support for Django 3.1 and drop support for (EOL) <2.2\n* `#408`_: Refactored the application, updating the URLs to use path and the views into a module\n\nMinor Changes\n\n* `#334`_: Django 2.1 support\n* `#337`_: Fix Oracle query failure caused by `TextField` in a group by clause\n* `#345`_: Added (some) Chinese translation\n* `#366`_: Changes to Travis django versions\n* `#372`_: Run queries as atomic requests\n* `#382`_: Django 2.2 support\n* `#383`_: Typo in the README\n* `#385`_: Removed deprecated `render_to_response` usage\n* `#386`_: Bump minimum django version to 2.2\n* `#387`_: Django 3 support\n* `#390`_: README formatting changes\n* `#393`_: Added option to install `XlsxWriter` as an extra package\n* `#397`_: Bump patch version of django 2.2\n* `#406`_: Show some love to the README\n* Fix `#341`_: PYC files excluded from build\n\n\n`1.1.3`_ (2019-09-23)\n=====================\n\n* `#347`_: URL-friendly parameter encoding\n* `#354`_: Updating dependency reference for Python 3 compatibility\n* `#357`_: Include database views in list of tables\n* `#359`_: Fix unicode issue when generating migration with py2 or py3\n* `#363`_: Do not use \"message\" attribute on exception\n* `#368`_: Update EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES\n\nMinor Changes\n\n* release checklist included in repo\n* readme updated with new screenshots\n* python dependencies/optional-dependencies updated to latest (six, xlsxwriter, factory-boy, sqlparse)\n\n\n`1.1.2`_ (2018-08-14)\n=====================\n\n* Fix `#269`_\n* Fix bug when deleting query\n* Fix bug when invalid characters present in Excel worksheet name\n\nMajor Changes\n\n* Django 2.0 compatibility\n* Improved interface to database connection management\n\nMinor Changes\n\n* Documentation updates\n* Load images over same protocol as originating page\n\n\n`1.1.1`_ (2017-03-21)\n=====================\n\n* Fix `#288`_ (incorrect import)\n\n\n`1.1.0`_ (2017-03-19)\n=====================\n\n* **BREAKING CHANGE**: ``EXPLORER_DATA_EXPORTERS`` setting is now a list of tuples instead of a dictionary.\n  This only affects you if you have customized this setting. This was to preserve ordering of the export buttons in the UI.\n* **BREAKING CHANGE**: Values from the database are now escaped by default. Disable this behavior (enabling potential XSS attacks)\n  with the ``EXPLORER_UNSAFE_RENDERING setting``.\n\nMajor Changes\n\n* Django 1.10 and 2.0 compatibility\n* Theming & visual updates\n* PDF export\n* Query-param based authentication (`#254`_)\n* Schema built via SQL querying rather than Django app/model introspection. Paves the way for the tool to be pointed at any DB, not just Django DBs\n\nMinor Changes\n\n* Switched from TinyS3 to Boto (will switch to Boto3 in next release)\n* Optionally show row numbers in results preview pane\n* Full-screen view (icon on top-right of preview pane)\n* Moved 'open in playground' to icon on top-right on SQL editor\n* Save-only option (does not execute query)\n* Show the time that the query was rendered (useful if you've had a tab open a while)\n\n\n`1.0.0`_ (2016-06-16)\n=====================\n\n* **BREAKING CHANGE**: Dropped support for Python 2.6. See ``.travis.yml`` for test matrix.\n* **BREAKING CHANGE**: The 'export' methods have all changed. Those these weren't originally designed to be external APIs,\n  folks have written consuming code that directly called export code.\n\n  If you had code that looked like:\n\n      ``explorer.utils.csv_report(query)``\n\n  You will now need to do something like:\n\n      ``explorer.exporters.get_exporter_class('csv')(query).get_file_output()``\n\n* There is a new export system! v1 is shipping with support for CSV, JSON, and Excel (xlsx). The availablility of these can be configured via the EXPLORER_DATA_EXPORTERS setting.\n  * `Note` that for Excel export to work, you will need to install ``xlsxwriter`` from ``optional-requirements.txt.``\n* Introduced Query History link. Find it towards the top right of a saved query.\n* Front end performance improvements and library upgrades.\n* Allow non-admins with permission to log into explorer.\n* Added a proper test_project for an easier entry-point for contributors, or folks who want to kick the tires.\n* Loads of little bugfixes.\n\n`0.9.2`_ (2016-02-02)\n=====================\n\n* Fixed readme issue (.1) and ``setup.py`` issue (.2)\n\n`0.9.1`_ (2016-02-01)\n=====================\n\nMajor changes\n\n* Dropped support for Django 1.6, added support for Django 1.9.\n  See .travis.yml for test matrix.\n* Dropped charted.js & visualization because it didn't work well.\n* Client-side pivot tables with pivot.js. This is ridiculously cool!\n\nMinor (but awesome!) changes\n\n* Cmd-/ to comment/uncomment a block of SQL\n* Quick 'shortcut' links to the corresponding querylog to more quickly share results.\n  Look at the top-right of the editor. Also works for playground!\n* Prompt for unsaved changes before navigating away\n* Support for default parameter values via $$paramName:defaultValue$$\n* Optional Celery task for truncating query logs as entries build up\n* Display historical average query runtime\n\n* Increased default number of rows from 100 to 1000\n* Increased SQL editor size (5 additional visible lines)\n* CSS cleanup and streamlining (making better use of foundation)\n* Various bugfixes (blacklist not enforced on playground being the big one)\n* Upgraded front-end libraries\n* Hide Celery-based features if tasks not enabled.\n\n`0.8.0`_ (2015-10-21)\n=====================\n\n* Snapshots! Dump the csv results of a query to S3 on a regular schedule.\n  More details in readme.rst under 'features'.\n* Async queries + email! If you have a query that takes a long time to run, execute it in the background and\n  Explorer will send you an email with the results when they are ready. More details in readme.rst\n* Run counts! Explorer inspects the query log to see how many times a query has been executed.\n* Column Statistics! Click the ... on top of numeric columns in the results pane to see min, max, avg, sum, count, and missing values.\n* Python 3! * Django 1.9!\n* Delimiters! Export with delimiters other than commas.\n* Listings respect permissions! If you've given permission to queries to non-admins,\n  they will see only those queries on the listing page.\n\n`0.7.0`_ (2015-02-18)\n=====================\n\n* Added search functionality to schema view and explorer view (using list.js).\n* Python 2.6 compatibility.\n* Basic charts via charted (from Medium via charted.co).\n* SQL formatting function.\n* Token authentication to retrieve csv version of queries.\n* Fixed south_migrations packaging issue.\n* Refactored front-end and pulled CSS and JS into dedicated files.\n\n`0.6.0`_ (2014-11-05)\n=====================\n\n* Introduced Django 1.7 migrations. See readme.rst for info on how to run South migrations if you are not on Django 1.7 yet.\n* Upgraded front-end libraries to latest versions.\n* Added ability to grant selected users view permissions on selected queries via the ``EXPLORER_USER_QUERY_VIEWS`` parameter\n* Example usage: ``EXPLORER_USER_QUERY_VIEWS = {1: [3,4], 2:[3]}``\n* This would grant user with PK 1 read-only access to query with PK=3 and PK=4 and user 2 access to query 3.\n* Bugfixes\n* Navigating to an explorer URL without the trailing slash now redirects to the intended page (e.g. ``/logs`` -> ``/logs/``)\n* Downloading a .csv and subsequently re-executing a query via a keyboard shortcut (cmd+enter) would re-submit the form and re-download the .csv. It now correctly just refreshes the query.\n* Django 1.7 compatibility fix\n\n`0.5.1`_ (2014-09-02)\n=====================\n\nBugfixes\n\n* Created_by_user not getting saved correctly\n* Content-disposition .csv issue\n* Issue with queries ending in ``...like '%...`` clauses\n* Change the way customer user model is referenced\n\n* Pseudo-folders for queries. Use \"Foo * Ba1\", \"Foo * Bar2\" for query names and the UI will build a little \"Foo\" pseudofolder for you in the query list.\n\n`0.5.0`_ (2014-06-06)\n=====================\n\n* Query logs! Accessible via ``explorer/logs/``. You can look at previously executed queries (so you don't, for instance,\n  lose that playground query you were working, or have to worry about mucking up a recorded query).\n  It's quite usable now, and could be used for versioning and reverts in the future. It can be accessed at ``explorer/logs/``\n* Actually captures the creator of the query via a ForeignKey relation, instead of just using a Char field.\n* Re-introduced type information in the schema helpers.\n* Proper relative URL handling after downloading a query as CSV.\n* Users with view permissions can use query parameters. There is potential for SQL injection here.\n  I think about the permissions as being about preventing users from borking up queries, not preventing them from viewing data.\n  You've been warned.\n* Refactored params handling for extra safety in multi-threaded environments.\n\n`0.4.1`_ (2014-02-24)\n=====================\n\n* Renaming template blocks to prevent conflicts\n\n`0.4`_ (2014-02-14 `Happy Valentine's Day!`)\n============================================\n\n* Templatized columns for easy linking\n* Additional security config options for splitting create vs. view permissions\n* Show many-to-many relation tables in schema helper\n\n`0.3`_ (2014-01-25)\n-------------------\n\n* Query execution time shown in query preview\n* Schema helper available as a sidebar in the query views\n* Better defaults for sql blacklist\n* Minor UI bug fixes\n\n`0.2`_ (2014-01-05)\n-------------------\n\n* Support for parameters\n* UI Tweaks\n* Test coverage\n\n`0.1.1`_ (2013-12-31)\n=====================\n\nBug Fixes\n\n* Proper SQL blacklist checks\n* Downloading CSV from playground\n\n`0.1`_ (2013-12-29)\n-------------------\n\nInitial Release\n\n.. _0.1: https://github.com/explorerhq/sql-explorer/tree/0.1\n.. _0.1.1: https://github.com/explorerhq/sql-explorer/compare/0.1...0.1.1\n.. _0.2: https://github.com/explorerhq/sql-explorer/compare/0.1.1...0.2\n.. _0.3: https://github.com/explorerhq/sql-explorer/compare/0.2...0.3\n.. _0.4: https://github.com/explorerhq/sql-explorer/compare/0.3...0.4\n.. _0.4.1: https://github.com/explorerhq/sql-explorer/compare/0.4...0.4.1\n.. _0.5.0: https://github.com/explorerhq/sql-explorer/compare/0.4.1...0.5.0\n.. _0.5.1: https://github.com/explorerhq/sql-explorer/compare/0.5.0...541148e7240e610f01dd0c260969c8d56e96a462\n.. _0.6.0: https://github.com/explorerhq/sql-explorer/compare/0.5.0...0.6.0\n.. _0.7.0: https://github.com/explorerhq/sql-explorer/compare/0.6.0...0.7.0\n.. _0.8.0: https://github.com/explorerhq/sql-explorer/compare/0.7.0...0.8.0\n.. _0.9.1: https://github.com/explorerhq/sql-explorer/compare/0.9.0...0.9.1\n.. _0.9.2: https://github.com/explorerhq/sql-explorer/compare/0.9.1...0.9.2\n.. _1.0.0: https://github.com/explorerhq/sql-explorer/compare/0.9.2...1.0.0\n\n.. _1.1.0: https://github.com/explorerhq/sql-explorer/compare/1.0.0...1.1.1\n.. _1.1.1: https://github.com/explorerhq/sql-explorer/compare/1.1.0...1.1.1\n.. _1.1.2: https://github.com/explorerhq/sql-explorer/compare/1.1.1...1.1.2\n.. _1.1.3: https://github.com/explorerhq/sql-explorer/compare/1.1.2...1.1.3\n.. _2.0.0: https://github.com/explorerhq/sql-explorer/compare/1.1.3...2.0\n.. _2.1.0: https://github.com/explorerhq/sql-explorer/compare/2.0...2.1.0\n.. _2.1.1: https://github.com/explorerhq/sql-explorer/compare/2.1.0...2.1.1\n.. _2.1.2: https://github.com/explorerhq/sql-explorer/compare/2.1.1...2.1.2\n.. _2.1.3: https://github.com/explorerhq/sql-explorer/compare/2.1.2...2.1.3\n.. _2.2.0: https://github.com/explorerhq/sql-explorer/compare/2.1.3...2.2.0\n.. _2.3.0: https://github.com/explorerhq/sql-explorer/compare/2.2.0...2.3.0\n.. _2.4.0: https://github.com/explorerhq/sql-explorer/compare/2.3.0...2.4.0\n.. _2.4.1: https://github.com/explorerhq/sql-explorer/compare/2.4.0...2.4.1\n.. _2.4.2: https://github.com/explorerhq/sql-explorer/compare/2.4.1...2.4.2\n.. _2.5.0: https://github.com/explorerhq/sql-explorer/compare/2.4.2...2.5.0\n.. _3.0: https://github.com/explorerhq/sql-explorer/compare/2.5.0...3.0\n.. _3.0.1: https://github.com/explorerhq/sql-explorer/compare/3.0...3.0.1\n.. _3.1.0: https://github.com/explorerhq/sql-explorer/compare/3.0.1...3.1.0\n.. _3.1.1: https://github.com/explorerhq/sql-explorer/compare/3.1.0...3.1.1\n.. _3.2.0: https://github.com/explorerhq/sql-explorer/compare/3.1.1...3.2.0\n.. _3.2.1: https://github.com/explorerhq/sql-explorer/compare/3.2.0...3.2.1\n.. _4.0.0.beta1: https://github.com/explorerhq/sql-explorer/compare/3.2.1...4.0.0.beta1\n.. _4.0.2: https://github.com/explorerhq/sql-explorer/compare/4.0.0...4.0.2\n.. _4.1.0: https://github.com/explorerhq/sql-explorer/compare/4.0.2...4.1.0\n.. _4.2.0: https://github.com/explorerhq/sql-explorer/compare/4.1.0...4.2.0\n.. _4.3.0: https://github.com/explorerhq/sql-explorer/compare/4.2.0...4.3.0\n.. _5.0.0: https://github.com/explorerhq/sql-explorer/compare/4.3.0...5.0.0\n.. _5.0.1: https://github.com/explorerhq/sql-explorer/compare/5.0.0...5.0.1\n.. _5.0.2: https://github.com/explorerhq/sql-explorer/compare/5.0.1...5.0.2\n.. _5.1.0: https://github.com/explorerhq/sql-explorer/compare/5.0.2...5.1.0\n.. _5.1.1: https://github.com/explorerhq/sql-explorer/compare/5.1.0...5.1.1\n.. _5.2.0: https://github.com/explorerhq/sql-explorer/compare/5.1.1...5.2.0\n.. _5.3b1: https://github.com/explorerhq/sql-explorer/compare/5.2.0...5.3b1\n\n\n.. _#254: https://github.com/explorerhq/sql-explorer/pull/254\n.. _#334: https://github.com/explorerhq/sql-explorer/pull/334\n.. _#337: https://github.com/explorerhq/sql-explorer/pull/337\n.. _#345: https://github.com/explorerhq/sql-explorer/pull/345\n.. _#347: https://github.com/explorerhq/sql-explorer/pull/347\n.. _#354: https://github.com/explorerhq/sql-explorer/pull/354\n.. _#357: https://github.com/explorerhq/sql-explorer/pull/357\n.. _#359: https://github.com/explorerhq/sql-explorer/pull/359\n.. _#363: https://github.com/explorerhq/sql-explorer/pull/363\n.. _#366: https://github.com/explorerhq/sql-explorer/pull/366\n.. _#368: https://github.com/explorerhq/sql-explorer/pull/368\n.. _#370: https://github.com/explorerhq/sql-explorer/pull/370\n.. _#372: https://github.com/explorerhq/sql-explorer/pull/372\n.. _#382: https://github.com/explorerhq/sql-explorer/pull/382\n.. _#383: https://github.com/explorerhq/sql-explorer/pull/383\n.. _#385: https://github.com/explorerhq/sql-explorer/pull/385\n.. _#386: https://github.com/explorerhq/sql-explorer/pull/386\n.. _#387: https://github.com/explorerhq/sql-explorer/pull/387\n.. _#390: https://github.com/explorerhq/sql-explorer/pull/390\n.. _#393: https://github.com/explorerhq/sql-explorer/pull/393\n.. _#397: https://github.com/explorerhq/sql-explorer/pull/397\n.. _#404: https://github.com/explorerhq/sql-explorer/pull/404\n.. _#406: https://github.com/explorerhq/sql-explorer/pull/406\n.. _#408: https://github.com/explorerhq/sql-explorer/pull/408\n.. _#410: https://github.com/explorerhq/sql-explorer/pull/410\n.. _#413: https://github.com/explorerhq/sql-explorer/pull/413\n.. _#414: https://github.com/explorerhq/sql-explorer/pull/414\n.. _#416: https://github.com/explorerhq/sql-explorer/pull/416\n.. _#415: https://github.com/explorerhq/sql-explorer/pull/415\n.. _#417: https://github.com/explorerhq/sql-explorer/pull/417\n.. _#418: https://github.com/explorerhq/sql-explorer/pull/418\n.. _#420: https://github.com/explorerhq/sql-explorer/pull/420\n.. _#424: https://github.com/explorerhq/sql-explorer/pull/424\n.. _#425: https://github.com/explorerhq/sql-explorer/pull/425\n.. _#441: https://github.com/explorerhq/sql-explorer/pull/441\n.. _#442: https://github.com/explorerhq/sql-explorer/pull/442\n.. _#444: https://github.com/explorerhq/sql-explorer/pull/444\n.. _#445: https://github.com/explorerhq/sql-explorer/pull/445\n.. _#449: https://github.com/explorerhq/sql-explorer/pull/449\n.. _#450: https://github.com/explorerhq/sql-explorer/pull/450\n.. _#470: https://github.com/explorerhq/sql-explorer/pull/470\n.. _#471: https://github.com/explorerhq/sql-explorer/pull/471\n.. _#475: https://github.com/explorerhq/sql-explorer/pull/475\n.. _#478: https://github.com/explorerhq/sql-explorer/pull/478\n.. _#481: https://github.com/explorerhq/sql-explorer/pull/481\n.. _#484: https://github.com/explorerhq/sql-explorer/pull/484\n.. _#488: https://github.com/explorerhq/sql-explorer/pull/488\n.. _#494: https://github.com/explorerhq/sql-explorer/pull/494\n.. _#496: https://github.com/explorerhq/sql-explorer/pull/496\n.. _#497: https://github.com/explorerhq/sql-explorer/pull/497\n.. _#498: https://github.com/explorerhq/sql-explorer/pull/498\n.. _#500: https://github.com/explorerhq/sql-explorer/pull/500\n.. _#501: https://github.com/explorerhq/sql-explorer/pull/501\n.. _#504: https://github.com/explorerhq/sql-explorer/pull/504\n.. _#505: https://github.com/explorerhq/sql-explorer/pull/505\n.. _#506: https://github.com/explorerhq/sql-explorer/pull/506\n.. _#508: https://github.com/explorerhq/sql-explorer/pull/508\n.. _#515: https://github.com/explorerhq/sql-explorer/pull/515\n.. _#517: https://github.com/explorerhq/sql-explorer/pull/517\n.. _#519: https://github.com/explorerhq/sql-explorer/pull/519\n.. _#520: https://github.com/explorerhq/sql-explorer/pull/520\n.. _#521: https://github.com/explorerhq/sql-explorer/pull/521\n.. _#522: https://github.com/explorerhq/sql-explorer/pull/522\n.. _#523: https://github.com/explorerhq/sql-explorer/pull/523\n.. _#524: https://github.com/explorerhq/sql-explorer/pull/524\n.. _#528: https://github.com/explorerhq/sql-explorer/pull/528\n.. _#529: https://github.com/explorerhq/sql-explorer/pull/529\n.. _#533: https://github.com/explorerhq/sql-explorer/pull/533\n.. _#537: https://github.com/explorerhq/sql-explorer/pull/537\n.. _#539: https://github.com/explorerhq/sql-explorer/pull/539\n.. _#544: https://github.com/explorerhq/sql-explorer/pull/544\n.. _#562: https://github.com/explorerhq/sql-explorer/pull/562\n.. _#565: https://github.com/explorerhq/sql-explorer/pull/565\n.. _#566: https://github.com/explorerhq/sql-explorer/pull/566\n.. _#571: https://github.com/explorerhq/sql-explorer/pull/571\n.. _#594: https://github.com/explorerhq/sql-explorer/pull/594\n.. _#647: https://github.com/explorerhq/sql-explorer/pull/647\n.. _#643: https://github.com/explorerhq/sql-explorer/pull/643\n.. _#644: https://github.com/explorerhq/sql-explorer/pull/644\n.. _#645: https://github.com/explorerhq/sql-explorer/pull/645\n.. _#648: https://github.com/explorerhq/sql-explorer/pull/648\n.. _#649: https://github.com/explorerhq/sql-explorer/pull/649\n.. _#635: https://github.com/explorerhq/sql-explorer/pull/635\n.. _#636: https://github.com/explorerhq/sql-explorer/pull/636\n.. _#555: https://github.com/explorerhq/sql-explorer/pull/555\n.. _#651: https://github.com/explorerhq/sql-explorer/pull/651\n.. _#659: https://github.com/explorerhq/sql-explorer/pull/659\n.. _#662: https://github.com/explorerhq/sql-explorer/pull/662\n.. _#660: https://github.com/explorerhq/sql-explorer/pull/660\n.. _#664: https://github.com/explorerhq/sql-explorer/pull/664\n\n.. _#269: https://github.com/explorerhq/sql-explorer/issues/269\n.. _#288: https://github.com/explorerhq/sql-explorer/issues/288\n.. _#341: https://github.com/explorerhq/sql-explorer/issues/341\n.. _#371: https://github.com/explorerhq/sql-explorer/issues/371\n.. _#396: https://github.com/explorerhq/sql-explorer/issues/396\n.. _#412: https://github.com/explorerhq/sql-explorer/issues/412\n.. _#430: https://github.com/explorerhq/sql-explorer/issues/430\n.. _#431: https://github.com/explorerhq/sql-explorer/issues/431\n.. _#433: https://github.com/explorerhq/sql-explorer/issues/433\n.. _#440: https://github.com/explorerhq/sql-explorer/issues/440\n.. _#443: https://github.com/explorerhq/sql-explorer/issues/443\n.. _#477: https://github.com/explorerhq/sql-explorer/issues/477\n.. _#483: https://github.com/explorerhq/sql-explorer/issues/483\n.. _#490: https://github.com/explorerhq/sql-explorer/issues/490\n.. _#492: https://github.com/explorerhq/sql-explorer/issues/492\n.. _#592: https://github.com/explorerhq/sql-explorer/issues/592\n.. _#609: https://github.com/explorerhq/sql-explorer/issues/609\n.. _#610: https://github.com/explorerhq/sql-explorer/issues/610\n.. _#612: https://github.com/explorerhq/sql-explorer/issues/612\n.. _#616: https://github.com/explorerhq/sql-explorer/issues/616\n.. _#618: https://github.com/explorerhq/sql-explorer/issues/618\n.. _#619: https://github.com/explorerhq/sql-explorer/issues/619\n.. _#631: https://github.com/explorerhq/sql-explorer/issues/631\n.. _#633: https://github.com/explorerhq/sql-explorer/issues/633\n.. _#567: https://github.com/explorerhq/sql-explorer/issues/567\n.. _#654: https://github.com/explorerhq/sql-explorer/issues/654\n.. _#653: https://github.com/explorerhq/sql-explorer/issues/653\n.. _#675: https://github.com/explorerhq/sql-explorer/issues/675\n\n.. _furo: https://github.com/pradyunsg/furo\n"
  },
  {
    "path": "LICENSE",
    "content": "* All content that resides under the \"explorer/ee/\" directory of this repository is licensed under the license defined\nin \"explorer/ee/LICENSE\".\n\n* Content outside of the above mentioned directory is provided under the \"MIT\" license as defined below.\n\n** The MIT License (MIT) **\n\nCopyright (c) 2024, SQL Explorer, Inc\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "recursive-include explorer *\nrecursive-exclude * *.pyc __pycache__ .DS_Store\nrecursive-include requirements *\ninclude package.json\ninclude vite.config.mjs\ninclude README.rst\n"
  },
  {
    "path": "README.rst",
    "content": ".. image:: https://readthedocs.org/projects/django-sql-explorer/badge/?version=latest\n   :target: https://django-sql-explorer.readthedocs.io/en/latest/?badge=latest\n   :alt: Documentation Status\n\n.. image:: http://img.shields.io/pypi/v/django-sql-explorer.svg?style=flat-square\n    :target: https://pypi.python.org/pypi/django-sql-explorer/\n    :alt: Latest Version\n\n.. image:: http://img.shields.io/pypi/dm/django-sql-explorer.svg?style=flat-square\n    :target: https://pypi.python.org/pypi/django-sql-explorer/\n    :alt: Downloads\n\n.. image:: http://img.shields.io/pypi/l/django-sql-explorer.svg?style=flat-square\n    :target: https://pypi.python.org/pypi/django-sql-explorer/\n    :alt: License\n\nSQL Explorer\n============\n\n* `Official Website <https://www.sqlexplorer.io/>`_\n* `Live Demo <https://demo.sqlexplorer.io/>`_\n* `Documentation <https://django-sql-explorer.readthedocs.io/en/latest/>`_\n\nVideo Tour\n----------\n\n.. |inline-image| image:: https://sql-explorer.s3.amazonaws.com/video-thumbnail.png\n   :target: https://sql-explorer.s3.amazonaws.com/Sql+Explorer+5.mp4\n   :height: 10em\n\n|inline-image|\n\nQuick Start\n-----------\n\nIncluded is a complete test project that you can use to kick the tires.\n\n1. Run ``docker compose up``\n2. Navigate to 127.0.0.1:8000/explorer/\n3. log in with admin/admin\n4. Begin exploring!\n\nThis will also run a Vite dev server with hot reloading for front-end changes.\n\nAbout\n-----\n\nSQL Explorer aims to make the flow of data between people fast,\nsimple, and confusion-free. It is a Django-based application that you\ncan add to an existing Django site, or use as a standalone business\nintelligence tool. It will happily connect to any SQL database that\n`Django supports <https://docs.djangoproject.com/en/5.0/ref/databases/>`_\nas well as user-uploaded CSV, JSON, or SQLite databases.\n\nQuickly write and share SQL queries in a simple, usable SQL editor,\nview the results in the browser, and keep the information flowing.\n\nAdd an OpenAI (or other provider) API key and get an LLM-powered\nSQL assistant that can help write and debug queries. The assistant\nwill automatically add relevant context and schema into the underlying\nLLM prompt.\n\nSQL Explorer values simplicity, intuitive use, unobtrusiveness,\nstability, and the principle of least surprise. The project is MIT\nlicensed, and pull requests are welcome.\n\nSome key features include:\n\n- Support for multiple connections, admin configured or user-provided.\n- Users can upload and immediately query JSON or CSV files.\n- AI-powered SQL assistant\n- Quick access to schema information to make querying easier\n  (including autocomplete)\n- Ability to snapshot queries on a regular schedule, capturing changing data\n- Query history and logs\n- Quick in-browser statistics, pivot tables, and scatter-plots (saving\n  a trip to Excel for simple analyses)\n- Parameterized queries that automatically generate a friendly UI for\n  users who don't know SQL\n- A playground area for quickly running ad-hoc queries\n- Send query results via email\n- Saved queries can be exposed as a quick-n-dirty JSON API if desired\n- ...and more!\n\nScreenshots\n-----------\n\n**Writing a query and viewing the schema helper**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-query-with-schema.png\n\n------------------\n\n**Using the SQL AI Assistant**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-assistant.png\n\n------------------\n\n**Viewing all queries**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-query-list.png\n\n------------------\n\n**Query results w/ stats summary**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-query-results.png\n\n------------------\n\n**Pivot in browser**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-pivot.png\n\n------------------\n\n**View logs**\n\n.. image:: https://sql-explorer.s3.amazonaws.com/5.0-querylogs.png\n\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  web:\n    build: .\n    command: [\"python\", \"manage.py\", \"runserver\", \"0.0.0.0:8000\"]\n    ports:\n      - \"8000:8000\"\n      - \"5173:5173\"\n    volumes:\n      - .:/app\n      - node_modules:/app/node_modules\n    environment:\n      - DJANGO_SETTINGS_MODULE=test_project.settings\n\nvolumes:\n  node_modules:\n"
  },
  {
    "path": "docs/Makefile",
    "content": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nPAPER         =\nBUILDDIR      = _build\n\n# User-friendly check for sphinx-build\nifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)\n$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)\nendif\n\n# Internal variables.\nPAPEROPT_a4     = -D latex_paper_size=a4\nPAPEROPT_letter = -D latex_paper_size=letter\nALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n# the i18n builder cannot share the environment and doctrees with the others\nI18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n\n.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext\n\nhelp:\n\t@echo \"Please use \\`make <target>' where <target> is one of\"\n\t@echo \"  html       to make standalone HTML files\"\n\t@echo \"  dirhtml    to make HTML files named index.html in directories\"\n\t@echo \"  singlehtml to make a single large HTML file\"\n\t@echo \"  pickle     to make pickle files\"\n\t@echo \"  json       to make JSON files\"\n\t@echo \"  htmlhelp   to make HTML files and a HTML help project\"\n\t@echo \"  qthelp     to make HTML files and a qthelp project\"\n\t@echo \"  devhelp    to make HTML files and a Devhelp project\"\n\t@echo \"  epub       to make an epub\"\n\t@echo \"  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter\"\n\t@echo \"  latexpdf   to make LaTeX files and run them through pdflatex\"\n\t@echo \"  latexpdfja to make LaTeX files and run them through platex/dvipdfmx\"\n\t@echo \"  text       to make text files\"\n\t@echo \"  man        to make manual pages\"\n\t@echo \"  texinfo    to make Texinfo files\"\n\t@echo \"  info       to make Texinfo files and run them through makeinfo\"\n\t@echo \"  gettext    to make PO message catalogs\"\n\t@echo \"  changes    to make an overview of all changed/added/deprecated items\"\n\t@echo \"  xml        to make Docutils-native XML files\"\n\t@echo \"  pseudoxml  to make pseudoxml-XML files for display purposes\"\n\t@echo \"  linkcheck  to check all external links for integrity\"\n\t@echo \"  doctest    to run all doctests embedded in the documentation (if enabled)\"\n\nclean:\n\trm -rf $(BUILDDIR)/*\n\nhtml:\n\t$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/html.\"\n\ndirhtml:\n\t$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/dirhtml.\"\n\nsinglehtml:\n\t$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml\n\t@echo\n\t@echo \"Build finished. The HTML page is in $(BUILDDIR)/singlehtml.\"\n\npickle:\n\t$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle\n\t@echo\n\t@echo \"Build finished; now you can process the pickle files.\"\n\njson:\n\t$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json\n\t@echo\n\t@echo \"Build finished; now you can process the JSON files.\"\n\nhtmlhelp:\n\t$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp\n\t@echo\n\t@echo \"Build finished; now you can run HTML Help Workshop with the\" \\\n\t      \".hhp project file in $(BUILDDIR)/htmlhelp.\"\n\nqthelp:\n\t$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp\n\t@echo\n\t@echo \"Build finished; now you can run \"qcollectiongenerator\" with the\" \\\n\t      \".qhcp project file in $(BUILDDIR)/qthelp, like this:\"\n\t@echo \"# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoSQLExplorer.qhcp\"\n\t@echo \"To view the help file:\"\n\t@echo \"# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoSQLExplorer.qhc\"\n\ndevhelp:\n\t$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp\n\t@echo\n\t@echo \"Build finished.\"\n\t@echo \"To view the help file:\"\n\t@echo \"# mkdir -p $$HOME/.local/share/devhelp/DjangoSQLExplorer\"\n\t@echo \"# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoSQLExplorer\"\n\t@echo \"# devhelp\"\n\nepub:\n\t$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub\n\t@echo\n\t@echo \"Build finished. The epub file is in $(BUILDDIR)/epub.\"\n\nlatex:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo\n\t@echo \"Build finished; the LaTeX files are in $(BUILDDIR)/latex.\"\n\t@echo \"Run \\`make' in that directory to run these through (pdf)latex\" \\\n\t      \"(use \\`make latexpdf' here to do that automatically).\"\n\nlatexpdf:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo \"Running LaTeX files through pdflatex...\"\n\t$(MAKE) -C $(BUILDDIR)/latex all-pdf\n\t@echo \"pdflatex finished; the PDF files are in $(BUILDDIR)/latex.\"\n\nlatexpdfja:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo \"Running LaTeX files through platex and dvipdfmx...\"\n\t$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja\n\t@echo \"pdflatex finished; the PDF files are in $(BUILDDIR)/latex.\"\n\ntext:\n\t$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text\n\t@echo\n\t@echo \"Build finished. The text files are in $(BUILDDIR)/text.\"\n\nman:\n\t$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man\n\t@echo\n\t@echo \"Build finished. The manual pages are in $(BUILDDIR)/man.\"\n\ntexinfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo\n\t@echo \"Build finished. The Texinfo files are in $(BUILDDIR)/texinfo.\"\n\t@echo \"Run \\`make' in that directory to run these through makeinfo\" \\\n\t      \"(use \\`make info' here to do that automatically).\"\n\ninfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo \"Running Texinfo files through makeinfo...\"\n\tmake -C $(BUILDDIR)/texinfo info\n\t@echo \"makeinfo finished; the Info files are in $(BUILDDIR)/texinfo.\"\n\ngettext:\n\t$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale\n\t@echo\n\t@echo \"Build finished. The message catalogs are in $(BUILDDIR)/locale.\"\n\nchanges:\n\t$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes\n\t@echo\n\t@echo \"The overview file is in $(BUILDDIR)/changes.\"\n\nlinkcheck:\n\t$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck\n\t@echo\n\t@echo \"Link check complete; look for any errors in the above output \" \\\n\t      \"or in $(BUILDDIR)/linkcheck/output.txt.\"\n\ndoctest:\n\t$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest\n\t@echo \"Testing of doctests in the sources finished, look at the \" \\\n\t      \"results in $(BUILDDIR)/doctest/output.txt.\"\n\nxml:\n\t$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml\n\t@echo\n\t@echo \"Build finished. The XML files are in $(BUILDDIR)/xml.\"\n\npseudoxml:\n\t$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml\n\t@echo\n\t@echo \"Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml.\"\n"
  },
  {
    "path": "docs/_static/.directory",
    "content": ""
  },
  {
    "path": "docs/_templates/.directory",
    "content": ""
  },
  {
    "path": "docs/conf.py",
    "content": "# Configuration file for the Sphinx documentation builder.\n#\n# This file only contains a selection of the most common options. For a full\n# list see the documentation:\n# https://www.sphinx-doc.org/en/master/usage/configuration.html\n\n# -- Path setup --------------------------------------------------------------\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#\nimport datetime\nimport os\nimport sys\n\nimport explorer\n\n\nsys.path.insert(0, os.path.abspath(\"../\"))\nsys.path.insert(0, os.path.abspath(\".\"))\n\n# -- Project information -----------------------------------------------------\ncurrent_year = datetime.datetime.now().year\nproject = \"Django SQL Explorer\"\ncopyright = f\"2016-{current_year}, SQL Explorer\"\nauthor = \"Chris Clark\"\n\nversion = explorer.get_version(True)\n# The full version, including alpha/beta/rc tags.\nrelease = explorer.__version__\n\n# -- General configuration ---------------------------------------------------\n\nmaster_doc = \"index\"\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 = [\n    \"sphinx_copybutton\",\n    \"sphinxext.opengraph\",\n    \"sphinx.ext.autodoc\",\n]\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\n# This pattern also affects html_static_path and html_extra_path.\nexclude_patterns = [\"_build\", \"Thumbs.db\", \".DS_Store\"]\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 = \"furo\"\nhtml_theme_options = {\n    \"navigation_with_keys\": True,\n}\npygments_style = \"sphinx\"\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"
  },
  {
    "path": "docs/dependencies.rst",
    "content": "Dependencies\n============\n\nAn effort has been made to keep the number of dependencies to a\nminimum.\n\nPython\n------\n\n============================================================ ======= ================\nName                                                         Version License\n============================================================ ======= ================\n`sqlparse <https://github.com/andialbrecht/sqlparse/>`_      0.4.0   BSD\n`requests <https://requests.readthedocs.io/en/latest/>`_     2.2.0   Apache 2.0\n============================================================ ======= ================\n\n- sqlparse is used for SQL formatting\n- requests is used for anonymous usage tracking\n\n**Python - Optional Dependencies**\n\n====================================================================  ===========  =============\nName                                                                    Version      License\n====================================================================  ===========  =============\n`celery <http://www.celeryproject.org/>`_                              >=3.1,<4      BSD\n`django-celery <http://www.celeryproject.org/>`_                       >=3.3.1       BSD\n`Factory Boy <https://github.com/rbarrois/factory_boy>`_               >=3.1.0       MIT\n`xlsxwriter <http://xlsxwriter.readthedocs.io/>`_                      >=1.3.6       BSD\n`boto <https://github.com/boto/boto>`_                                 >=2.49        MIT\n====================================================================  ===========  =============\n\n- Factory Boy is required for tests\n- celery is required for the 'email' feature, and for snapshots\n- boto is required for snapshots\n- xlsxwriter is required for Excel export (csv still works fine without it)\n\nJavaScript & CSS\n----------------\n\nPlease see package.json for the full list of JavaScript dependencies.\n\nVite builds the JS and CSS bundles for SQL Explorer.\nThe bundle for the SQL editor is fairly large at ~400kb, due primarily to CodeMirror. There is opportunity to reduce this by removing jQuery, which we hope to do in a future release.\n\nThe built front-end files are distributed in the PyPi release (and will be found by collectstatic). Instructions for building the front-end files are in :doc:`install`.\n\n"
  },
  {
    "path": "docs/development.rst",
    "content": "Running Locally (quick start)\n-----------------------------\n\nWhether you have cloned the repo, or installed via pip, included is a test_project that you can use to kick the tires.\n\nRun:\n\n``docker compose up``\n\nYou can now navigate to 127.0.0.1:8000/explorer/, log in with admin/admin, and begin exploring!\n\nInstalling From Source\n----------------------\n\nIf you want to install SQL Explorer from source (e.g. not from the built PyPi package),\ninto an existing project, you can do so by cloning the repository and following the usual\n:doc:`install` instructions, and then additionally building the front-end dependencies:\n\n::\n\n    nvm install\n    nvm use\n    npm install\n    npm run build\n\nThe front-end assets will be built and placed in the /static/ folder\nand collected properly by your Django installation during the `collect static`\nphase. Copy the /explorer directory into site-packages and you're ready to go.\n\nTests\n-----\n\nInstall the dev requirements:\n\n``pip install -r requirements/dev.txt``\n\nAnd then:\n\n``python manage.py test --settings=explorer.tests.settings``\n\nOr with coverage:\n\n``coverage run --source='.' manage.py test --settings=explorer.tests.settings``\n``coverage combine``\n``coverage report``\n"
  },
  {
    "path": "docs/features.rst",
    "content": "Features\n========\n\nSQL Assistant\n-------------\n- Built in integration with OpenAI (or the LLM of your choosing)\n  to quickly get help with your query, with relevant schema\n  automatically injected into the prompt.\n- The assistant tries hard to get relevant context into the prompt to the LLM, alongside your explicit request. You\n  can choose tables to include explicitly (and any tables you are reference in your SQL you will see get included as\n  well). When a table is \"included\", the prompt will include the schema of the table, 3 sample rows, any Table\n  Annotations you have added, and any designated \"few shot examples\". More on each of those below.\n- Table Annotations: Write persistent table annotations with descriptive information that will get injected into the\n  prompt for the assistant. For example, if a table is commonly joined to another table through a non-obvious foreign\n  key, you can tell the assistant about it in plain english, as an annotation to that table. Every time that table is\n  deemed 'relevant' to an assistant request, that annotation will be included alongside the schema and sample data.\n- Few-shot examples: Using the small checkbox on the bottom-right of any saved query, you can designate queries as\n  \"Assistant Examples\". When making an assistant request, the 'included tables' are intersected with tables referenced\n  by designated Example queries, and those queries are injected into the prompt, and the LLM is told that that these\n  are good reference queries.\n\nDatabase Support\n----------------\n- Supports MySql, postgres (and, by extension, pg-connection-compatible DBs like Redshift), SQLite,\n  Oracle, MS SQL Server, MariaDB, and Snowflake\n- Note for Snowflake or SQL Server, you will need to install the relevant Django connection package\n  (e.g. https://pypi.org/project/django-snowflake/, https://github.com/microsoft/mssql-django)\n- Also supports ad-hoc data sources by uploading JSON, CSV, or SQLite files directly.\n\nSnapshots\n---------\n- Tick the 'snapshot' box on a query, and Explorer will upload a\n  .csv snapshot of the query results to S3. Configure the snapshot\n  frequency via a celery cron task, e.g. for daily at 1am\n  (see test_project/celery_config.py for an example of this, along with test_project/__init__.py):\n\n.. code-block:: python\n\n    app.conf.beat_schedule = {\n       \"explorer.tasks.snapshot_queries\": {\n            \"task\": \"explorer.tasks.snapshot_queries\",\n            \"schedule\": crontab(hour=\"1\", minute=\"0\")\n        },\n    }\n\n- Requires celery, obviously. Also uses boto3. All\n  of these deps are optional and can be installed with\n  ``pip install \"django-sql-explorer[snapshots]\"``\n- The checkbox for opting a query into a snapshot is ALL THE WAY\n  on the bottom of the query view (underneath the results table).\n- You must also have the setting ``EXPLORER_TASKS_ENABLED`` enabled.\n\nEmail query results\n-------------------\n- Click the email icon in the query listing view, enter an email\n  address, and the query results (zipped .csv) will be sent to you\n  asynchronously. Very handy for long-running queries.\n- You must also have the setting ``EXPLORER_TASKS_ENABLED`` enabled.\n\nParameterized Queries\n---------------------\n- Use $$foo$$ in your queries and Explorer will build a UI to fill\n  out parameters. When viewing a query like ``SELECT * FROM table\n  WHERE id=$$id$$``, Explorer will generate UI for the ``id``\n  parameter.\n- Parameters are stashed in the URL, so you can share links to\n  parameterized queries with colleagues\n- Use ``$$paramName:defaultValue$$`` to provide default values for the\n  parameters.\n- Use ``$$paramName|label$$`` to add a label (e.g. \"User ID\") to the\n  parameter.\n- You can combine both a default and label to your parameter but you must\n  start with the label: ``$$paramName|label:defaultValue$$``.\n\nSchema Helper\n-------------\n- ``/explorer/schema/<connection-alias>`` renders a list of your table\n  and column names + types that you can refer to while writing\n  queries. Apps can be excluded from this list so users aren't\n  bogged down with tons of irrelevant tables. See settings\n  documentation below for details.\n- Autocomplete for table and column names in the Codemirror SQL editor\n- This is available quickly as a sidebar helper while composing\n  queries (see screenshot)\n- Quick search for the tables you are looking for. Just start\n  typing!\n- Explorer uses Django DB introspection to generate the\n  schema. This can sometimes be slow, as it issues a separate\n  query for each table it introspects. Therefore, once generated,\n  Explorer caches the schema information. There is also the option\n  to generate the schema information asynchronously, via Celery. To\n  enable this, make sure Celery is installed and configured, and\n  set ``EXPLORER_ENABLE_TASKS`` and ``EXPLORER_ASYNC_SCHEMA`` to\n  ``True``.\n\nTemplate Columns\n----------------\n- Let's say you have a query like ``SELECT id, email FROM user`` and\n  you'd like to quickly drill through to the profile page for each\n  user in the result. You can create a ``template`` column to do\n  just that.\n- Just set up a template column in your settings file:\n\n.. code-block:: python\n\n   EXPLORER_TRANSFORMS = [\n       ('user', '<a href=\"https://yoursite.com/profile/{0}/\">{0}</a>')\n   ]\n\n- And change your query to ``SELECT id AS \"user\", email FROM\n  user``. Explorer will match the ``user`` column alias to the\n  transform and merge each cell in that column into the template\n  string. `Cool!`\n- Note you **must** set ``EXPLORER_UNSAFE_RENDERING`` to ``True`` if you\n  want to see rendered HTML (vs string literals) in the output.\n  This will globally un-escape query results in the preview pane. E.g.\n  any queries that return HTML will render as HTML in the preview pane.\n  This could have cross-site scripting implications if you don't trust\n  the data source you are querying.\n\nPivot Table\n-----------\n- Go to the Pivot tab on query results to use the in-browser pivot\n  functionality (provided by Pivottable JS).\n- Hit the link icon on the top right to get a URL to recreate the\n  exact pivot setup to share with colleagues.\n- Download the pivot view as a CSV.\n\nDisplaying query results as charts\n----------------------------------\n\nIf the results table has numeric columns, they can be displayed in a bar chart. The first column will always be used\nas the x-axis labels. This is quite basic, but can be useful for quick visualization. Charts (if enabled) will render\nfor query results with ten or fewer numeric columns. With more series than that, the charts become a hot mess quickly.\n\nTo enable this feature, set ``EXPLORER_CHARTS_ENABLED`` setting to ``True`` and install the plotting library\n``matplotlib`` with:\n\n.. code-block:: console\n\n   pip install \"django-sql-explorer[charts]\"\n\nThis will add the \"Line chart\" and \"Bar chart\" tabs alongside the \"Preview\" and the \"Pivot\" tabs in the query results\nview.\n\nQuery Logs\n----------\n- Explorer will save a snapshot of every query you execute so you\n  can recover lost ad-hoc queries, and see what you've been\n  querying.\n- This also serves as cheap-and-dirty versioning of Queries, and\n  provides the 'run count' property and average duration in\n  milliseconds, by aggregating the logs.\n- You can also quickly share playground queries by copying the\n  link to the playground's query log record -- look on the top\n  right of the sql editor for the link icon.\n- If Explorer gets a lot of use, the logs can get\n  beefy. explorer.tasks contains the 'truncate_querylogs' task\n  that will remove log entries older than <days> (30 days and\n  older in the example below).\n\n.. code-block:: python\n\n   app.conf.beat_schedule = {\n       \"explorer.tasks.truncate_querylogs\": {\n           \"task\": \"explorer.tasks.truncate_querylogs\",\n           \"schedule\": crontab(hour=\"1\", minute=\"10\"),\n           \"kwargs\": {\"days\": 30}\n       }\n   }\n\nMultiple Connections\n--------------------\n- Have data in more than one database? No problemo. Just set up\n  multiple Django database connections, register them with\n  Explorer, and you can write, save, and view queries against all\n  of your different data sources. Compatible with any database\n  support by Django. Note that the target database does *not* have\n  to contain any Django schema, or be related to Django in any\n  way. See connections.py for more documentation on\n  multi-connection setup.\n- SQL Explorer also supports user-provided connections in the form\n  of standard database connection details, or uploading CSV, JSON or SQLite\n  files.\n\nFile Uploads\n------------\n\nUpload CSV or JSON files, or SQLite databases to immediately create connections for querying.\n\n**How it works**\n\n1. Your file is uploaded to the web server. For CSV files, the first row is assumed to be a header.\n2. It is read into a Pandas dataframe. Many fields end up as strings that are in fact numeric or datetimes.\n3. During this step, if it is a json file, the json is 'normalized'. E.g. nested objects are flattened.\n4. A customer parser runs type-detection on each column for richer typer information.\n5. The dataframe is coerced to these more accurate types.\n6. The dataframe is written to a SQLite file, which is present on the server, and uploaded to S3.\n7. The SQLite database file will be named <filename>_<userid>.db to prevent conflicts if different users uploaded files\n   with the same name.\n8. The SQLite database is added as a new connection to SQL Explorer and is available for querying just like any\n   other data source.\n9. If the SQLite file is not available locally, it will be pulled on-demand from S3 to the app server when needed.\n10. Local SQLite files are periodically cleaned up by a recurring task after (by default) 7 days of inactivity.\n\nNote that if the upload is a SQLite database, steps 2-5 are skipped and the database is simply uploaded to S3 and made\navailable for querying.\n\n**Adding tables to uploads**\n\nYou can also append uploaded files to previously uploaded data sources. For example, if you had a\n'customers.csv' file and an 'orders.csv' file, you could upload customers.csv and create a new data source. You can\nthen go back and upload orders.csv with the 'Append' drop-down set to your newly-created customers database, and you\nwill have a resulting SQLite database connection with both tables available to be queried together. If you were to\nupload a new 'orders.csv' and append it to customers, the table 'orders' would be *fully replaced* with the new file.\n\n**File formats**\n\n- Supports well-formed .csv, and .json files. Also supports .json files where each line of the file is a separate json\n  object. See /explorer/tests/json/ in the source for examples of what is supported.\n- Supports SQLite files with a .db or .sqlite extension. The validity of the SQLite file is not fully checked until\n  a query is attempted.\n\n**Configuration**\n\n- See the 'User uploads' section of :doc:`settings` for configuration details.\n\nPower tips\n----------\n- On the query listing page, focus gets set to a search box so you\n  can just navigate to ``/explorer`` and start typing the name of your\n  query to find it.\n- Quick search also works after hitting \"Show Schema\" on a query\n  view.\n- Command+Enter and Ctrl+Enter will execute a query when typing in\n  the SQL editor area.\n- Cmd+Shift+F (Windows: Ctrl+Shift+F) to format the SQL in the editor.\n- Use the Query Logs feature to share one-time queries that aren't\n  worth creating a persistent query for. Just run your SQL in the\n  playground, then navigate to ``/logs`` and share the link\n  (e.g. ``/explorer/play/?querylog_id=2428``)\n- Click the 'history' link towards the top-right of a saved query\n  to filter the logs down to changes to just that query.\n- If you need to download a query as something other than csv but\n  don't want to globally change delimiters via\n  ``settings.EXPLORER_CSV_DELIMETER``, you can use\n  ``/query/download?delim=|`` to get a pipe (or whatever) delimited\n  file. For a tab-delimited file, use ``delim=tab``. Note that the\n  file extension will remain .csv\n- If a query is taking a long time to run (perhaps timing out) and\n  you want to get in there to optimize it, go to\n  ``/query/123/?show=0``. You'll see the normal query detail page, but\n  the query won't execute.\n- Set env vars for ``EXPLORER_TOKEN_AUTH_ENABLED=TRUE`` and\n  ``EXPLORER_TOKEN=<SOME TOKEN>`` and you have an instant data\n  API. Just:\n\n.. code-block:: console\n\n   curl --header \"X-API-TOKEN: <TOKEN>\" https://www.your-site.com/explorer/<QUERY_ID>/stream?format=csv\n\nYou can also pass the token with a query parameter like this:\n\n.. code-block:: console\n\n   curl https://www.your-site.com/explorer/<QUERY_ID>/stream?format=csv&token=<TOKEN>\n\n\nSecurity\n--------\n- It's recommended you setup read-only roles for each of your database\n  connections and only use these particular connections for your queries\n  through the ``EXPLORER_CONNECTIONS`` setting -- or set up userland\n  connections via DatabaseConnections in the Django admin, or the SQL\n  Explorer front-end.\n- SQL Explorer supports three different permission checks for users of\n  the tool. Users passing the ``EXPLORER_PERMISSION_CHANGE`` test can\n  create, edit, delete, and execute queries. Users who do not pass\n  this test but pass the ``EXPLORER_PERMISSION_VIEW`` test can only\n  execute queries. Other users cannot access any part of\n  SQL Explorer. Both permission groups are set to is_staff by default\n  and can be overridden in your settings file. Lastly, the permission\n  ``EXPLORER_PERMISSION_CONNECTIONS`` controls which users can manage\n  connections via the UI (if enabled). This is also set to is_staff by\n  default.\n- Enforces a SQL blacklist so destructive queries don't get\n  executed (delete, drop, alter, update etc). This is not\n  a substitute for using a readonly connection -- but is better\n  than nothing for certain use cases where a readonly connection\n  may not be available.\n"
  },
  {
    "path": "docs/history.rst",
    "content": ".. include:: ../HISTORY.rst\n"
  },
  {
    "path": "docs/index.rst",
    "content": ".. rstcheck: ignore-next-code-block\n.. Django SQL Explorer documentation master file, created by\n   sphinx-quickstart on Thu Oct 15 17:39:19 2020.\n   You can adapt this file completely to your liking, but it should at least\n   contain the root `toctree` directive.\n\n###################\nDjango SQL Explorer\n###################\n\n.. include:: ../README.rst\n\n.. toctree::\n   :maxdepth: 2\n   :caption: Contents:\n\n   features.rst\n   install.rst\n   development.rst\n   settings.rst\n   dependencies.rst\n   history.rst\n"
  },
  {
    "path": "docs/install.rst",
    "content": "Install\n=======\n\n* Requires Python 3.10 or higher.\n* Requires Django 3.2 or higher.\n\nSet up a Django project with the following:\n\n.. code-block:: shell-session\n\n    $ pip install django\n    $ django-admin startproject project\n\nMore information in the `django tutorial <https://docs.djangoproject.com/en/3.1/intro/tutorial01/>`_.\n\nInstall with pip from pypi:\n\n.. code-block:: shell-session\n\n   $ pip install django-sql-explorer\n\nTake a look at available ``extras``\n\nAdd to your ``INSTALLED_APPS``, located in the ``settings.py`` file in your project folder:\n\n..  code-block:: python\n    :emphasize-lines: 3\n\n    INSTALLED_APPS = (\n        'explorer',\n    )\n\nAdd the following to your urls.py (all Explorer URLs are restricted\nvia the ``EXPLORER_PERMISSION_VIEW`` and ``EXPLORER_PERMISSION_CHANGE``\nsettings. See Settings section below for further documentation.):\n\n..  code-block:: python\n    :emphasize-lines: 5\n\n    from django.urls import path, include\n\n    urlpatterns = [\n        path('explorer/', include('explorer.urls')),\n    ]\n\nRun migrate to create the tables:\n\n``python manage.py migrate``\n\nCreate a superuser:\n\n``python manage.py createsuperuser``\n\nAnd run the server:\n\n``python manage.py runserver``\n\nYou can now browse to http://127.0.0.1:8000/explorer/. Add a database connection at /explorer/connections/new/, and you\nare ready to start exploring! If you have a database in your settings.DATABASES you would like to query, you can create\na connection with the same alias and name and set the Engine to \"Django Database\".\n\nNote that Explorer expects STATIC_URL to be set appropriately. This isn't a problem\nwith vanilla Django setups, but if you are using e.g. Django Storages with S3, you\nmust set your STATIC_URL to point to your S3 bucket (e.g. s3_bucket_url + '/static/')\n\nAI SQL Assistant\n----------------\nTo enable AI features, you must install the OpenAI SDK and Tiktoken library from\nrequirements/optional.txt. By default the Assistant is configured to use OpenAI and\nthe `gpt-4-0125-preview` model. To use those settings, set an OpenAI API token in\nyour project's settings.py file:\n\n``EXPLORER_AI_API_KEY = 'your_openai_api_key'``\n\nOr, more likely:\n\n``EXPLORER_AI_API_KEY = os.environ.get(\"OPENAI_API_KEY\")``\n\nIf you would prefer to use a different provider and/or different model, you can\nalso override the AI API URL root and default model. For example, this would configure\nthe Assistant to use OpenRouter and Mixtral 8x7B Instruct:\n\n..  code-block:: python\n    :emphasize-lines: 5\n\n    EXPLORER_ASSISTANT_MODEL = {\"name\": \"mistralai/mixtral-8x7b-instruct:nitro\",\n                                \"max_tokens\": 32768})\n    EXPLORER_ASSISTANT_BASE_URL = \"https://openrouter.ai/api/v1\"\n    EXPLORER_AI_API_KEY = os.environ.get(\"OPENROUTER_API_KEY\")\n\nOther Parameters\n----------------\n\nThe default behavior when viewing a parameterized query is to autorun the associated\nSQL with the default parameter values. This may perform poorly and you may want\na chance for your users to review the parameters before running. If so you may add\nthe following setting which will allow the user to view the query and adjust any\nparameters before hitting \"Save & Run\"\n\n.. code-block:: python\n\n    EXPLORER_AUTORUN_QUERY_WITH_PARAMS = False\n\nThere are a handful of features (snapshots, emailing queries) that\nrely on Celery and the dependencies in optional-requirements.txt. If\nyou have Celery installed, set ``EXPLORER_TASKS_ENABLED=True`` in your\nsettings.py to enable these features.\n\nInstalling From Source\n----------------------\n\nBecause the front-end assets must be built, installing SQL Explorer via pip\nfrom github is not supported. The package will be installed, but the front-end\nassets will be missing and will not be able to be built, as the necessary\nconfiguration files are not included when github builds the wheel for pip.\n\nTo run from source, clone the repository and follow the :doc:`development`\ninstructions.\n"
  },
  {
    "path": "docs/make.bat",
    "content": "@ECHO OFF\n\npushd %~dp0\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-build\n)\nset SOURCEDIR=.\nset BUILDDIR=_build\n\nif \"%1\" == \"\" goto help\n\n%SPHINXBUILD% >NUL 2>NUL\nif errorlevel 9009 (\n\techo.\n\techo.The 'sphinx-build' command was not found. Make sure you have Sphinx\n\techo.installed, then set the SPHINXBUILD environment variable to point\n\techo.to the full path of the 'sphinx-build' executable. Alternatively you\n\techo.may add the Sphinx directory to PATH.\n\techo.\n\techo.If you don't have Sphinx installed, grab it from\n\techo.http://sphinx-doc.org/\n\texit /b 1\n)\n\n%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\ngoto end\n\n:help\n%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%\n\n:end\npopd\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "django-sql-explorer>=2.0\nfuro\nSphinx>4\nsphinx-copybutton\nsphinxext-opengraph\n"
  },
  {
    "path": "docs/settings.rst",
    "content": "********\nSettings\n********\n\nHere are all of the available settings with their default values.\n\n\nSQL Blacklist\n*************\n\nDisallowed words in SQL queries to prevent destructive actions.\n\n.. code-block:: python\n\n   EXPLORER_SQL_BLACKLIST = (\n        # DML\n        'COMMIT',\n        'DELETE',\n        'INSERT',\n        'MERGE',\n        'REPLACE',\n        'ROLLBACK',\n        'SET',\n        'START',\n        'UPDATE',\n        'UPSERT',\n\n        # DDL\n        'ALTER',\n        'CREATE',\n        'DROP',\n        'RENAME',\n        'TRUNCATE',\n\n        # DCL\n        'GRANT',\n        'REVOKE',\n    )\n\n\n\nDefault rows\n************\n\nThe number of rows to show by default in the preview pane.\n\n.. code-block:: python\n\n   EXPLORER_DEFAULT_ROWS = 1000\n\n\nInclude table prefixes\n**********************\n\nIf not ``None``, show schema only for tables starting with these prefixes. \"Wins\" if in conflict with ``EXCLUDE``\n\n.. code-block:: python\n\n   EXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES = None  # shows all tables\n\n\nExclude table prefixes\n**********************\n\nDon't show schema for tables starting with these prefixes, in the schema helper.\n\n.. code-block:: python\n\n   EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES = (\n       'django.contrib.auth',\n       'django.contrib.contenttypes',\n       'django.contrib.sessions',\n       'django.contrib.admin'\n   )\n\n\nInclude views\n*************\n\nInclude database views\n\n.. code-block:: python\n\n   EXPLORER_SCHEMA_INCLUDE_VIEWS = False\n\n\nASYNC schema\n************\nGenerate DB schema asynchronously. Requires Celery and ``EXPLORER_TASKS_ENABLED``\n\n.. code-block:: python\n\n   EXPLORER_ASYNC_SCHEMA = False\n\n\nDatabase connections\n********************\n\nA dictionary of ``{'Friendly Name': 'django_db_alias'}``.\nIf you want to create a DatabaseConnection to a DB that is registered in settings.DATABASES, then add the alias to this\ndictionary (with a friendly name of your choice), and then create a new connection (../connections/new/) with the alias\nand name set to the alias of the Django database, and the Engine set to \"Django Database\".\n\n.. code-block:: python\n\n   EXPLORER_CONNECTIONS = {}\n\n\nPermission view\n****************\nCallback to check if the user is allowed to view and execute stored queries\n\n.. code-block:: python\n\n   EXPLORER_PERMISSION_VIEW = lambda r: r.user.is_staff\n\n\nPermission change\n*****************\n\nCallback to check if the user is allowed to add/change/delete queries\n\n.. code-block:: python\n\n   EXPLORER_PERMISSION_CHANGE = lambda r: r.user.is_staff\n\n\nTransforms\n**********\n\nList of tuples, see :ref:`Template Columns` more info.\n\n.. code-block:: python\n\n   EXPLORER_TRANSFORMS = []\n\n\nRecent query count\n******************\n\nThe number of recent queries to show at the top of the query listing.\n\n.. code-block:: python\n\n   EXPLORER_RECENT_QUERY_COUNT = 10\n\n\nUser query views\n****************\n\nA dict granting view permissions on specific queries of the form\n\n.. code-block:: python\n\n   EXPLORER_GET_USER_QUERY_VIEWS = {userId: [queryId, ], }\n\n**Default Value:**\n\n.. code-block:: python\n\n   EXPLORER_GET_USER_QUERY_VIEWS = {}\n\n\nToken Authentication\n********************\n\nBool indicating whether token-authenticated requests should be enabled. See :ref:`Power Tips`.\n\n.. code-block:: python\n\n   EXPLORER_TOKEN_AUTH_ENABLED = False\n\n\nToken\n*****\n\nAccess token for query results.\n\n.. code-block:: python\n\n   EXPLORER_TOKEN = \"CHANGEME\"\n\n\nCelery tasks\n************\n\nTurn on if you want to use the ``snapshot_queries`` celery task, or email report functionality in ``tasks.py``\n\n.. code-block:: python\n\n   EXPLORER_TASKS_ENABLED = False\n\n\nS3 access key\n*************\n\nS3 Access Key for snapshot upload\n\n.. code-block:: python\n\n   EXPLORER_S3_ACCESS_KEY = None\n\n\nS3 secret key\n*************\n\nS3 Secret Key for snapshot upload\n\n.. code-block:: python\n\n   EXPLORER_S3_SECRET_KEY = None\n\n\nS3 bucket\n*********\n\nS3 Bucket for snapshot upload\n\n.. code-block:: python\n\n   EXPLORER_S3_BUCKET = None\n\n\nS3 region\n******************\n\nS3 region. Defaults to us-east-1 if not specified.\n\n.. code-block:: python\n\n   EXPLORER_S3_REGION = 'us-east-1'\n\n\n\nS3 endpoint url\n******************\n\nS3 endpoint url. Normally not necessary to set.\nUseful to set if you are using a non-AWS S3 service or you are using a private AWS endpoint.\n\n\n.. code-block:: python\n\n   EXPLORER_S3_ENDPOINT_URL = 'https://accesspoint.vpce-abc123-abcdefgh.s3.us-east-1.vpce.amazonaws.com'\n\n\n\nS3 destination path\n********************\n\nS3 destination path. Defaults to empty string.\nUseful to set destination folder relative to S3 bucket.\nAlong with settings ``EXPLORER_S3_ENDPOINT_URL`` and ``EXPLORER_S3_BUCKET`` you can specify full destination path for async query results.\n\n.. code-block:: python\n\n    EXPLORER_S3_DESTINATION = 'explorer/query'\n\n    # if\n    EXPLORER_S3_ENDPOINT_URL = 'https://amazonaws.com'\n    EXPLORER_S3_BUCKET = 'test-bucket'\n    # then files will be saved to\n    # https://amazonaws.com/test-bucket/explorer/query/filename1.csv\n    # where `filename1.csv` is generated filename\n\n\nS3 link expiration\n******************\n\nS3 link expiration time. Defaults to 3600 seconds (1hr) if not specified.\nLinks are generated as presigned urls for security\n\n.. code-block:: python\n\n   EXPLORER_S3_LINK_EXPIRATION = 3600\n\n\nS3 signature version\n********************\n\nThe signature version when signing requests.\nAs of ``boto3`` version 1.13.21 the default signature version used for generating presigned urls is still ``v2``.\nTo be able to access your s3 objects in all regions through presigned urls, explicitly set this to ``s3v4``.\n\n.. code-block:: python\n\n   EXPLORER_S3_SIGNATURE_VERSION = 's3v4'\n\n\nFrom email\n**********\n\nThe default 'from' address when using async report email functionality\n\n.. code-block:: python\n\n   EXPLORER_FROM_EMAIL = \"django-sql-explorer@example.com\"\n\n\nData exporters\n**************\n\nThe export buttons to use. Default includes Excel, so xlsxwriter from ``requirements/optional.txt`` is needed\n\n.. code-block:: python\n\n   EXPLORER_DATA_EXPORTERS = [\n       ('csv', 'explorer.exporters.CSVExporter'),\n       ('excel', 'explorer.exporters.ExcelExporter'),\n       ('json', 'explorer.exporters.JSONExporter')\n   ]\n\n\nUnsafe rendering\n****************\n\nDisable auto escaping for rendering values from the database. Be wary of XSS attacks if querying unknown data.\n\n.. code-block:: python\n\n   EXPLORER_UNSAFE_RENDERING = False\n\n\nNo permission view\n******************\n\nPath to a view used when the user does not have permission. By default, a basic login view is provided\nbut a dotted path to a python view can be used\n\n.. code-block:: python\n\n   EXPLORER_NO_PERMISSION_VIEW = 'explorer.views.auth.safe_login_view_wrapper'\n\nAnonymous Telemetry Collection\n******************************\n\nBy default, anonymous usage statistics are collected. To disable this, set the following setting to False.\nYou can see what is being collected in telemetry.py.\n\n.. code-block:: python\n\n    EXPLORER_ENABLE_ANONYMOUS_STATS = False\n\nAI Settings (SQL Assistant)\n***************************\n\nThe following three settings control the SQL Assistant. More information is available in :doc:`install` instructions.\n\n.. code-block:: python\n\n    EXPLORER_AI_API_KEY = getattr(settings, \"EXPLORER_AI_API_KEY\", None)\n    EXPLORER_ASSISTANT_BASE_URL = getattr(settings, \"EXPLORER_ASSISTANT_BASE_URL\", \"https://api.openai.com/v1\")\n    EXPLORER_ASSISTANT_MODEL = getattr(settings, \"EXPLORER_ASSISTANT_MODEL\",\n                                   # Return the model name and max_tokens it supports\n                                   {\"name\": \"gpt-4o\",\n                                    \"max_tokens\": 128000})\n\n\nUser-Configured DB Connections\n******************************\nSet `EXPLORER_DB_CONNECTIONS_ENABLED` to `True` to enable DB connections to get configured in the browser (e.g. not\njust in settings.py). This also allows uploading of CSV or SQLite files for instant querying.\n\nIf you are using a database driver that requires information beyond the basic alias/db name/user/password/host/port,\nyou can add arbitrary connection data in the 'extras' field. Provide a JSON object and it will get merged into the\nfinal Django-style connection dictionary object. For example, for postgres, you could put this in the extras field:\n\n.. code-block:: python\n    {'OPTIONS': {'server_side_binding': true}}\n\nWhich would enable this (obscure) postgres feature. Neato! Note this must be valid JSON.\n\n\nUser Uploads\n************\nWith `EXPLORER_DB_CONNECTIONS_ENABLED` set to `True`, you can also set `EXPLORER_USER_UPLOADS_ENABLED` to allow users\nto upload their own CSV and SQLite files directly to explorer as new connections.\n\nGo to connections->Upload File. The uploaded files are limited in size by the\n`EXPLORER_MAX_UPLOAD_SIZE` setting which is set to 500mb by default (500 * 1024 * 1024). SQLite files (in either .db or\n.sqlite) will simply appear as connections. CSV files get run through a parser that infers the type of each field.\n\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/bash\n# entrypoint.sh\n\nset -e\n\n# Source the nvm script to set up the environment\n# This should match the version referenced in Dockerfile\n. /usr/local/.nvm/nvm.sh\nnvm use 20.15.1\n\n# Django\npython manage.py migrate\npython manage.py runserver 0.0.0.0:8000 &\necho \"Django server started\"\n\n# Vite dev server\nexport APP_VERSION=$(python -c 'from explorer import __version__; print(__version__)')\necho \"Starting Vite with APP_VERSION=${APP_VERSION}\"\nnpx vite --config vite.config.mjs\n"
  },
  {
    "path": "explorer/__init__.py",
    "content": "__version_info__ = {\n    \"major\": 5,\n    \"minor\": 3,\n    \"patch\": 0,\n    \"releaselevel\": \"final\",\n    \"serial\": 0\n}\n\n\ndef get_version(short=False):\n    assert __version_info__[\"releaselevel\"] in (\"alpha\", \"beta\", \"final\")\n    vers = [\"%(major)i.%(minor)i\" % __version_info__, ]\n    if __version_info__[\"patch\"]:\n        vers.append(\".%(patch)i\" % __version_info__)\n    if __version_info__[\"releaselevel\"] != \"final\" and not short:\n        vers.append(\n            \"%s%i\" % (\n                __version_info__[\"releaselevel\"][0],\n                __version_info__[\"serial\"])\n        )\n    return \"\".join(vers)\n\n\n__version__ = get_version()\n"
  },
  {
    "path": "explorer/actions.py",
    "content": "import tempfile\nfrom collections import defaultdict\nfrom datetime import date\nfrom wsgiref.util import FileWrapper\nfrom zipfile import ZipFile\n\nfrom django.http import HttpResponse\n\nfrom explorer.exporters import CSVExporter\n\n\ndef generate_report_action(description=\"Generate CSV file from SQL query\",):\n\n    def generate_report(modeladmin, request, queryset):\n        results = [\n            report for report in queryset if report.passes_blacklist()[0]\n        ]\n        queries = (len(results) > 0 and _package(results)) or defaultdict(int)\n        response = HttpResponse(\n            queries[\"data\"],\n            content_type=queries[\"content_type\"]\n        )\n        response[\"Content-Disposition\"] = queries[\"filename\"]\n        response[\"Content-Length\"] = queries[\"length\"]\n        return response\n\n    generate_report.short_description = description\n    return generate_report\n\n\ndef _package(queries):\n    ret = {}\n    is_one = len(queries) == 1\n    name_root = lambda n: f\"attachment; filename={n}\"  # noqa\n    ret[\"content_type\"] = (is_one and \"text/csv\") or \"application/zip\"\n    formatted = queries[0].title.replace(\",\", \"\")\n    day = date.today()\n    ret[\"filename\"] = (\n        is_one and name_root(f\"{formatted}.csv\")\n    ) or name_root(f\"Report_{day}.zip\")\n\n    ret[\"data\"] = (\n        is_one and CSVExporter(queries[0]).get_output()\n    ) or _build_zip(queries)\n\n    ret[\"length\"] = (is_one and len(ret[\"data\"]) or ret[\"data\"].blksize)\n    return ret\n\n\ndef _build_zip(queries):\n    temp = tempfile.TemporaryFile()\n    zip_file = ZipFile(temp, \"w\")\n    for r in queries:\n        zip_file.writestr(\n            f\"{r.title}.csv\", CSVExporter(r).get_output() or \"Error!\"\n        )\n    zip_file.close()\n    ret = FileWrapper(temp)\n    temp.seek(0)\n    return ret\n"
  },
  {
    "path": "explorer/admin.py",
    "content": "from django.contrib import admin\n\nfrom explorer.actions import generate_report_action\nfrom explorer.models import Query, ExplorerValue\nfrom explorer.ee.db_connections.admin import DatabaseConnectionAdmin  # noqa\n\n\n@admin.register(Query)\nclass QueryAdmin(admin.ModelAdmin):\n    list_display = (\"title\", \"description\", \"created_by_user\", \"few_shot\")\n    list_filter = (\"title\",)\n    raw_id_fields = (\"created_by_user\",)\n    actions = [generate_report_action()]\n\n\n@admin.register(ExplorerValue)\nclass ExplorerValueAdmin(admin.ModelAdmin):\n    list_display = (\"key\", \"value\", \"display_key\")\n    list_filter = (\"key\",)\n    search_fields = (\"key\", \"value\")\n\n    def display_key(self, obj):\n        # Human-readable name for the key\n        return dict(ExplorerValue.EXPLORER_SETTINGS_CHOICES).get(obj.key, \"\")\n\n    display_key.short_description = \"Setting Name\"\n"
  },
  {
    "path": "explorer/app_settings.py",
    "content": "from pydoc import locate\n\nfrom django.conf import settings\n\n\nEXPLORER_CONNECTIONS = getattr(settings, \"EXPLORER_CONNECTIONS\", {})\n\n# Deprecated as of 6.0. Will be removed in a future version.\nEXPLORER_DEFAULT_CONNECTION = getattr(\n    settings, \"EXPLORER_DEFAULT_CONNECTION\", None\n)\n\n# Change the behavior of explorer\nEXPLORER_SQL_BLACKLIST = getattr(\n    settings, \"EXPLORER_SQL_BLACKLIST\",\n    (\n        # DML\n        \"COMMIT\",\n        \"DELETE\",\n        \"INSERT\",\n        \"MERGE\",\n        \"REPLACE\",\n        \"ROLLBACK\",\n        \"SET\",\n        \"START\",\n        \"UPDATE\",\n        \"UPSERT\",\n\n        # DDL\n        \"ALTER\",\n        \"CREATE\",\n        \"DROP\",\n        \"RENAME\",\n        \"TRUNCATE\",\n\n        # DCL\n        \"GRANT\",\n        \"REVOKE\",\n    )\n)\n\n\nEXPLORER_DEFAULT_ROWS = getattr(settings, \"EXPLORER_DEFAULT_ROWS\", 1000)\n\nEXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES = getattr(\n    settings,\n    \"EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES\",\n    (\n        \"auth_\",\n        \"contenttypes_\",\n        \"sessions_\",\n        \"admin_\"\n    )\n)\n\nEXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES = getattr(\n    settings,\n    \"EXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES\",\n    None\n)\nEXPLORER_SCHEMA_INCLUDE_VIEWS = getattr(\n    settings,\n    \"EXPLORER_SCHEMA_INCLUDE_VIEWS\",\n    False\n)\n\nEXPLORER_TRANSFORMS = getattr(settings, \"EXPLORER_TRANSFORMS\", [])\nEXPLORER_PERMISSION_VIEW = getattr(\n    settings, \"EXPLORER_PERMISSION_VIEW\", lambda r: r.user.is_staff\n)\nEXPLORER_PERMISSION_CHANGE = getattr(\n    settings, \"EXPLORER_PERMISSION_CHANGE\", lambda r: r.user.is_staff\n)\nEXPLORER_PERMISSION_CONNECTIONS = getattr(\n    settings, \"EXPLORER_PERMISSION_CONNECTIONS\", lambda r: r.user.is_staff\n)\nEXPLORER_RECENT_QUERY_COUNT = getattr(\n    settings, \"EXPLORER_RECENT_QUERY_COUNT\", 5\n)\n\nDEFAULT_EXPORTERS = [\n    (\"csv\", \"explorer.exporters.CSVExporter\"),\n    (\"json\", \"explorer.exporters.JSONExporter\"),\n]\ntry:\n    import xlsxwriter  # noqa\n\n    DEFAULT_EXPORTERS.insert(\n        1,\n        (\"excel\", \"explorer.exporters.ExcelExporter\"),\n    )\nexcept ImportError:\n    pass\n\nEXPLORER_DATA_EXPORTERS = getattr(\n    settings, \"EXPLORER_DATA_EXPORTERS\", DEFAULT_EXPORTERS\n)\nCSV_DELIMETER = getattr(settings, \"EXPLORER_CSV_DELIMETER\", \",\")\n\n# API access\nEXPLORER_TOKEN = getattr(settings, \"EXPLORER_TOKEN\", \"CHANGEME\")\n\n# These are callable to aid testability by dodging the settings cache.\n# There is surely a better pattern for this, but this'll hold for now.\nEXPLORER_GET_USER_QUERY_VIEWS = lambda: getattr(  # noqa\n    settings, \"EXPLORER_USER_QUERY_VIEWS\", {}\n)\nEXPLORER_TOKEN_AUTH_ENABLED = lambda: getattr(  # noqa\n    settings, \"EXPLORER_TOKEN_AUTH_ENABLED\", False\n)\nEXPLORER_NO_PERMISSION_VIEW = lambda: locate(  # noqa\n    getattr(\n        settings,\n        \"EXPLORER_NO_PERMISSION_VIEW\",\n        \"explorer.views.auth.safe_login_view_wrapper\",\n    ),\n)\n\n# Async task related. Note that the EMAIL_HOST settings must be set up for\n# email to work.\nENABLE_TASKS = getattr(settings, \"EXPLORER_TASKS_ENABLED\", False)\nS3_ACCESS_KEY = getattr(settings, \"EXPLORER_S3_ACCESS_KEY\", None)\nS3_SECRET_KEY = getattr(settings, \"EXPLORER_S3_SECRET_KEY\", None)\nS3_BUCKET = getattr(settings, \"EXPLORER_S3_BUCKET\", None)\nS3_LINK_EXPIRATION: int = getattr(settings, \"EXPLORER_S3_LINK_EXPIRATION\", 3600)\nFROM_EMAIL = getattr(\n    settings, \"EXPLORER_FROM_EMAIL\", \"django-sql-explorer@example.com\"\n)\nS3_REGION = getattr(settings, \"EXPLORER_S3_REGION\", \"us-east-1\")\nS3_ENDPOINT_URL = getattr(settings, \"EXPLORER_S3_ENDPOINT_URL\", None)\nS3_DESTINATION = getattr(settings, \"EXPLORER_S3_DESTINATION\", \"\")\nS3_SIGNATURE_VERSION = getattr(settings, \"EXPLORER_S3_SIGNATURE_VERSION\", \"v4\")\n\nUNSAFE_RENDERING = getattr(settings, \"EXPLORER_UNSAFE_RENDERING\", False)\n\nEXPLORER_CHARTS_ENABLED = getattr(settings, \"EXPLORER_CHARTS_ENABLED\", False)\n\nEXPLORER_SHOW_SQL_BY_DEFAULT = getattr(settings, \"EXPLORER_SHOW_SQL_BY_DEFAULT\", True)\n\nEXPLORER_ENABLE_ANONYMOUS_STATS = getattr(settings, \"EXPLORER_ENABLE_ANONYMOUS_STATS\", True)\nEXPLORER_COLLECT_ENDPOINT_URL = \"https://collect.sqlexplorer.io/stat\"\n\n# If set to True will autorun queries when viewed which is the historical behavior\n# Default to True if not set in order to be backwards compatible\n# If set to False will not autorun queries containing parameters when viewed\n# - user will need to run by clicking the Save & Run Button to execute\nEXPLORER_AUTORUN_QUERY_WITH_PARAMS = getattr(settings, \"EXPLORER_AUTORUN_QUERY_WITH_PARAMS\", True)\nVITE_DEV_MODE = getattr(settings, \"VITE_DEV_MODE\", False)\n\n\n# AI Assistant settings. Setting the first to an OpenAI key is the simplest way to enable the assistant\nEXPLORER_AI_API_KEY = getattr(settings, \"EXPLORER_AI_API_KEY\", None)\n\nEXPLORER_ASSISTANT_BASE_URL = getattr(settings, \"EXPLORER_ASSISTANT_BASE_URL\", \"https://api.openai.com/v1\")\n\n# Deprecated. Will be removed in a future release. Please use EXPLORER_ASSISTANT_MODEL_NAME instead\nEXPLORER_ASSISTANT_MODEL = getattr(settings, \"EXPLORER_ASSISTANT_MODEL\",\n                                   # Return the model name and max_tokens it supports\n                                   {\"name\": \"gpt-4o\",\n                                    \"max_tokens\": 128000})\n\nEXPLORER_ASSISTANT_MODEL_NAME = getattr(settings, \"EXPLORER_ASSISTANT_MODEL_NAME\",\n                                    EXPLORER_ASSISTANT_MODEL[\"name\"])\n\n\nEXPLORER_DB_CONNECTIONS_ENABLED = getattr(settings, \"EXPLORER_DB_CONNECTIONS_ENABLED\", False)\nEXPLORER_USER_UPLOADS_ENABLED = getattr(settings, \"EXPLORER_USER_UPLOADS_ENABLED\", False)\nEXPLORER_PRUNE_LOCAL_UPLOAD_COPY_DAYS_INACTIVITY = getattr(settings,\n                                                           \"EXPLORER_PRUNE_LOCAL_UPLOAD_COPY_DAYS_INACTIVITY\", 7)\n# 500mb default max\nEXPLORER_MAX_UPLOAD_SIZE = getattr(settings, \"EXPLORER_MAX_UPLOAD_SIZE\", 500 * 1024 * 1024)\n\nEXPLORER_HOSTED = getattr(settings, \"EXPLORER_HOSTED\", False)\n\n\ndef has_assistant():\n    return EXPLORER_AI_API_KEY is not None\n\n\ndef db_connections_enabled():\n    return EXPLORER_DB_CONNECTIONS_ENABLED\n\n\ndef user_uploads_enabled():\n    return (EXPLORER_USER_UPLOADS_ENABLED and\n            EXPLORER_DB_CONNECTIONS_ENABLED and\n            S3_BUCKET is not None)\n"
  },
  {
    "path": "explorer/apps.py",
    "content": "from django.apps import AppConfig\nfrom django.utils.translation import gettext_lazy as _\nfrom django.db import transaction, DEFAULT_DB_ALIAS, connections\n\n\nclass ExplorerAppConfig(AppConfig):\n\n    name = \"explorer\"\n    verbose_name = _(\"SQL Explorer\")\n    default_auto_field = \"django.db.models.AutoField\"\n\n\n# SQL Explorer DatabaseConnection models store connection info but are always translated into \"django-style\" connections\n# before use, because we use Django's DB engine to run queries, gather schema information, etc.\n\n# In general this isn't a problem; we cough up a django-style connection and all is well. The exception is when using\n# the `with transaction.atomic(using=...):` context manager. The atomic() function takes a connection *alias* argument\n# and the retrieves the connection from settings.DATABASES. But of course if we are providing a user-created connection\n# alias, Django doesn't find it.\n\n# The solution is to monkey-patch the `get_connection` function within transaction.atomic to make it aware of the\n# user-created connections.\n\n# This code should be double-checked against new versions of Django to make sure the original logic is still correct.\n\ndef new_get_connection(using=None):\n    from explorer.ee.db_connections.models import DatabaseConnection\n    if using is None:\n        using = DEFAULT_DB_ALIAS\n    if using in connections:\n        return connections[using]\n    return DatabaseConnection.objects.get(alias=using).as_django_connection()\n\ntransaction.get_connection = new_get_connection\n"
  },
  {
    "path": "explorer/assistant/__init__.py",
    "content": "\n"
  },
  {
    "path": "explorer/assistant/forms.py",
    "content": "from django import forms\nfrom explorer.assistant.models import TableDescription\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\nclass TableDescriptionForm(forms.ModelForm):\n    class Meta:\n        model = TableDescription\n        fields = \"__all__\"\n        widgets = {\n            \"database_connection\": forms.Select(attrs={\"class\": \"form-select\"}),\n            \"description\": forms.Textarea(attrs={\"class\": \"form-control\", \"rows\": 3}),\n        }\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        if not self.instance.pk:  # Check if this is a new instance\n            # Set the default value for database_connection\n            self.fields[\"database_connection\"].initial = default_db_connection()\n\n        if self.instance and self.instance.table_name:\n            choices = [(self.instance.table_name, self.instance.table_name)]\n        else:\n            choices = []\n\n        f = forms.ChoiceField(\n            choices=choices,\n            widget=forms.Select(attrs={\"class\": \"form-select\", \"data-placeholder\": \"Select table\"})\n        )\n\n        # We don't actually care about validating the 'choices' that the ChoiceField does by default.\n        # Really we are just using that field type in order to get a valid pre-populated Select widget on the client\n        # But also it can't be blank!\n        def valid_value_new(v):\n            return bool(v)\n\n        f.valid_value = valid_value_new\n\n        self.fields[\"table_name\"] = f\n\n        if self.instance and self.instance.table_name:\n            self.fields[\"table_name\"].initial = self.instance.table_name\n"
  },
  {
    "path": "explorer/assistant/models.py",
    "content": "from django.db import models\nfrom django.conf import settings\nfrom explorer.ee.db_connections.models import DatabaseConnection\n\n\nclass PromptLog(models.Model):\n\n    class Meta:\n        app_label = \"explorer\"\n\n    prompt = models.TextField(blank=True)\n    user_request = models.TextField(blank=True)\n    response = models.TextField(blank=True)\n    run_by_user = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        null=True,\n        blank=True,\n        on_delete=models.CASCADE\n    )\n    run_at = models.DateTimeField(auto_now_add=True)\n    duration = models.FloatField(blank=True, null=True)  # seconds\n    model = models.CharField(blank=True, max_length=128, default=\"\")\n    error = models.TextField(blank=True, null=True)\n    database_connection = models.ForeignKey(to=DatabaseConnection, on_delete=models.SET_NULL, blank=True, null=True)\n\n\nclass TableDescription(models.Model):\n\n    class Meta:\n        app_label = \"explorer\"\n        unique_together = (\"database_connection\", \"table_name\")\n\n    database_connection = models.ForeignKey(to=DatabaseConnection, on_delete=models.CASCADE)\n    table_name = models.CharField(max_length=512)\n    description = models.TextField()\n\n    def __str__(self):\n        return f\"{self.database_connection.alias} - {self.table_name}\"\n"
  },
  {
    "path": "explorer/assistant/urls.py",
    "content": "from django.urls import path\nfrom explorer.assistant.views import (TableDescriptionListView,\n                                      TableDescriptionCreateView,\n                                      TableDescriptionUpdateView,\n                                      TableDescriptionDeleteView,\n                                      AssistantHelpView,\n                                      AssistantHistoryApiView)\n\nassistant_urls = [\n    path(\"assistant/\", AssistantHelpView.as_view(), name=\"assistant\"),\n    path(\"assistant/history/\", AssistantHistoryApiView.as_view(), name=\"assistant_history\"),\n    path(\"table-descriptions/\", TableDescriptionListView.as_view(), name=\"table_description_list\"),\n    path(\"table-descriptions/new/\", TableDescriptionCreateView.as_view(), name=\"table_description_create\"),\n    path(\"table-descriptions/<int:pk>/update/\", TableDescriptionUpdateView.as_view(), name=\"table_description_update\"),\n    path(\"table-descriptions/<int:pk>/delete/\", TableDescriptionDeleteView.as_view(), name=\"table_description_delete\"),\n]\n"
  },
  {
    "path": "explorer/assistant/utils.py",
    "content": "from dataclasses import dataclass\nfrom explorer import app_settings\nfrom explorer.schema import schema_info\nfrom explorer.models import ExplorerValue, Query\nfrom django.db.utils import OperationalError\nfrom django.db.models.functions import Lower\nfrom django.db.models import Q\nfrom explorer.assistant.models import TableDescription\n\n\nOPENAI_MODEL = app_settings.EXPLORER_ASSISTANT_MODEL[\"name\"]\nROW_SAMPLE_SIZE = 3\nMAX_FIELD_SAMPLE_SIZE = 200  # characters\n\n\ndef openai_client():\n    from openai import OpenAI\n    return OpenAI(\n        api_key=app_settings.EXPLORER_AI_API_KEY,\n        base_url=app_settings.EXPLORER_ASSISTANT_BASE_URL\n    )\n\n\ndef do_req(prompt):\n    messages = [\n        {\"role\": \"system\", \"content\": prompt[\"system\"]},\n        {\"role\": \"user\", \"content\": prompt[\"user\"]},\n    ]\n    resp = openai_client().chat.completions.create(\n      model=OPENAI_MODEL,\n      messages=messages\n    )\n    messages.append(resp.choices[0].message)\n    return messages\n\n\ndef extract_response(r):\n    return r[-1].content\n\n\ndef table_schema(db_connection, table_name):\n    schema = schema_info(db_connection)\n    s = [table for table in schema if table[0].lower() == table_name.lower()]\n    if len(s):\n        return s[0][1]\n\n\ndef sample_rows_from_table(connection, table_name):\n    \"\"\"\n    Fetches a sample of rows from the specified table and ensures that any field values\n    exceeding 200 characters (or bytes) are truncated. This is useful for handling fields\n    like \"description\" that might contain very long strings of text or binary data.\n    Truncating these fields prevents issues with displaying or processing overly large values.\n    An ellipsis (\"...\") is appended to indicate that the data has been truncated.\n\n    Args:\n        connection: The database connection.\n        table_name: The name of the table to sample rows from.\n\n    Returns:\n        A list of rows with field values truncated if they exceed 500 characters/bytes.\n    \"\"\"\n    cursor = connection.cursor()\n    try:\n        cursor.execute(f\"SELECT * FROM {table_name} LIMIT {ROW_SAMPLE_SIZE}\")\n        ret = [[header[0] for header in cursor.description]]\n        rows = cursor.fetchall()\n\n        for row in rows:\n            processed_row = []\n            for field in row:\n                new_val = field\n                if isinstance(field, str) and len(field) > MAX_FIELD_SAMPLE_SIZE:\n                    new_val = field[:MAX_FIELD_SAMPLE_SIZE] + \"...\"  # Truncate and add ellipsis\n                elif isinstance(field, (bytes, bytearray)):\n                    new_val = \"<binary_data>\"\n                processed_row.append(new_val)\n            ret.append(processed_row)\n\n        return ret\n    except OperationalError as e:\n        return [[str(e)]]\n\n\ndef format_rows_from_table(rows):\n    \"\"\" Given an array of rows (a list of lists), returns e.g.\n\nAlbumId | Title | ArtistId\n1 | For Those About To Rock We Salute You | 1\n2 | Let It Rip | 2\n3 | Restless and Wild | 2\n\n    \"\"\"\n    return \"\\n\".join([\" | \".join([str(item) for item in row]) for row in rows])\n\n\ndef build_system_prompt(flavor):\n    bsp = ExplorerValue.objects.get_item(ExplorerValue.ASSISTANT_SYSTEM_PROMPT).value\n    bsp += f\"\\nYou are an expert at writing SQL, specifically for {flavor}, and account for the nuances of this dialect of SQL. You always respond with valid {flavor} SQL.\"  # noqa\n    return bsp\n\n\ndef get_relevant_annotation(db_connection, t):\n    return TableDescription.objects.annotate(\n        table_name_lower=Lower(\"table_name\")\n    ).filter(\n        database_connection=db_connection,\n        table_name_lower=t.lower()\n    ).first()\n\n\ndef get_relevant_few_shots(db_connection, included_tables):\n    included_tables_lower = [t.lower() for t in included_tables]\n\n    query_conditions = Q()\n    for table in included_tables_lower:\n        query_conditions |= Q(sql__icontains=table)\n\n    return Query.objects.annotate(\n        sql_lower=Lower(\"sql\")\n    ).filter(\n        database_connection=db_connection,\n        few_shot=True\n    ).filter(query_conditions)\n\n\ndef get_few_shot_chunk(db_connection, included_tables):\n    included_tables = [t.lower() for t in included_tables]\n    few_shot_examples = get_relevant_few_shots(db_connection, included_tables)\n    if few_shot_examples:\n        return \"## Relevant example queries, written by expert SQL analysts ##\\n\" + \"\\n\\n\".join(\n            [f\"Description: {fs.title} - {fs.description}\\nSQL:\\n{fs.sql}\"\n             for fs in few_shot_examples.all()]\n        )\n\n\n@dataclass\nclass TablePromptData:\n    name: str\n    schema: list\n    sample: list\n    annotation: TableDescription\n\n    def render(self):\n        fmt_schema = \"\\n\".join([str(field) for field in self.schema])\n        ret = f\"\"\"## Information for Table '{self.name}' ##\n\nSchema:\\n{fmt_schema}\n\nSample rows:\\n{format_rows_from_table(self.sample)}\"\"\"\n        if self.annotation:\n            ret += f\"\\nUsage Notes:\\n{self.annotation.description}\"\n        return ret\n\n\ndef build_prompt(db_connection, assistant_request, included_tables, query_error=None, sql=None):\n    included_tables = [t.lower() for t in included_tables]\n\n    error_chunk = f\"## Query Error ##\\n{query_error}\" if query_error else None\n    sql_chunk = f\"## Existing User-Written SQL ##\\n{sql}\" if sql else None\n    request_chunk = f\"## User's Request to Assistant ##\\n{assistant_request}\"\n    table_chunks = [\n        TablePromptData(\n            name=t,\n            schema=table_schema(db_connection, t),\n            sample=sample_rows_from_table(db_connection.as_django_connection(), t),\n            annotation=get_relevant_annotation(db_connection, t)\n        ).render()\n        for t in included_tables\n    ]\n    few_shot_chunk = get_few_shot_chunk(db_connection, included_tables)\n\n    chunks = [error_chunk, sql_chunk, *table_chunks, few_shot_chunk, request_chunk]\n\n    prompt = {\n        \"system\": build_system_prompt(db_connection.as_django_connection().vendor),\n        \"user\": \"\\n\\n\".join([c for c in chunks if c]),\n    }\n    return prompt\n"
  },
  {
    "path": "explorer/assistant/views.py",
    "content": "from django.http import JsonResponse\nfrom django.views import View\nfrom django.utils import timezone\nfrom django.views.generic import ListView, CreateView, UpdateView, DeleteView\nfrom django.urls import reverse_lazy\n\nfrom .forms import TableDescriptionForm\nfrom .models import TableDescription\n\nimport json\n\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\nfrom explorer.telemetry import Stat, StatNames\nfrom explorer.ee.db_connections.models import DatabaseConnection\nfrom explorer.assistant.models import PromptLog\nfrom explorer.assistant.utils import (\n    do_req, extract_response,\n    build_prompt\n)\n\n\ndef run_assistant(request_data, user):\n\n    sql = request_data.get(\"sql\")\n    included_tables = request_data.get(\"selected_tables\", [])\n\n    connection_id = request_data.get(\"connection_id\")\n    try:\n        conn = DatabaseConnection.objects.get(id=connection_id)\n    except DatabaseConnection.DoesNotExist:\n        return \"Error: Connection not found\"\n    assistant_request = request_data.get(\"assistant_request\")\n    prompt = build_prompt(conn, assistant_request,\n                          included_tables, request_data.get(\"db_error\"), request_data.get(\"sql\"))\n\n    start = timezone.now()\n    pl = PromptLog(\n        prompt=prompt,\n        run_by_user=user,\n        run_at=timezone.now(),\n        user_request=assistant_request,\n        database_connection=conn\n    )\n    response_text = None\n    try:\n        resp = do_req(prompt)\n        response_text = extract_response(resp)\n        pl.response = response_text\n    except Exception as e:\n        pl.error = str(e)\n    finally:\n        stop = timezone.now()\n        pl.duration = (stop - start).total_seconds()\n        pl.save()\n        Stat(StatNames.ASSISTANT_RUN, {\n            \"included_table_count\": len(included_tables),\n            \"has_sql\": bool(sql),\n            \"duration\": pl.duration,\n        }).track()\n    return response_text\n\n\nclass AssistantHelpView(View):\n\n    def post(self, request, *args, **kwargs):\n        try:\n            data = json.loads(request.body)\n            resp = run_assistant(data, request.user)\n            response_data = {\n                \"status\": \"success\",\n                \"message\": resp\n            }\n            return JsonResponse(response_data)\n        except json.JSONDecodeError:\n            return JsonResponse({\"status\": \"error\", \"message\": \"Invalid JSON\"}, status=400)\n\n\nclass TableDescriptionListView(PermissionRequiredMixin, ExplorerContextMixin, ListView):\n    model = TableDescription\n    permission_required = \"view_permission\"\n    template_name = \"assistant/table_description_list.html\"\n    context_object_name = \"table_descriptions\"\n\n\nclass TableDescriptionCreateView(PermissionRequiredMixin, ExplorerContextMixin, CreateView):\n    model = TableDescription\n    permission_required = \"change_permission\"\n    template_name = \"assistant/table_description_form.html\"\n    success_url = reverse_lazy(\"table_description_list\")\n    form_class = TableDescriptionForm\n\n\nclass TableDescriptionUpdateView(PermissionRequiredMixin, ExplorerContextMixin, UpdateView):\n    model = TableDescription\n    permission_required = \"change_permission\"\n    template_name = \"assistant/table_description_form.html\"\n    success_url = reverse_lazy(\"table_description_list\")\n    form_class = TableDescriptionForm\n\n\nclass TableDescriptionDeleteView(PermissionRequiredMixin, ExplorerContextMixin, DeleteView):\n    model = TableDescription\n    permission_required = \"change_permission\"\n    template_name = \"assistant/table_description_confirm_delete.html\"\n    success_url = reverse_lazy(\"table_description_list\")\n\n\nclass AssistantHistoryApiView(View):\n\n    def post(self, request, *args, **kwargs):\n        try:\n            data = json.loads(request.body)\n            logs = PromptLog.objects.filter(\n                run_by_user=request.user,\n                database_connection_id=data[\"connection_id\"]\n            ).order_by(\"-run_at\")[:5]\n            ret = [{\n                \"user_request\": log.user_request,\n                \"response\": log.response\n            } for log in logs]\n            return JsonResponse({\"logs\": ret})\n        except json.JSONDecodeError:\n            return JsonResponse({\"status\": \"error\", \"message\": \"Invalid JSON\"}, status=400)\n"
  },
  {
    "path": "explorer/charts.py",
    "content": "from io import BytesIO\nfrom typing import Iterable, Optional\nfrom .models import QueryResult\n\nBAR_WIDTH = 0.2\n\n\ndef get_chart(result: QueryResult, chart_type: str, num_rows: int) -> Optional[str]:\n    import matplotlib.pyplot as plt\n    \"\"\"\n        Return a line or bar chart in SVG format if the result table adheres to the expected format.\n        A line chart is rendered if\n        * there is at least on row of in the result table\n        * there is at least one numeric column (the first column (with index 0) does not count)\n        The first column is used as x-axis labels.\n        All other numeric columns represent a line on the chart.\n        The name of the column is used as the name of the line in the legend.\n        Not numeric columns (except the first on) are ignored.\n    \"\"\"\n    if chart_type not in (\"bar\", \"line\"):\n        return\n    if len(result.data) < 1:\n        return None\n    data = result.data[:num_rows]\n    numeric_columns = [\n        c for c in range(1, len(data[0]))\n        if all([isinstance(col[c], (int, float)) or col[c] is None for col in data])\n    ]\n    # Don't create charts for > 10 series. This is a lightweight visualization.\n    if len(numeric_columns) < 1 or len(numeric_columns) > 10:\n        return None\n    labels = [row[0] for row in data]\n    fig, ax = plt.subplots(figsize=(10, 3.8))\n    bars = []\n    bar_positions = []\n    for idx, col_num in enumerate(numeric_columns):\n        if chart_type == \"bar\":\n            values = [row[col_num] if row[col_num] is not None else 0 for row in data]\n            bar_container = ax.bar([x + idx * BAR_WIDTH\n                                    for x in range(len(labels))], values, BAR_WIDTH, label=result.headers[col_num])\n            bars.append(bar_container)\n            bar_positions.append([(rect.get_x(), rect.get_height()) for rect in bar_container])\n        if chart_type == \"line\":\n            ax.plot(labels, [row[col_num] for row in data], label=result.headers[col_num])\n\n    ax.set_xlabel(result.headers[0])\n\n    if chart_type == \"bar\":\n        ax.set_xticks([x + BAR_WIDTH * (len(numeric_columns) / 2 - 0.5) for x in range(len(labels))])\n        ax.set_xticklabels(labels)\n\n    ax.legend()\n    for label in ax.get_xticklabels():\n        label.set_rotation(20)  # makes labels fit better\n        label.set_ha(\"right\")\n    svg_str = get_svg(fig)\n    return svg_str\n\n\ndef get_svg(fig) -> str:\n    buffer = BytesIO()\n    fig.savefig(buffer, format=\"svg\")\n    buffer.seek(0)\n    graph = buffer.getvalue().decode(\"utf-8\")\n    buffer.close()\n    return graph\n\n\ndef is_numeric(column: Iterable) -> bool:\n    return all([isinstance(value, (int, float)) or value is None for value in column])\n"
  },
  {
    "path": "explorer/ee/LICENSE",
    "content": "** Additional License for \"explorer/ee/\" Directory **\n\nAll content that resides under the \"explorer/ee/\" directory of this repository is provided under the MIT License,\nexcept that the following additional rights are reserved:\n\n** \"Commons Clause\" License Condition v1.0 **\n\nThe Software may not be used as part of any commercial offering or service, such as hosting, service, or consulting\nofferings. For purposes of the foregoing, \"commercial offering\" means the provision of the Software to third parties\nfor a fee or other consideration, including without limitation, providing the Software as part of a managed service,\nas part of a platform as a service, or providing it to third parties on a software as a service basis.\n\nThis restriction does not apply to non-commercial use by any individual, nor does it prevent such individual from\nproviding services to third parties using the Software.\n\n** Exceptions **\n\nSQL Explorer, Inc. may grant a Commercial License Agreement to provide exceptions to the \"Commons Clause\" License\nCondition. If you wish to obtain such a license, please contact SQL Explorer, Inc. at support@sqlexplorer.io. No\nexception is granted implicitly or explicitly without a written and signed Commercial License Agreement from SQL\nExplorer, Inc.\n"
  },
  {
    "path": "explorer/ee/__init__.py",
    "content": "\n"
  },
  {
    "path": "explorer/ee/db_connections/__init__.py",
    "content": ""
  },
  {
    "path": "explorer/ee/db_connections/admin.py",
    "content": "from django.contrib import admin\n\nfrom explorer.models import DatabaseConnection\n\n\n@admin.register(DatabaseConnection)\nclass DatabaseConnectionAdmin(admin.ModelAdmin):\n    pass\n\n"
  },
  {
    "path": "explorer/ee/db_connections/create_sqlite.py",
    "content": "import os\nfrom io import BytesIO\n\nfrom explorer.utils import secure_filename\nfrom explorer.ee.db_connections.type_infer import get_parser\nfrom explorer.ee.db_connections.utils import pandas_to_sqlite, uploaded_db_local_path\n\n\ndef get_names(file, append_conn=None, user_id=None):\n    s_filename = secure_filename(file.name)\n    table_name, _ = os.path.splitext(s_filename)\n\n    # f_name represents the filename of both the sqlite DB on S3, and on the local filesystem.\n    # If we are appending to an existing data source, then we re-use the same name.\n    # New connections get a new database name.\n    if append_conn:\n        f_name = os.path.basename(append_conn.name)\n    else:\n        f_name = f\"{table_name}_{user_id}.db\"\n\n    return table_name, f_name\n\n\ndef parse_to_sqlite(file, append_conn=None, user_id=None) -> (BytesIO, str):\n\n    table_name, f_name = get_names(file, append_conn, user_id)\n\n    # When appending, make sure the database exists locally so that we can write to it\n    if append_conn:\n        append_conn.download_sqlite_if_needed()\n\n    df_parser = get_parser(file)\n    if df_parser:\n        try:\n            df = df_parser(file.read())\n            local_path = uploaded_db_local_path(f_name)\n            f_bytes = pandas_to_sqlite(df, table_name, local_path)\n        except Exception as e:  # noqa\n            raise ValueError(f\"Error while parsing {f_name}: {e}\") from e\n    else:\n        # If it's a SQLite file already, simply cough it up as a BytesIO object\n        return BytesIO(file.read()), f_name\n    return f_bytes, f_name\n"
  },
  {
    "path": "explorer/ee/db_connections/forms.py",
    "content": "from django import forms\nfrom explorer.ee.db_connections.models import DatabaseConnection\nimport json\nfrom django.core.exceptions import ValidationError\n\n\n# This is very annoying, but Django renders the literal string 'null' in the form when displaying JSON\n# via a TextInput widget. So this custom widget prevents that.\nclass JSONTextInput(forms.TextInput):\n    def render(self, name, value, attrs=None, renderer=None):\n        if value in (None, \"\", \"null\"):\n            value = \"\"\n        elif isinstance(value, dict):\n            value = json.dumps(value)\n        return super().render(name, value, attrs, renderer)\n\n    def value_from_datadict(self, data, files, name):\n        value = data.get(name)\n        if value in (None, \"\", \"null\"):\n            return None\n        try:\n            return json.loads(value)\n        except (TypeError, ValueError) as ex:\n            raise ValidationError(\"Enter a valid JSON\") from ex\n\n\nclass DatabaseConnectionForm(forms.ModelForm):\n    class Meta:\n        model = DatabaseConnection\n        fields = \"__all__\"\n        widgets = {\n            \"alias\": forms.TextInput(attrs={\"class\": \"form-control\"}),\n            \"engine\": forms.Select(attrs={\"class\": \"form-select\"}),\n            \"name\": forms.TextInput(attrs={\"class\": \"form-control\"}),\n            \"user\": forms.TextInput(attrs={\"class\": \"form-control\"}),\n            \"password\": forms.PasswordInput(attrs={\"class\": \"form-control\"}),\n            \"host\": forms.TextInput(attrs={\"class\": \"form-control\"}),\n            \"port\": forms.TextInput(attrs={\"class\": \"form-control\"}),\n            \"extras\": JSONTextInput(attrs={\"class\": \"form-control\"}),\n        }\n"
  },
  {
    "path": "explorer/ee/db_connections/mime.py",
    "content": "import csv\nimport json\n\n# These are 'shallow' checks. They are just to understand if the upload appears valid at surface-level.\n# A deeper check will happen when pandas tries to parse the file.\n# This is designed to be quick, and simply assigned the right (full) parsing function to the uploaded file.\n\n\ndef is_csv(file):\n    if file.content_type != \"text/csv\":\n        return False\n    try:\n        # Check if the file content can be read as a CSV\n        file.seek(0)\n        sample = file.read(1024).decode(\"utf-8\")\n        csv.Sniffer().sniff(sample)\n        file.seek(0)\n        return True\n    except csv.Error:\n        return False\n\n\ndef is_json(file):\n    if file.content_type != \"application/json\":\n        return False\n    if not file.name.lower().endswith(\".json\"):\n        return False\n    return True\n\n\ndef is_json_list(file):\n    if not file.name.lower().endswith(\".json\"):\n        return False\n    file.seek(0)\n    first_line = file.readline()\n    file.seek(0)\n    try:\n        json.loads(first_line.decode(\"utf-8\"))\n        return True\n    except ValueError:\n        return False\n\n\ndef is_sqlite(file):\n    if file.content_type not in [\"application/x-sqlite3\", \"application/octet-stream\"]:\n        return False\n    try:\n        # Check if the file starts with the SQLite file header\n        file.seek(0)\n        header = file.read(16)\n        file.seek(0)\n        return header == b\"SQLite format 3\\x00\"\n    except Exception as e:  # noqa\n        return False\n"
  },
  {
    "path": "explorer/ee/db_connections/models.py",
    "content": "import os\nimport json\nfrom django.db import models, DatabaseError, connections, transaction\nfrom django.db.utils import load_backend\nfrom explorer.app_settings import EXPLORER_CONNECTIONS\nfrom explorer.ee.db_connections.utils import quick_hash, uploaded_db_local_path\nfrom django.core.cache import cache\nfrom django_cryptography.fields import encrypt\n\n\nclass DatabaseConnectionManager(models.Manager):\n\n    def uploads(self):\n        return self.filter(engine=DatabaseConnection.SQLITE, host__isnull=False)\n\n    def non_uploads(self):\n        return self.exclude(engine=DatabaseConnection.SQLITE, host__isnull=False)\n\n    def default(self):\n        return self.filter(default=True).first()\n\n\nclass DatabaseConnection(models.Model):\n\n    objects = DatabaseConnectionManager()\n\n    SQLITE = \"django.db.backends.sqlite3\"\n    DJANGO = \"django_connection\"\n\n    DATABASE_ENGINES = (\n        (SQLITE, \"SQLite3\"),\n        (\"django.db.backends.postgresql\", \"PostgreSQL\"),\n        (\"django.db.backends.mysql\", \"MySQL\"),\n        (\"django.db.backends.oracle\", \"Oracle\"),\n        (\"django.db.backends.mysql\", \"MariaDB\"),\n        (\"django_cockroachdb\", \"CockroachDB\"),\n        (\"mssql\", \"SQL Server (mssql-django)\"),\n        (\"django_snowflake\", \"Snowflake\"),\n        (DJANGO, \"Django Connection\"),\n    )\n\n    alias = models.CharField(max_length=255, unique=True)\n    engine = models.CharField(max_length=255, choices=DATABASE_ENGINES)\n    name = models.CharField(max_length=255, blank=True, null=True)\n    user = encrypt(models.CharField(max_length=255, blank=True, null=True))\n    password = encrypt(models.CharField(max_length=255, blank=True, null=True))\n    host = encrypt(models.CharField(max_length=255, blank=True))\n    port = models.CharField(max_length=255, blank=True, null=True)\n    extras = models.JSONField(blank=True, null=True)\n    upload_fingerprint = models.CharField(max_length=255, blank=True, null=True)\n    default = models.BooleanField(default=False)\n\n    def __str__(self):\n        return f\"{self.alias}\"\n\n    def update_fingerprint(self):\n        self.upload_fingerprint = self.local_fingerprint()\n        self.save()\n\n    def local_fingerprint(self):\n        if os.path.exists(self.local_name):\n            return quick_hash(self.local_name)\n\n    def _download_sqlite(self):\n        from explorer.utils import get_s3_bucket\n        s3 = get_s3_bucket()\n        s3.download_file(self.host, self.local_name)\n\n    def _download_needed(self):\n        # If the file doesn't exist, obviously we need to download it\n        # If it does exist, then check if it's out of date. But only check if in fact the DatabaseConnection has been\n        # saved to the DB. For example, we might be validating an unsaved connection, in which case the fingerprint\n        # won't be set yet.\n        return (not os.path.exists(self.local_name) or\n               (self.id is not None and self.local_fingerprint() != self.upload_fingerprint))\n\n    def download_sqlite_if_needed(self):\n\n        if self._download_needed():\n            cache_key = f\"download_lock_{self.local_name}\"\n            lock_acquired = cache.add(cache_key, \"locked\", timeout=300)  # Timeout after 5 minutes\n\n            if lock_acquired:\n                try:\n                    if self._download_needed():\n                        self._download_sqlite()\n                        self.update_fingerprint()\n                finally:\n                    cache.delete(cache_key)\n\n    @property\n    def is_upload(self):\n        return self.engine == self.SQLITE and self.host\n\n    @property\n    def is_django_alias(self):\n        return self.engine == DatabaseConnection.DJANGO\n\n    @property\n    def local_name(self):\n        if self.is_upload:\n            return uploaded_db_local_path(self.name)\n\n    def delete_local_sqlite(self):\n        if self.is_upload and os.path.exists(self.local_name):\n            os.remove(self.local_name)\n\n    # See the comment in apps.py for a more in-depth explanation of what's going on here.\n    def as_django_connection(self):\n        if self.is_upload:\n            self.download_sqlite_if_needed()\n\n        # You can't access a Django-backed connection unless it has been registered in EXPLORER_CONNECTIONS.\n        # Otherwise, users with userspace DatabaseConnection rights could connect to underlying Django DB connections.\n        if self.is_django_alias:\n            if self.alias in EXPLORER_CONNECTIONS.values():\n                return connections[self.alias]\n            else:\n                raise DatabaseError(\"Django alias connections must be registered in EXPLORER_CONNECTIONS.\")\n\n        connection_settings = {\n            \"ENGINE\": self.engine,\n            \"NAME\": self.name if not self.is_upload else self.local_name,\n            \"USER\": self.user,\n            \"PASSWORD\": self.password,\n            \"HOST\": self.host if not self.is_upload else None,\n            \"PORT\": self.port,\n            \"TIME_ZONE\": None,\n            \"CONN_MAX_AGE\": 0,\n            \"CONN_HEALTH_CHECKS\": False,\n            \"OPTIONS\": {},\n            \"TEST\": {},\n            \"AUTOCOMMIT\": True,\n            \"ATOMIC_REQUESTS\": False,\n        }\n\n        if self.extras:\n            extras_dict = json.loads(self.extras) if isinstance(self.extras, str) else self.extras\n            connection_settings.update(extras_dict)\n\n        try:\n            backend = load_backend(self.engine)\n            return backend.DatabaseWrapper(connection_settings, self.alias)\n        except DatabaseError as e:\n            raise DatabaseError(f\"Failed to create explorer connection: {e}\") from e\n\n    def save(self, *args, **kwargs):\n        # If this instance is marked as default, unset the default on all other instances\n        if self.default:\n            with transaction.atomic():\n                DatabaseConnection.objects.filter(default=True).update(default=False)\n        else:\n            # If there is no default set yet, make this newly created one the default.\n            has_default = DatabaseConnection.objects.filter(default=True).exists()\n            if not has_default:\n                self.default = True\n\n        super().save(*args, **kwargs)\n"
  },
  {
    "path": "explorer/ee/db_connections/type_infer.py",
    "content": "import io\nimport json\nfrom explorer.ee.db_connections.mime import is_csv, is_json, is_sqlite, is_json_list\n\n\nMAX_TYPING_SAMPLE_SIZE = 5000\nSHORTEST_PLAUSIBLE_DATE_STRING = 5\n\n\ndef get_parser(file):\n    if is_csv(file):\n        return csv_to_typed_df\n    if is_json_list(file):\n        return json_list_to_typed_df\n    if is_json(file):\n        return json_to_typed_df\n    if is_sqlite(file):\n        return None\n    raise ValueError(f\"File {file.content_type} not supported.\")\n\n\ndef csv_to_typed_df(csv_bytes, delimiter=\",\", has_headers=True):\n    import pandas as pd\n    csv_file = io.BytesIO(csv_bytes)\n    df = pd.read_csv(csv_file, sep=delimiter, header=0 if has_headers else None)\n    return df_to_typed_df(df)\n\n\ndef json_list_to_typed_df(json_bytes):\n    import pandas as pd\n    data = []\n    for line in io.BytesIO(json_bytes).readlines():\n        data.append(json.loads(line.decode(\"utf-8\")))\n\n    df = pd.json_normalize(data)\n    return df_to_typed_df(df)\n\n\ndef json_to_typed_df(json_bytes):\n    import pandas as pd\n    json_file = io.BytesIO(json_bytes)\n    json_content = json.load(json_file)\n    df = pd.json_normalize(json_content)\n    return df_to_typed_df(df)\n\n\ndef atof_custom(value):\n    # Remove any thousands separators and convert the decimal point\n    if \",\" in value and \".\" in value:\n        if value.index(\",\") < value.index(\".\"):\n            # 0,000.00 format\n            value = value.replace(\",\", \"\")\n        else:\n            # 0.000,00 format\n            value = value.replace(\".\", \"\").replace(\",\", \".\")\n    elif \",\" in value:\n        # No decimal point, only thousands separator\n        value = value.replace(\",\", \"\")\n    return float(value)\n\n\n\ndef df_to_typed_df(df):  # noqa\n    import pandas as pd\n    from dateutil import parser\n    try:\n\n        for column in df.columns:\n\n            # If we somehow have an array within a field (e.g. from a json object) then convert it to a string\n            df[column] = df[column].apply(lambda x: str(x) if isinstance(x, list) else x)\n\n            values = df[column].dropna().unique()\n            if len(values) > MAX_TYPING_SAMPLE_SIZE:\n                values = pd.Series(values).sample(MAX_TYPING_SAMPLE_SIZE, random_state=42).to_numpy()\n\n            is_date = False\n            is_integer = True\n            is_float = True\n\n            for value in values:\n                try:\n                    float_val = atof_custom(str(value))\n                    if float_val == int(float_val):\n                        continue  # This is effectively an integer\n                    else:\n                        is_integer = False\n                except ValueError:\n                    is_integer = False\n                    is_float = False\n                    break\n\n            if is_integer:\n                is_float = False\n\n            if not is_integer and not is_float:\n                is_date = True\n\n                # The dateutil parser is very aggressive and will interpret many short strings as dates.\n                # For example \"12a\" will be interpreted as 12:00 AM on the current date.\n                # That is not the behavior anyone wants. The shortest plausible date string is e.g. 1-1-23\n                try_parse = [v for v in values if len(str(v)) > SHORTEST_PLAUSIBLE_DATE_STRING]\n                if len(try_parse) > 0:\n                    for value in try_parse:\n                        try:\n                            parser.parse(str(value))\n                        except (ValueError, TypeError, OverflowError):\n                            is_date = False\n                            break\n                else:\n                    is_date = False\n\n            if is_date:\n                df[column] = pd.to_datetime(df[column], errors=\"coerce\", utc=True)\n            elif is_integer:\n                df[column] = df[column].apply(lambda x: int(atof_custom(str(x))) if pd.notna(x) else x)\n                # If there are NaN / blank values, the column will be converted to float\n                # Convert it back to integer\n                df[column] = df[column].astype(\"Int64\")\n            elif is_float:\n                df[column] = df[column].apply(lambda x: atof_custom(str(x)) if pd.notna(x) else x)\n            else:\n                inferred_type = pd.api.types.infer_dtype(values)\n                if inferred_type == \"integer\":\n                    df[column] = pd.to_numeric(df[column], errors=\"coerce\", downcast=\"integer\")\n                elif inferred_type == \"floating\":\n                    df[column] = pd.to_numeric(df[column], errors=\"coerce\")\n\n        return df\n\n    except pd.errors.ParserError as e:\n        return str(e)\n"
  },
  {
    "path": "explorer/ee/db_connections/utils.py",
    "content": "import os\n\nimport hashlib\nimport sqlite3\nimport io\n\n\ndef default_db_connection():\n    from explorer.ee.db_connections.models import DatabaseConnection\n    return DatabaseConnection.objects.default()\n\n\ndef default_db_connection_id():\n    return default_db_connection().id\n\n\n# Uploading the same filename twice (from the same user) will overwrite the 'old' DB on S3\ndef upload_sqlite(db_bytes, path):\n    from explorer.utils import get_s3_bucket\n    bucket = get_s3_bucket()\n    bucket.put_object(Key=path, Body=db_bytes, ServerSideEncryption=\"AES256\")\n\n\n# Aliases have the user_id appended to them so that if two users upload files with the same name\n# they don't step on one another. Without this, the *files* would get uploaded separately (because\n# the DBs go into user-specific folders on s3), but the *aliases* would be the same. So one user\n# could (innocently) upload a file with the same name, and any existing queries would be suddenly pointing\n# to this new database connection. Oops!\ndef create_connection_for_uploaded_sqlite(filename, s3_path):\n    from explorer.ee.db_connections.models import DatabaseConnection\n    return DatabaseConnection.objects.create(\n        alias=filename,\n        engine=DatabaseConnection.SQLITE,\n        name=filename,\n        host=s3_path,\n    )\n\n\ndef user_dbs_local_dir():\n    d = os.path.normpath(os.path.join(os.getcwd(), \"user_dbs\"))\n    if not os.path.exists(d):\n        os.makedirs(d)\n    return d\n\n\ndef uploaded_db_local_path(name):\n    return os.path.join(user_dbs_local_dir(), name)\n\n\ndef sqlite_to_bytesio(local_path):\n    # Write the file to disk. It'll be uploaded to s3, and left here locally for querying\n    db_file = io.BytesIO()\n    with open(local_path, \"rb\") as f:\n        db_file.write(f.read())\n    db_file.seek(0)\n    return db_file\n\n\ndef pandas_to_sqlite(df, table_name, local_path):\n    # Write the DataFrame to a local SQLite database and return it as a BytesIO object.\n    # This intentionally leaves the sqlite db on the local disk so that it is ready to go for\n    # querying immediately after the connection has been created. Removing it would also be OK, since\n    # the system knows to re-download it if it's not available, but this saves an extra download from S3.\n    conn = sqlite3.connect(local_path)\n\n    try:\n        df.to_sql(table_name, conn, if_exists=\"replace\", index=False)\n    finally:\n        conn.commit()\n        conn.close()\n\n    return sqlite_to_bytesio(local_path)\n\n\ndef quick_hash(file_path, num_samples=10, sample_size=1024):\n    hasher = hashlib.sha256()\n    file_size = os.path.getsize(file_path)\n\n    if file_size == 0:\n        return hasher.hexdigest()\n\n    sample_interval = file_size // num_samples\n    with open(file_path, \"rb\") as f:\n        for i in range(num_samples):\n            f.seek(i * sample_interval)\n            sample_data = f.read(sample_size)\n            if not sample_data:\n                break\n            hasher.update(sample_data)\n\n    return hasher.hexdigest()\n"
  },
  {
    "path": "explorer/ee/db_connections/views.py",
    "content": "import logging\nfrom django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView\nfrom django.views import View\nfrom django.http import JsonResponse, HttpResponse\nfrom django.urls import reverse_lazy\nfrom django.db.utils import OperationalError, DatabaseError\nfrom explorer.models import DatabaseConnection\nfrom explorer.ee.db_connections.utils import (\n    upload_sqlite,\n    create_connection_for_uploaded_sqlite\n)\nfrom explorer.ee.db_connections.create_sqlite import parse_to_sqlite\nfrom explorer.schema import clear_schema_cache\nfrom explorer.app_settings import EXPLORER_MAX_UPLOAD_SIZE\nfrom explorer.ee.db_connections.forms import DatabaseConnectionForm\nfrom explorer.utils import delete_from_s3\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\nfrom explorer.ee.db_connections.mime import is_sqlite\n\n\nlogger = logging.getLogger(__name__)\n\n\nclass UploadDbView(PermissionRequiredMixin, View):\n\n    permission_required = \"connections_permission\"\n\n    def post(self, request):  # noqa\n        file = request.FILES.get(\"file\")\n        if file:\n\n            # 'append' should be None, or the ID of the DatabaseConnection to append this table to.\n            # This is stored in DatabaseConnection.host of the previously uploaded connection\n            append = request.POST.get(\"append\")\n            append_path = None\n            conn = None\n            if append:\n                conn = DatabaseConnection.objects.get(id=append)\n                append_path = conn.host\n\n            if file.size > EXPLORER_MAX_UPLOAD_SIZE:\n                friendly = EXPLORER_MAX_UPLOAD_SIZE / (1024 * 1024)\n                return JsonResponse({\"error\": f\"File size exceeds the limit of {friendly} MB\"}, status=400)\n\n            # You can't double stramp a triple stamp!\n            if append_path and is_sqlite(file):\n                msg = \"Can't append a SQLite file to a SQLite file. Only CSV and JSON.\"\n                logger.error(msg)\n                return JsonResponse({\"error\": msg}, status=400)\n\n            try:\n                f_bytes, f_name = parse_to_sqlite(file, conn, request.user.id)\n            except ValueError as e:\n                logger.error(f\"Error getting bytes for {file.name}: {e}\")\n                return JsonResponse({\"error\": \"File was not csv, json, or sqlite.\"}, status=400)\n            except TypeError as e:\n                logger.error(f\"Error parse {file.name}: {e}\")\n                return JsonResponse({\"error\": \"Error parsing file.\"}, status=400)\n\n            if append_path:\n                s3_path = append_path\n            else:\n                s3_path = f\"user_dbs/user_{request.user.id}/{f_name}\"\n\n            try:\n                upload_sqlite(f_bytes, s3_path)\n            except Exception as e:  # noqa\n                logger.exception(f\"Exception while uploading file {f_name}: {e}\")\n                return JsonResponse({\"error\": \"Error while uploading file to S3.\"}, status=400)\n\n            # If we're not appending, then need to create a new DatabaseConnection\n            if not append_path:\n                conn = create_connection_for_uploaded_sqlite(f_name, s3_path)\n\n            clear_schema_cache(conn)\n            conn.update_fingerprint()\n            return JsonResponse({\"success\": True})\n        else:\n            return JsonResponse({\"error\": \"No file provided\"}, status=400)\n\n\nclass DatabaseConnectionsListView(PermissionRequiredMixin, ExplorerContextMixin, ListView):\n\n    permission_required = \"connections_permission\"\n    template_name = \"connections/connections.html\"\n    model = DatabaseConnection\n\n\nclass DatabaseConnectionDetailView(PermissionRequiredMixin, ExplorerContextMixin, DetailView):\n    permission_required = \"connections_permission\"\n    model = DatabaseConnection\n    template_name = \"connections/database_connection_detail.html\"\n\n\nclass DatabaseConnectionCreateView(PermissionRequiredMixin, ExplorerContextMixin, CreateView):\n    permission_required = \"connections_permission\"\n    model = DatabaseConnection\n    form_class = DatabaseConnectionForm\n    template_name = \"connections/database_connection_form.html\"\n    success_url = reverse_lazy(\"explorer_connections\")\n\n\nclass DatabaseConnectionUploadCreateView(TemplateView):\n    template_name = \"connections/connection_upload.html\"\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\"valid_connections\"] = DatabaseConnection.objects.filter(engine=DatabaseConnection.SQLITE,\n                                                                         host__isnull=False)\n        return context\n\n\nclass DatabaseConnectionUpdateView(PermissionRequiredMixin, ExplorerContextMixin, UpdateView):\n    permission_required = \"connections_permission\"\n    model = DatabaseConnection\n    form_class = DatabaseConnectionForm\n    template_name = \"connections/database_connection_form.html\"\n    success_url = reverse_lazy(\"explorer_connections\")\n\n\nclass DatabaseConnectionDeleteView(PermissionRequiredMixin, DeleteView):\n    permission_required = \"connections_permission\"\n    model = DatabaseConnection\n    template_name = \"connections/database_connection_confirm_delete.html\"\n    success_url = reverse_lazy(\"explorer_connections\")\n\n    def delete(self, request, *args, **kwargs):\n        connection = self.get_object()\n        if connection.is_upload:\n            delete_from_s3(connection.host)\n        return super().delete(request, *args, **kwargs)\n\n\nclass DatabaseConnectionRefreshView(PermissionRequiredMixin, View):\n\n    permission_required = \"connections_permission\"\n    success_url = reverse_lazy(\"explorer_connections\")\n\n    def get(self, request, pk):  # noqa\n        conn = DatabaseConnection.objects.get(id=pk)\n        conn.delete_local_sqlite()\n        clear_schema_cache(conn)\n        message = f\"Deleted schema cache for {conn.alias}. Schema will be regenerated on next use.\"\n        if conn.is_upload:\n            message += \"\\nRemoved local SQLite DB. Will be re-downloaded from S3 on next use.\"\n        message += \"\\nPlease hit back to return to the application.\"\n        return HttpResponse(content_type=\"text/plain\", content=message)\n\n\nclass DatabaseConnectionValidateView(PermissionRequiredMixin, View):\n\n    permission_required = \"connections_permission\"\n\n    # pk param is ignored, in order to deal with having 2 URL patterns\n    def post(self, request, pk=None):  # noqa\n        form = DatabaseConnectionForm(request.POST)\n\n        instance = DatabaseConnection.objects.filter(alias=request.POST[\"alias\"]).first()\n        if instance:\n            form = DatabaseConnectionForm(request.POST, instance=instance)\n        if form.is_valid():\n            connection_data = form.cleaned_data\n            explorer_connection = DatabaseConnection(\n                alias=connection_data[\"alias\"],\n                engine=connection_data[\"engine\"],\n                name=connection_data[\"name\"],\n                user=connection_data[\"user\"],\n                password=connection_data[\"password\"],\n                host=connection_data[\"host\"],\n                port=connection_data[\"port\"],\n                extras=connection_data[\"extras\"]\n            )\n            try:\n                conn = explorer_connection.as_django_connection()\n                with conn.cursor() as cursor:\n                    cursor.execute(\"SELECT 1\")\n                return JsonResponse({\"success\": True})\n            except OperationalError as e:\n                return JsonResponse({\"success\": False, \"error\": str(e)})\n            except DatabaseError as e:\n                return JsonResponse({\"success\": False, \"error\": str(e)})\n        else:\n            return JsonResponse({\"success\": False, \"error\": \"Invalid form data\"})\n"
  },
  {
    "path": "explorer/ee/urls.py",
    "content": "from django.urls import path\n\nfrom explorer.ee.db_connections.views import (\n    UploadDbView,\n    DatabaseConnectionsListView,\n    DatabaseConnectionCreateView,\n    DatabaseConnectionDetailView,\n    DatabaseConnectionUpdateView,\n    DatabaseConnectionDeleteView,\n    DatabaseConnectionValidateView,\n    DatabaseConnectionUploadCreateView,\n    DatabaseConnectionRefreshView\n)\n\nee_urls = [\n    path(\"connections/\", DatabaseConnectionsListView.as_view(), name=\"explorer_connections\"),\n    path(\"connections/upload/\", UploadDbView.as_view(), name=\"explorer_upload\"),\n    path(\"connections/<int:pk>/\", DatabaseConnectionDetailView.as_view(), name=\"explorer_connection_detail\"),\n    path(\"connections/new/\", DatabaseConnectionCreateView.as_view(), name=\"explorer_connection_create\"),\n    path(\"connections/create_upload/\", DatabaseConnectionUploadCreateView.as_view(), name=\"explorer_upload_create\"),\n    path(\"connections/<int:pk>/edit/\", DatabaseConnectionUpdateView.as_view(), name=\"explorer_connection_update\"),\n    path(\"connections/<int:pk>/delete/\", DatabaseConnectionDeleteView.as_view(), name=\"explorer_connection_delete\"),\n    path(\"connections/validate/\", DatabaseConnectionValidateView.as_view(), name=\"explorer_connection_validate\"),\n    path(\"connections/<int:pk>/refresh/\", DatabaseConnectionRefreshView.as_view(),\n         name=\"explorer_connection_refresh\")\n]\n"
  },
  {
    "path": "explorer/exporters.py",
    "content": "import codecs\nimport csv\nimport json\nimport uuid\nfrom datetime import datetime\nfrom io import BytesIO, StringIO\n\nfrom django.core.serializers.json import DjangoJSONEncoder\nfrom django.utils.module_loading import import_string\nfrom django.utils.text import get_valid_filename, slugify\n\nfrom explorer import app_settings\n\n\ndef get_exporter_class(format):\n    class_str = dict(app_settings.EXPLORER_DATA_EXPORTERS)[format]\n    return import_string(class_str)\n\n\nclass BaseExporter:\n\n    name = \"\"\n    content_type = \"\"\n    file_extension = \"\"\n\n    def __init__(self, query):\n        self.query = query\n\n    def get_output(self, **kwargs):\n        value = self.get_file_output(**kwargs).getvalue()\n        return value\n\n    def get_file_output(self, **kwargs):\n        res = self.query.execute_query_only()\n        return self._get_output(res, **kwargs)\n\n    def _get_output(self, res, **kwargs):\n        \"\"\"\n        :param res: QueryResult\n        :param kwargs: Optional. Any exporter-specific arguments.\n        :return: File-like object\n        \"\"\"\n        raise NotImplementedError\n\n    def get_filename(self):\n        return get_valid_filename(self.query.title or \"\") + self.file_extension\n\n\nclass CSVExporter(BaseExporter):\n\n    name = \"CSV\"\n    content_type = \"text/csv\"\n    file_extension = \".csv\"\n\n    def _get_output(self, res, **kwargs):\n        delim = kwargs.get(\"delim\") or app_settings.CSV_DELIMETER\n        delim = \"\\t\" if delim == \"tab\" else str(delim)\n        delim = app_settings.CSV_DELIMETER if len(delim) > 1 else delim\n        csv_data = StringIO()\n        csv_data.write(codecs.BOM_UTF8.decode(\"utf-8\"))\n        writer = csv.writer(csv_data, delimiter=delim)\n        writer.writerow(res.headers)\n        for row in res.data:\n            writer.writerow(row)\n        return csv_data\n\n\nclass JSONExporter(BaseExporter):\n\n    name = \"JSON\"\n    content_type = \"application/json\"\n    file_extension = \".json\"\n\n    def _get_output(self, res, **kwargs):\n        data = []\n        for row in res.data:\n            data.append(\n                dict(zip(\n                    [str(h) if h is not None else \"\" for h in res.headers],\n                    row\n                ))\n            )\n\n        json_data = json.dumps(data, cls=DjangoJSONEncoder)\n        return StringIO(json_data)\n\n\nclass ExcelExporter(BaseExporter):\n\n    name = \"Excel\"\n    content_type = \"application/vnd.ms-excel\"\n    file_extension = \".xlsx\"\n\n    def _get_output(self, res, **kwargs):\n        import xlsxwriter\n        output = BytesIO()\n\n        wb = xlsxwriter.Workbook(output, {\"in_memory\": True})\n\n        ws = wb.add_worksheet(name=self._format_title())\n\n        # Write headers\n        row = 0\n        col = 0\n        header_style = wb.add_format({\"bold\": True})\n        for header in res.header_strings:\n            ws.write(row, col, header, header_style)\n            col += 1\n\n        # Write data\n        row = 1\n        col = 0\n        for data_row in res.data:\n            for data in data_row:\n                # xlsxwriter can't handle timezone-aware datetimes or\n                # UUIDs, so we help out here and just cast it to a\n                # string\n                if isinstance(data, (datetime, uuid.UUID)):\n                    data = str(data)\n                # JSON and Array fields\n                if isinstance(data, (dict, list)):\n                    data = json.dumps(data)\n                ws.write(row, col, data)\n                col += 1\n            row += 1\n            col = 0\n\n        wb.close()\n        return output\n\n    def _format_title(self):\n        # XLSX writer won't allow sheet names > 31 characters or that\n        # contain invalid characters\n        # https://github.com/jmcnamara/XlsxWriter/blob/master/xlsxwriter/\n        # test/workbook/test_check_sheetname.py\n        title = slugify(self.query.title)\n        return title[:31]\n"
  },
  {
    "path": "explorer/forms.py",
    "content": "from django.forms import BooleanField, CharField, ModelForm, ValidationError\nfrom django.forms.widgets import CheckboxInput, Select\nfrom django.db.models import Value, IntegerField, When, Case\n\nfrom explorer.models import MSG_FAILED_BLACKLIST, Query\nfrom explorer.ee.db_connections.models import DatabaseConnection\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\nclass SqlField(CharField):\n\n    def validate(self, value):\n        \"\"\"\n        Ensure that the SQL passes the blacklist.\n\n        :param value: The SQL for this Query model.\n        \"\"\"\n        super().validate(value)\n        query = Query(sql=value)\n\n        passes_blacklist, failing_words = query.passes_blacklist()\n\n        error = MSG_FAILED_BLACKLIST % \", \".join(\n            failing_words) if not passes_blacklist else None\n\n        if error:\n            raise ValidationError(\n                error,\n                code=\"InvalidSql\"\n            )\n\n\nclass QueryForm(ModelForm):\n\n    sql = SqlField()\n    snapshot = BooleanField(widget=CheckboxInput, required=False)\n    few_shot = BooleanField(widget=CheckboxInput, required=False)\n    database_connection = CharField(widget=Select, required=False)\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.fields[\"database_connection\"].widget.choices = self.connections\n        if not self.instance.database_connection:\n            default_db = default_db_connection()\n            self.initial[\"database_connection\"] = default_db_connection().alias if default_db else None\n        self.fields[\"database_connection\"].widget.attrs[\"class\"] = \"form-select\"\n\n    def clean(self):\n        # Don't overwrite created_by_user\n        if self.instance and self.instance.created_by_user:\n            self.cleaned_data[\"created_by_user\"] = \\\n                self.instance.created_by_user\n        return super().clean()\n\n    def clean_database_connection(self):\n        connection_id = self.cleaned_data.get(\"database_connection\")\n        if connection_id:\n            try:\n                return DatabaseConnection.objects.get(id=connection_id)\n            except DatabaseConnection.DoesNotExist as e:\n                raise ValidationError(\"Invalid database connection selected.\") from e\n        return None\n\n    @property\n    def created_at_time(self):\n        return self.instance.created_at.strftime(\"%Y-%m-%d\")\n\n    @property\n    def connections(self):\n        default_db = default_db_connection()\n        if default_db is None:\n            return []\n\n        # Ensure the default connection appears first in the dropdown in the form\n        result = DatabaseConnection.objects.annotate(\n            custom_order=Case(\n                When(id=default_db_connection().id, then=Value(0)),\n                default=Value(1),\n                output_field=IntegerField(),\n            )\n        ).order_by(\"custom_order\", \"id\")\n        return [(c.id, c.alias) for c in result.all()]\n\n    class Meta:\n        model = Query\n        fields = [\"title\", \"sql\", \"description\", \"snapshot\", \"database_connection\", \"few_shot\"]\n"
  },
  {
    "path": "explorer/locale/ru/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PACKAGE VERSION\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2021-07-24 16:19-0500\\n\"\n\"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n\"\n\"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n\"\n\"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n\"\n\"%100>=11 && n%100<=14)? 2 : 3);\\n\"\n\n#: explorer/apps.py:10 explorer/templates/explorer/base.html:9\n#: explorer/templates/explorer/base.html:34\n#: explorer/templates/explorer/fullscreen.html:8\nmsgid \"SQL Explorer\"\nmsgstr \"SQL Навигатор\"\n\n#: explorer/models.py:40\nmsgid \"Include in snapshot task (if enabled)\"\nmsgstr \"Включить в задачу снятия снепшота (если разрешено)\"\n\n#: explorer/models.py:47\nmsgid \"\"\n\"Name of DB connection (as specified in settings) to use for this query.Will \"\n\"use EXPLORER_DEFAULT_CONNECTION if left blank\"\nmsgstr \"\"\n\"Название соединения БД (как указано в настройках), чтобы использовать для \"\n\"этого запроса.Если не использовать, будет использоваться соединение по \"\n\"умолчанию\"\n\n#: explorer/models.py:60 explorer/templates/explorer/query_list.html:22\n#: explorer/templates/explorer/query_list.html:57\nmsgid \"Query\"\nmsgstr \"Запрос\"\n\n#: explorer/models.py:61\nmsgid \"Queries\"\nmsgstr \"Запросы\"\n\n#: explorer/templates/explorer/fullscreen.html:20\n#: explorer/templates/explorer/play.html:6\n#: explorer/templates/explorer/query.html:7\n#: explorer/templates/explorer/query.html:26\n#: explorer/templates/explorer/query_list.html:6\n#: explorer/templates/explorer/querylog_list.html:6\nmsgid \"New Query\"\nmsgstr \"Новый запрос\"\n\n#: explorer/templates/explorer/fullscreen.html:46\n#: explorer/templates/explorer/preview_pane.html:106\nmsgid \"Empty Resultset\"\nmsgstr \"Результат запроса пуст\"\n\n#: explorer/templates/explorer/play.html:7\n#: explorer/templates/explorer/play.html:15\n#: explorer/templates/explorer/query.html:9\n#: explorer/templates/explorer/query_list.html:7\n#: explorer/templates/explorer/querylog_list.html:7\n#: explorer/templates/explorer/querylog_list.html:23\n#: explorer/templates/explorer/querylog_list.html:41\nmsgid \"Playground\"\nmsgstr \"Полигон\"\n\n#: explorer/templates/explorer/play.html:8\n#: explorer/templates/explorer/query.html:14\n#: explorer/templates/explorer/query_list.html:8\n#: explorer/templates/explorer/querylog_list.html:8\nmsgid \"Logs\"\nmsgstr \"Журналы\"\n\n#: explorer/templates/explorer/play.html:17\nmsgid \"\"\n\"The playground is for experimenting and writing ad-hoc queries. By default, \"\n\"nothing you do here will be saved.\"\nmsgstr \"\"\n\"Полигон предназначен для экспериментов и написания специальных запросов. По \"\n\"умолчанию тут ничего не будет сохраняться\"\n\n#: explorer/templates/explorer/play.html:27\n#: explorer/templates/explorer/query.html:61\nmsgid \"Connection\"\nmsgstr \"Соединение\"\n\n#: explorer/templates/explorer/play.html:42\nmsgid \"Playground SQL\"\nmsgstr \"Экспериментальный SQL\"\n\n#: explorer/templates/explorer/play.html:62\n#: explorer/templates/explorer/query.html:147\nmsgid \"Refresh\"\nmsgstr \"Обновить\"\n\n#: explorer/templates/explorer/play.html:65\n#: explorer/templates/explorer/play.html:77\n#: explorer/templates/explorer/query.html:127\n#: explorer/templates/explorer/query.html:154\nmsgid \"Toggle Dropdown\"\nmsgstr \"Включить выпадающий список\"\n\n#: explorer/templates/explorer/play.html:68\nmsgid \"Save As New Query\"\nmsgstr \"Сохранить как новый запрос\"\n\n#: explorer/templates/explorer/play.html:73\n#: explorer/templates/explorer/query.html:150\nmsgid \"Download\"\nmsgstr \"Скачать\"\n\n#: explorer/templates/explorer/play.html:85\n#: explorer/templates/explorer/query.html:141\nmsgid \"Show Schema\"\nmsgstr \"Показать схему базы\"\n\n#: explorer/templates/explorer/play.html:88\n#: explorer/templates/explorer/query.html:144\nmsgid \"Hide Schema\"\nmsgstr \"Скрыть схему\"\n\n#: explorer/templates/explorer/play.html:91\n#: explorer/templates/explorer/query.html:138\nmsgid \"Format\"\nmsgstr \"Отформатировать запрос\"\n\n#: explorer/templates/explorer/play.html:95\nmsgid \"Playground Query\"\nmsgstr \"Экспериментальный запрос\"\n\n#: explorer/templates/explorer/preview_pane.html:10\nmsgid \"Preview\"\nmsgstr \"Предпросмотр\"\n\n#: explorer/templates/explorer/preview_pane.html:16\nmsgid \"Snapshots\"\nmsgstr \"Варианты\"\n\n#: explorer/templates/explorer/preview_pane.html:23\n#: explorer/templates/explorer/preview_pane.html:139\nmsgid \"Pivot\"\nmsgstr \"Сводная таблица\"\n\n#: explorer/templates/explorer/preview_pane.html:38\n#, python-format\nmsgid \"Execution time: %(duration)s ms\"\nmsgstr \"Время выполнения: %(duration)s мс\"\n\n#: explorer/templates/explorer/preview_pane.html:46\nmsgid \"Showing\"\nmsgstr \"Отображение\"\n\n#: explorer/templates/explorer/preview_pane.html:48\nmsgid \"First\"\nmsgstr \"Первый\"\n\n#: explorer/templates/explorer/preview_pane.html:50\n#, python-format\nmsgid \"of %(total_rows)s total rows.\"\nmsgstr \"из %(total_rows)s выбранных записей.\"\n\n#: explorer/templates/explorer/query.html:12\nmsgid \"Query Detail\"\nmsgstr \"Подробности запроса\"\n\n#: explorer/templates/explorer/query.html:33\nmsgid \"History\"\nmsgstr \"История\"\n\n#: explorer/templates/explorer/query.html:54\nmsgid \"Title\"\nmsgstr \"Название\"\n\n#: explorer/templates/explorer/query.html:77\nmsgid \"Description\"\nmsgstr \"Описание\"\n\n#: explorer/templates/explorer/query.html:124\nmsgid \"Save & Run\"\nmsgstr \"Сохранить и запустить\"\n\n#: explorer/templates/explorer/query.html:133\nmsgid \"Save Only\"\nmsgstr \"Сохранить\"\n\n#: explorer/templates/explorer/query.html:176\nmsgid \"Snapshot\"\nmsgstr \"Вариант\"\n\n#: explorer/templates/explorer/query.html:182\n#, python-format\nmsgid \"\"\n\"Avg. execution: %(avg_duration|floatformat:2)sms. Query created by \"\n\"%(user_email)s on %(created)s.\"\nmsgstr \"\"\n\"Среднее время выполнения: %(avg_duration|floatformat:2)s мс. Запрос в \"\n\"%(created)s. от «%(user_email)s»\"\n\n#: explorer/templates/explorer/query_confirm_delete.html:7\n#, python-format\nmsgid \"Are you sure you want to delete \\\"%(title)s\\\"?\"\nmsgstr \"Вы уверены в удалении «%(title)s»?\"\n\n#: explorer/templates/explorer/query_list.html:15\n#, python-format\nmsgid \"Recently Run by You\"\nmsgstr \"Ваши последние запуски запросов, их %(qlen)s\"\n\n#: explorer/templates/explorer/query_list.html:23\nmsgid \"Last Run\"\nmsgstr \"Последний запуск\"\n\n#: explorer/templates/explorer/query_list.html:48\nmsgid \"All Queries\"\nmsgstr \"Все запросы\"\n\n#: explorer/templates/explorer/query_list.html:51\n#: explorer/templates/explorer/schema.html:10\nmsgid \"Search\"\nmsgstr \"Поиск\"\n\n#: explorer/templates/explorer/query_list.html:58\nmsgid \"Created\"\nmsgstr \"Создан\"\n\n#: explorer/templates/explorer/query_list.html:60\nmsgid \"Email\"\nmsgstr \"Емейл\"\n\n#: explorer/templates/explorer/query_list.html:62\nmsgid \"CSV\"\nmsgstr \"CSV\"\n\n#: explorer/templates/explorer/query_list.html:64\nmsgid \"Play\"\nmsgstr \"Запустить\"\n\n#: explorer/templates/explorer/query_list.html:65\nmsgid \"Delete\"\nmsgstr \"Удалить\"\n\n#: explorer/templates/explorer/query_list.html:67\nmsgid \"Run Count\"\nmsgstr \"Число запусков\"\n\n#: explorer/templates/explorer/query_list.html:87\n#, python-format\nmsgid \"by %(cuser)s\"\nmsgstr \" пользователем «%(cuser)s»\"\n\n#: explorer/templates/explorer/querylog_list.html:13\n#, python-format\nmsgid \"Recent Query Logs - Page %(pagenum)s\"\nmsgstr \"Журнал последних запросов — страница %(pagenum)s \"\n\n#: explorer/templates/explorer/querylog_list.html:18\nmsgid \"Run At\"\nmsgstr \"Время запуска\"\n\n#: explorer/templates/explorer/querylog_list.html:19\nmsgid \"Run By\"\nmsgstr \"Запущено пользователем\"\n\n#: explorer/templates/explorer/querylog_list.html:20\nmsgid \"Duration\"\nmsgstr \"Длительность\"\n\n#: explorer/templates/explorer/querylog_list.html:22\nmsgid \"Query ID\"\nmsgstr \"ID запроса\"\n\n#: explorer/templates/explorer/querylog_list.html:36\n#, python-format\nmsgid \"Query %(query_id)s\"\nmsgstr \"пользователем «%(query_id)s»\"\n\n#: explorer/templates/explorer/querylog_list.html:48\nmsgid \"Open\"\nmsgstr \"Открыть\"\n\n#: explorer/templates/explorer/querylog_list.html:63\n#, python-format\nmsgid \"Page %(pnum)s of %(anum)s.\"\nmsgstr \"Страница %(pnum)s из %(anum)s.\"\n\n#: explorer/templates/explorer/schema.html:7\nmsgid \"Schema\"\nmsgstr \"Схема\"\n\n#: explorer/templates/explorer/schema.html:15\nmsgid \"Collapse All\"\nmsgstr \"Свернуть все\"\n\n#: explorer/templates/explorer/schema.html:20\nmsgid \"Expand All\"\nmsgstr \"Развернуть все\"\n\n#: explorer/templates/explorer/schema_building.html:6\nmsgid \"Schema is building...\"\nmsgstr \"Строится схема…\"\n\n#: explorer/templates/explorer/schema_building.html:7\nmsgid \"Please wait a minute, and refresh.\"\nmsgstr \"Подождите минутку и обновите.\"\n\n#: explorer/views/query.py:125\nmsgid \"Query saved.\"\nmsgstr \"Запрос сохранен.\"\n\n#~ msgid \"SQL\"\n#~ msgstr \"SQL\"\n\n#~ msgid \"Avg. execution:\"\n#~ msgstr \"Среднее время запроса\"\n\n#~ msgid \"Query created by\"\n#~ msgstr \"Запрос создан\"\n\n#~ msgid \"on\"\n#~ msgstr \"в\"\n"
  },
  {
    "path": "explorer/locale/zh_Hans/LC_MESSAGES/django.po",
    "content": "# SOME DESCRIPTIVE TITLE.\n# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER\n# This file is distributed under the same license as the PACKAGE package.\n# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n#\n#, fuzzy\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: PACKAGE VERSION\\n\"\n\"Report-Msgid-Bugs-To: \\n\"\n\"POT-Creation-Date: 2018-10-21 13:58+0800\\n\"\n\"PO-Revision-Date: 2018-10-21 13:58+0806\\n\"\n\"Last-Translator: b'  <admin@xx.com>'\\n\"\n\"Language-Team: LANGUAGE <LL@li.org>\\n\"\n\"Language: \\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=UTF-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Plural-Forms: nplurals=1; plural=0;\\n\"\n\"X-Translated-Using: django-rosetta 0.9.0\\n\"\n\n#: apps.py:10 templates/explorer/base.html:9 templates/explorer/base.html:33\nmsgid \"SQL Explorer\"\nmsgstr \"SQL浏览器\"\n\n#: models.py:38\nmsgid \"Include in snapshot task (if enabled)\"\nmsgstr \"包含在快照任务中（如果启用了的话）\"\n\n#: models.py:40\nmsgid \"\"\n\"Name of DB connection (as specified in settings) to use for this query. Will\"\n\" use EXPLORER_DEFAULT_CONNECTION if left blank\"\nmsgstr \"该查询需要使用的数据库连接名字（配置文件中设定的），默认使用 EXPLORER_DEFAULT_CONNECTION\"\n\n#: models.py:49 templates/explorer/query_list.html:49\nmsgid \"Query\"\nmsgstr \"查询语句\"\n\n#: models.py:50\nmsgid \"Queries\"\nmsgstr \"查询语句\"\n\n#: templates/explorer/play.html:7 templates/explorer/query.html:7\n#: templates/explorer/query.html:17 templates/explorer/query_list.html:6\n#: templates/explorer/querylog_list.html:6\nmsgid \"New Query\"\nmsgstr \"新增查询\"\n\n#: templates/explorer/play.html:8 templates/explorer/play.html:16\n#: templates/explorer/query.html:8 templates/explorer/query_list.html:7\n#: templates/explorer/querylog_list.html:7\nmsgid \"Playground\"\nmsgstr \"实验场\"\n\n#: templates/explorer/play.html:9 templates/explorer/query.html:11\n#: templates/explorer/query_list.html:8\n#: templates/explorer/querylog_list.html:8\nmsgid \"Logs\"\nmsgstr \"日志\"\n\n#: templates/explorer/play.html:17\nmsgid \"\"\n\"The playground is for experimenting and writing ad-hoc queries. By default, \"\n\"nothing you do here will be saved.\"\nmsgstr \"试验场用于实验以及编写临时查询语句。默认情况下，这里所有操作都不会被保存。\"\n\n#: templates/explorer/play.html:24 templates/explorer/query.html:47\nmsgid \"Connection\"\nmsgstr \"连接\"\n\n#: templates/explorer/play.html:39\nmsgid \"Playground SQL\"\nmsgstr \"实验SQL\"\n\n#: templates/explorer/play.html:55 templates/explorer/query.html:110\nmsgid \"Refresh\"\nmsgstr \"刷新\"\n\n#: templates/explorer/play.html:58 templates/explorer/play.html:68\n#: templates/explorer/query.html:98 templates/explorer/query.html:115\nmsgid \"Toggle Dropdown\"\nmsgstr \"切换下拉框\"\n\n#: templates/explorer/play.html:61\nmsgid \"Save As New Query\"\nmsgstr \"保存为新的查询语句\"\n\n#: templates/explorer/play.html:65 templates/explorer/query.html:112\nmsgid \"Download\"\nmsgstr \"下载\"\n\n#: templates/explorer/play.html:75 templates/explorer/query.html:106\nmsgid \"Show Schema\"\nmsgstr \"显示表结构\"\n\n#: templates/explorer/play.html:76 templates/explorer/query.html:107\nmsgid \"Hide Schema\"\nmsgstr \"隐藏表结构\"\n\n#: templates/explorer/play.html:77 templates/explorer/query.html:108\nmsgid \"Format\"\nmsgstr \"格式\"\n\n#: templates/explorer/play.html:80\nmsgid \"Playground Query\"\nmsgstr \"实验查询语句\"\n\n#: templates/explorer/preview_pane.html:7\nmsgid \"Preview\"\nmsgstr \"预览\"\n\n#: templates/explorer/preview_pane.html:8\nmsgid \"Snapshots\"\nmsgstr \"快照\"\n\n#: templates/explorer/preview_pane.html:9\nmsgid \"Pivot\"\nmsgstr \"\"\n\n#: templates/explorer/query.html:10\nmsgid \"Query Detail\"\nmsgstr \"查询细节\"\n\n#: templates/explorer/query.html:20\nmsgid \"History\"\nmsgstr \"历史\"\n\n#: templates/explorer/query.html:62\nmsgid \"Description\"\nmsgstr \"描述\"\n\n#: templates/explorer/query.html:95\nmsgid \"Save & Run\"\nmsgstr \"保存并运行\"\n\n#: templates/explorer/query.html:103\nmsgid \"Save Only\"\nmsgstr \"保存\"\n\n#: templates/explorer/query_list.html:40\nmsgid \"All Queries\"\nmsgstr \"所有查询语句\"\n\n#: templates/explorer/query_list.html:43\nmsgid \"Search\"\nmsgstr \"搜索\"\n\n#: templates/explorer/query_list.html:50\nmsgid \"Created\"\nmsgstr \"创建时间\"\n\n#: templates/explorer/query_list.html:52\nmsgid \"Email\"\nmsgstr \"邮箱\"\n\n#: templates/explorer/query_list.html:54\nmsgid \"CSV\"\nmsgstr \"CSV\"\n\n#: templates/explorer/query_list.html:56\nmsgid \"Play\"\nmsgstr \"执行\"\n\n#: templates/explorer/query_list.html:57\nmsgid \"Delete\"\nmsgstr \"删除\"\n\n#: templates/explorer/query_list.html:59\nmsgid \"Run Count\"\nmsgstr \"运行次数\"\n\n#: templates/explorer/querylog_list.html:13\n#, python-format\nmsgid \"Recent Query Logs - Page %(page_obj.number)s\"\nmsgstr \"最近的查询日志 - %(page_obj.number)s页\"\n\n#: templates/explorer/schema.html:14\nmsgid \"Collapse All\"\nmsgstr \"全部收起\"\n\n#: templates/explorer/schema.html:17\nmsgid \"Expand All\"\nmsgstr \"全部展开\"\n"
  },
  {
    "path": "explorer/migrations/0001_initial.py",
    "content": "import django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='Query',\n            fields=[\n                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),\n                ('title', models.CharField(max_length=255)),\n                ('sql', models.TextField(blank=True)),\n                ('description', models.TextField(blank=True)),\n                ('created_at', models.DateTimeField(auto_now_add=True)),\n                ('last_run_date', models.DateTimeField(auto_now=True)),\n                ('created_by_user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),\n            ],\n            options={\n                'ordering': ['title'],\n                'verbose_name_plural': 'Queries',\n            },\n            bases=(models.Model,),\n        ),\n        migrations.CreateModel(\n            name='QueryLog',\n            fields=[\n                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),\n                ('sql', models.TextField(blank=True)),\n                ('is_playground', models.BooleanField(default=False)),\n                ('run_at', models.DateTimeField(auto_now_add=True)),\n                ('query', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='explorer.Query', null=True)),\n                ('run_by_user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),\n            ],\n            options={\n                'ordering': ['-run_at'],\n            },\n            bases=(models.Model,),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0002_auto_20150501_1515.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0001_initial'),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name='querylog',\n            name='is_playground',\n        ),\n        migrations.AlterField(\n            model_name='querylog',\n            name='sql',\n            field=models.TextField(blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0003_query_snapshot.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0002_auto_20150501_1515'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='query',\n            name='snapshot',\n            field=models.BooleanField(default=False, help_text=b'Include in snapshot task (if enabled)'),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0004_querylog_duration.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0003_query_snapshot'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='querylog',\n            name='duration',\n            field=models.FloatField(null=True, blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0005_auto_20160105_2052.py",
    "content": "# Generated by Django 1.9 on 2016-01-05 20:52\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0004_querylog_duration'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='query',\n            name='snapshot',\n            field=models.BooleanField(default=False, help_text='Include in snapshot task (if enabled)'),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0006_query_connection.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0005_auto_20160105_2052'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='query',\n            name='connection',\n            field=models.CharField(help_text=b'Name of DB connection (as specified in settings) to use for this query. Will use EXPLORER_DEFAULT_CONNECTION if left blank', max_length=128, null=True, blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0007_querylog_connection.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0006_query_connection'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='querylog',\n            name='connection',\n            field=models.CharField(max_length=128, null=True, blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0008_auto_20190308_1642.py",
    "content": "from django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0007_querylog_connection'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='query',\n            name='connection',\n            field=models.CharField(blank=True, help_text='Name of DB connection (as specified in settings) to use for this query. Will use EXPLORER_DEFAULT_CONNECTION if left blank', max_length=128, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0009_auto_20201009_0547.py",
    "content": "# Generated by Django 3.1.2 on 2020-10-09 05:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0008_auto_20190308_1642'),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name='query',\n            options={'ordering': ['title'], 'verbose_name': 'Query', 'verbose_name_plural': 'Queries'},\n        ),\n        migrations.AlterField(\n            model_name='query',\n            name='connection',\n            field=models.CharField(blank=True, default='', help_text='Name of DB connection (as specified in settings) to use for this query.Will use EXPLORER_DEFAULT_CONNECTION if left blank', max_length=128),\n        ),\n        migrations.AlterField(\n            model_name='querylog',\n            name='connection',\n            field=models.CharField(blank=True, default='', max_length=128),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0010_sql_required.py",
    "content": "# Generated by Django 3.0.8 on 2020-12-18 06:24\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0009_auto_20201009_0547'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='query',\n            name='sql',\n            field=models.TextField(),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0011_query_favorites.py",
    "content": "# Generated by Django 3.2.16 on 2023-01-12 18:24\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        ('explorer', '0010_sql_required'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='QueryFavorite',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('query', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='explorer.query')),\n                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),\n            ],\n            options={\n                'unique_together': {('query', 'user')},\n            },\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0012_alter_queryfavorite_query_alter_queryfavorite_user.py",
    "content": "# Generated by Django 4.1.7 on 2023-02-27 08:37\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"explorer\", \"0011_query_favorites\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"queryfavorite\",\n            name=\"query\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"favorites\",\n                to=\"explorer.query\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"queryfavorite\",\n            name=\"user\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"favorites\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0013_querylog_error_querylog_success.py",
    "content": "# Generated by Django 4.2.8 on 2023-12-15 09:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0012_alter_queryfavorite_query_alter_queryfavorite_user'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='querylog',\n            name='error',\n            field=models.TextField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name='querylog',\n            name='success',\n            field=models.BooleanField(default=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0014_promptlog.py",
    "content": "# Generated by Django 4.2.8 on 2024-01-11 08:22\n\nfrom django.conf import settings\nfrom django.db import migrations, models\nimport django.db.models.deletion\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        ('explorer', '0013_querylog_error_querylog_success'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='PromptLog',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('prompt', models.TextField(blank=True)),\n                ('response', models.TextField(blank=True)),\n                ('run_at', models.DateTimeField(auto_now_add=True)),\n                ('duration', models.FloatField(blank=True, null=True)),\n                ('model', models.CharField(blank=True, default='', max_length=128)),\n                ('error', models.TextField(blank=True, null=True)),\n                ('run_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0015_explorervalue.py",
    "content": "# Generated by Django 4.2.8 on 2024-04-25 13:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0014_promptlog'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='ExplorerValue',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('key', models.CharField(choices=[('UUID', 'Install Unique ID'), ('SMLS', 'Startup metric last send')], max_length=5)),\n                ('value', models.TextField(blank=True, null=True)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0016_alter_explorervalue_key.py",
    "content": "# Generated by Django 4.2.8 on 2024-04-26 13:05\n\nfrom django.db import migrations, models\n\n\ndef insert_assistant_prompt(apps, schema_editor):\n\n    ExplorerValue = apps.get_model('explorer', 'ExplorerValue')\n    ExplorerValue.objects.get_or_create(\n        key=\"ASP\",\n        value=\"\"\"You are a data analyst's assistant and will be asked write or modify a SQL query to assist a business\nuser with their analysis. The user will provide a prompt of what they are looking for help with, and may also\nprovide SQL they have written so far, relevant table schema, and sample rows from the tables they are querying.\n\nFor complex requests, you may use Common Table Expressions (CTEs) to break down the problem into smaller parts.\nCTEs are not needed for simpler requests.\n\"\"\"\n    )\n\ndef insert_assistant_prompt_reverse(apps, schema_editor):\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0015_explorervalue'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='explorervalue',\n            name='key',\n            field=models.CharField(choices=[('UUID', 'Install Unique ID'), ('SMLS', 'Startup metric last send'), ('ASP', 'System prompt for SQL Assistant')], max_length=5, unique=True),\n        ),\n        migrations.RunPython(insert_assistant_prompt, reverse_code=insert_assistant_prompt_reverse),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0017_databaseconnection.py",
    "content": "# Generated by Django 5.0.4 on 2024-05-07 18:41\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0016_alter_explorervalue_key'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='DatabaseConnection',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('alias', models.CharField(max_length=255, unique=True)),\n                ('engine', models.CharField(choices=[('django.db.backends.sqlite3', 'SQLite3'), ('django.db.backends.postgresql_psycopg2', 'PostgreSQL'), ('django.db.backends.mysql', 'MySQL'), ('django.db.backends.oracle', 'Oracle')], max_length=255)),\n                ('name', models.CharField(max_length=255)),\n                ('user', models.CharField(blank=True, max_length=255)),\n                ('password', models.CharField(blank=True, max_length=255)),\n                ('host', models.CharField(blank=True, max_length=255)),\n                ('port', models.CharField(blank=True, max_length=255)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0018_alter_databaseconnection_host_and_more.py",
    "content": "# Generated by Django 5.0.4 on 2024-05-14 15:55\n\nimport django_cryptography.fields\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0017_databaseconnection'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='host',\n            field=django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=255)),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='password',\n            field=django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=255)),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='user',\n            field=django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=255)),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0019_alter_databaseconnection_engine.py",
    "content": "# Generated by Django 5.0.5 on 2024-07-03 12:38\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0018_alter_databaseconnection_host_and_more'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='engine',\n            field=models.CharField(choices=[('django.db.backends.sqlite3', 'SQLite3'), ('django.db.backends.postgresql', 'PostgreSQL'), ('django.db.backends.mysql', 'MySQL'), ('django.db.backends.oracle', 'Oracle'), ('django.db.backends.mysql', 'MariaDB'), ('django_cockroachdb', 'CockroachDB'), ('django.db.backends.sqlserver', 'SQL Server (mssql-django)')], max_length=255),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0020_databaseconnection_extras_and_more.py",
    "content": "# Generated by Django 5.0.4 on 2024-07-08 01:17\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0019_alter_databaseconnection_engine'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='databaseconnection',\n            name='extras',\n            field=models.JSONField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='engine',\n            field=models.CharField(choices=[('django.db.backends.sqlite3', 'SQLite3'), ('django.db.backends.postgresql', 'PostgreSQL'), ('django.db.backends.mysql', 'MySQL'), ('django.db.backends.oracle', 'Oracle'), ('django.db.backends.mysql', 'MariaDB'), ('django_cockroachdb', 'CockroachDB'), ('mssql', 'SQL Server (mssql-django)'), ('django_snowflake', 'Snowflake')], max_length=255),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0021_alter_databaseconnection_password_and_more.py",
    "content": "# Generated by Django 5.0.4 on 2024-07-16 01:43\n\nimport django_cryptography.fields\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0020_databaseconnection_extras_and_more'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='password',\n            field=django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=255, null=True)),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='user',\n            field=django_cryptography.fields.encrypt(models.CharField(blank=True, max_length=255, null=True)),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0022_databaseconnection_upload_fingerprint.py",
    "content": "# Generated by Django 5.0.4 on 2024-07-24 20:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0021_alter_databaseconnection_password_and_more'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='databaseconnection',\n            name='upload_fingerprint',\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0023_query_database_connection_and_more.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-03 16:54\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0022_databaseconnection_upload_fingerprint'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='query',\n            name='database_connection',\n            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='explorer.databaseconnection'),\n        ),\n        migrations.AddField(\n            model_name='querylog',\n            name='database_connection',\n            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='explorer.databaseconnection'),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='engine',\n            field=models.CharField(\n                choices=[('django.db.backends.sqlite3', 'SQLite3'), ('django.db.backends.postgresql', 'PostgreSQL'),\n                         ('django.db.backends.mysql', 'MySQL'), ('django.db.backends.oracle', 'Oracle'),\n                         ('django.db.backends.mysql', 'MariaDB'), ('django_cockroachdb', 'CockroachDB'),\n                         ('mssql', 'SQL Server (mssql-django)'), ('django_snowflake', 'Snowflake'),\n                         ('django_connection', 'Django Connection')], max_length=255),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='name',\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n        migrations.AlterField(\n            model_name='databaseconnection',\n            name='port',\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n        migrations.AddField(\n            model_name='databaseconnection',\n            name='default',\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0024_auto_20240803_1135.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-03 16:35\n\nfrom django.db import migrations, connection\nfrom django.db import connections as djcs\nfrom explorer import app_settings\n\n\ndef populate_new_foreign_key(apps, _):\n    from explorer.app_settings import EXPLORER_DEFAULT_CONNECTION\n    DatabaseConnection = apps.get_model('explorer', 'DatabaseConnection')\n\n    # Create new connection for each based on EXPLORER_CONNECTIONS\n    connections = [c for c in djcs if c in app_settings.EXPLORER_CONNECTIONS.values()]\n    for c in connections:\n        if not DatabaseConnection.objects.filter(alias=c).exists():\n            is_default = c == EXPLORER_DEFAULT_CONNECTION\n            obj = DatabaseConnection(\n                alias=c,\n                name=c,\n                engine=\"django_connection\",\n                default=is_default\n            )\n            obj.save()\n            print(f\"created Connection ID {obj.id} for {obj.alias}\")\n\n    has_default = DatabaseConnection.objects.filter(default=True).exists()\n    if not has_default:\n        dbc = DatabaseConnection.objects.all().first()\n        if dbc:\n            dbc.default = True\n            dbc.save()\n\n    # These are written as raw SQL rather than queryset updates because e.g. Query.connection is no longer\n    # referencable as it was removed from the codebase.\n    with connection.cursor() as cursor:\n        for c in DatabaseConnection.objects.all():\n            # Update Query table\n            cursor.execute(\"\"\"\n                UPDATE explorer_query\n                SET database_connection_id = %s\n                WHERE connection = %s\n            \"\"\", [c.id, c.alias])\n\n            # Update QueryLog table\n            cursor.execute(\"\"\"\n                UPDATE explorer_querylog\n                SET database_connection_id = %s\n                WHERE connection = %s\n            \"\"\", [c.id, c.alias])\n\n        if EXPLORER_DEFAULT_CONNECTION:\n            default_conn = DatabaseConnection.objects.filter(alias=EXPLORER_DEFAULT_CONNECTION).first()\n            if default_conn:\n                cursor.execute(\"\"\"\n                    UPDATE explorer_query\n                    SET database_connection_id = %s\n                    WHERE connection = ''\n                \"\"\", [default_conn.id])\n\n                cursor.execute(\"\"\"\n                    UPDATE explorer_querylog\n                    SET database_connection_id = %s\n                    WHERE connection = ''\n                \"\"\", [default_conn.id])\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0023_query_database_connection_and_more'),\n    ]\n\n    operations = [\n        migrations.RunPython(populate_new_foreign_key),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0025_alter_query_database_connection_alter_querylog_database_connection.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-13 13:45\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0024_auto_20240803_1135'),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name='query',\n            name='database_connection',\n            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='explorer.databaseconnection'),\n        ),\n        migrations.AlterField(\n            model_name='querylog',\n            name='database_connection',\n            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='explorer.databaseconnection'),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0026_tabledescription.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-22 01:24\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0025_alter_query_database_connection_alter_querylog_database_connection'),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name='TableDescription',\n            fields=[\n                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),\n                ('table_name', models.CharField(max_length=512)),\n                ('description', models.TextField()),\n                ('database_connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='explorer.databaseconnection')),\n            ],\n            options={\n                'unique_together': {('database_connection', 'table_name')},\n            },\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0027_query_few_shot.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-25 21:26\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0026_tabledescription'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='query',\n            name='few_shot',\n            field=models.BooleanField(default=False, help_text='Will be included as a good example of SQL in assistant queries that use relevant tables'),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/0028_promptlog_database_connection_promptlog_user_request.py",
    "content": "# Generated by Django 5.0.4 on 2024-08-27 18:59\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        ('explorer', '0027_query_few_shot'),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name='promptlog',\n            name='database_connection',\n            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='explorer.databaseconnection'),\n        ),\n        migrations.AddField(\n            model_name='promptlog',\n            name='user_request',\n            field=models.TextField(blank=True),\n        ),\n    ]\n"
  },
  {
    "path": "explorer/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "explorer/models.py",
    "content": "import logging\nfrom time import time\nimport uuid\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.db import DatabaseError, models, transaction\nfrom django.urls import reverse\nfrom django.utils.translation import gettext_lazy as _\n\nfrom explorer import app_settings\nfrom explorer.telemetry import Stat, StatNames\nfrom explorer.utils import (\n    extract_params, get_params_for_url, get_s3_bucket, passes_blacklist, s3_url,\n    shared_dict_update, swap_params,\n)\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\n# Issue #618. All models must be imported so that Django understands how to manage migrations for the app\nfrom explorer.ee.db_connections.models import DatabaseConnection  # noqa\nfrom explorer.assistant.models import PromptLog, TableDescription  # noqa\n\nMSG_FAILED_BLACKLIST = \"Query failed the SQL blacklist: %s\"\n\nlogger = logging.getLogger(__name__)\n\n\nclass Query(models.Model):\n    title = models.CharField(max_length=255)\n    sql = models.TextField(blank=False, null=False)\n    description = models.TextField(blank=True)\n    created_by_user = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        null=True,\n        blank=True,\n        on_delete=models.CASCADE\n    )\n    created_at = models.DateTimeField(auto_now_add=True)\n    last_run_date = models.DateTimeField(auto_now=True)\n    snapshot = models.BooleanField(\n        default=False,\n        help_text=_(\"Include in snapshot task (if enabled)\")\n    )\n    # NOTE this field is deprecated in favor of database_connection and no longer in use.\n    # It is present in the 6.0 release to preserve backwards compatibility in case there is need for a rollback.\n    # It will be removed in a future release (e.g. v6.1)\n    connection = models.CharField(\n        blank=True,\n        max_length=128,\n        default=\"\",\n        help_text=_(\n            \"Name of DB connection (as specified in settings) to use for \"\n            \"this query.\"\n            \"Will use EXPLORER_DEFAULT_CONNECTION if left blank\"\n        )\n    )\n    database_connection = models.ForeignKey(to=DatabaseConnection, on_delete=models.SET_NULL, null=True)\n    few_shot = models.BooleanField(default=False, help_text=_(\n        \"Will be included as a good example of SQL in assistant queries that use relevant tables\"))\n\n    def __init__(self, *args, **kwargs):\n        self.params = kwargs.get(\"params\")\n        kwargs.pop(\"params\", None)\n        super().__init__(*args, **kwargs)\n\n    class Meta:\n        ordering = [\"title\"]\n        verbose_name = _(\"Query\")\n        verbose_name_plural = _(\"Queries\")\n\n    def __str__(self):\n        return str(self.title)\n\n    def get_run_count(self):\n        return self.querylog_set.count()\n\n    def last_run_log(self):\n        ql = self.querylog_set.first()\n        return ql or QueryLog(success=True, run_at=self.created_at)\n\n    def avg_duration_display(self):\n        d = self.avg_duration()\n        if d:\n            return f\"{self.avg_duration():10.3f}\"\n        return \"\"\n\n    def avg_duration(self):\n        return self.querylog_set.aggregate(\n            models.Avg(\"duration\")\n        )[\"duration__avg\"]\n\n    def passes_blacklist(self):\n        return passes_blacklist(self.final_sql())\n\n    def final_sql(self):\n        return swap_params(self.sql, self.available_params())\n\n    def execute_query_only(self):\n        # check blacklist every time sql is run to catch parameterized SQL\n        passes_blacklist_flag, failing_words = self.passes_blacklist()\n\n        error = MSG_FAILED_BLACKLIST % \", \".join(\n            failing_words) if not passes_blacklist_flag else None\n\n        if error:\n            raise ValidationError(\n                error,\n                code=\"InvalidSql\"\n            )\n        conn = self.database_connection or default_db_connection()\n        return QueryResult(\n            self.final_sql(), conn.as_django_connection()\n        )\n\n    def execute_with_logging(self, executing_user):\n        ql = self.log(executing_user)\n        ql.save()\n        try:\n            ret = self.execute()\n        except DatabaseError as e:\n            ql.success = False\n            ql.error = str(e)\n            ql.save()\n            raise e\n        ql.duration = ret.duration\n        ql.save()\n        Stat(StatNames.QUERY_RUN,\n             {\"sql_len\": len(ql.sql), \"duration\": ql.duration}).track()\n        return ret, ql\n\n    def execute(self):\n        ret = self.execute_query_only()\n        ret.process()\n        return ret\n\n    def available_params(self):\n        \"\"\"\n        Merge parameter values into a dictionary of available parameters\n\n        :return: A merged dictionary of parameter names and values.\n                 Values of non-existent parameters are removed.\n        :rtype: dict\n        \"\"\"\n        p = extract_params(self.sql)\n        p2 = {k: v[\"default\"] for k, v in p.items()}\n\n        if self.params:\n            shared_dict_update(p2, self.params)\n        return p2\n\n    def available_params_w_labels(self):\n        \"\"\"\n        Merge parameter values into a dictionary of available parameters with their labels\n\n        :return: A merged dictionary of parameter names and values/labels.\n                 Values of non-existent parameters are removed.\n        :rtype: dict\n        \"\"\"\n        p = extract_params(self.sql)\n        return {\n            k: {\n                \"label\": v[\"label\"] if v[\"label\"] else k,\n                \"val\": self.params[k] if self.params and k in self.params else v[\"default\"]\n            } for k, v in p.items()\n        }\n\n    def get_absolute_url(self):\n        return reverse(\"query_detail\", kwargs={\"query_id\": self.id})\n\n    @property\n    def params_for_url(self):\n        return get_params_for_url(self)\n\n    def log(self, user=None):\n        if user:\n            if user.is_anonymous:\n                user = None\n        ql = QueryLog(\n            sql=self.final_sql(),\n            query_id=self.id,\n            run_by_user=user,\n            database_connection=self.database_connection,\n        )\n        ql.save()\n        return ql\n\n    @property\n    def shared(self):\n        return self.id in set(\n            sum(app_settings.EXPLORER_GET_USER_QUERY_VIEWS().values(), [])\n        )\n\n    @property\n    def snapshots(self):\n        if app_settings.ENABLE_TASKS:\n            b = get_s3_bucket()\n            objects = b.objects.filter(Prefix=f\"query-{self.id}/snap-\")\n            objects_s = sorted(objects, key=lambda k: k.last_modified)\n            return [\n                SnapShot(\n                    s3_url(b, o.key),\n                    o.last_modified\n                ) for o in objects_s\n            ]\n\n    def is_favorite(self, user):\n        if user.is_authenticated:\n            return self.favorites.filter(user_id=user.id).exists()\n        else:\n            return False\n\n\nclass SnapShot:\n\n    def __init__(self, url, last_modified):\n        self.url = url\n        self.last_modified = last_modified\n\n\nclass QueryLog(models.Model):\n    sql = models.TextField(blank=True)\n    query = models.ForeignKey(\n        Query,\n        null=True,\n        blank=True,\n        on_delete=models.SET_NULL\n    )\n    run_by_user = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        null=True,\n        blank=True,\n        on_delete=models.CASCADE\n    )\n    run_at = models.DateTimeField(auto_now_add=True)\n    duration = models.FloatField(blank=True, null=True)  # milliseconds\n    # NOTE this field is deprecated in favor of database_connection and no longer in use.\n    # It is present in the 6.0 release to preserve backwards compatibility in case there is need for a rollback.\n    # It will be removed in a future release (e.g. v6.1)\n    connection = models.CharField(blank=True, max_length=128, default=\"\")\n    database_connection = models.ForeignKey(to=DatabaseConnection, on_delete=models.SET_NULL, null=True)\n    success = models.BooleanField(default=True)\n    error = models.TextField(blank=True, null=True)\n\n    @property\n    def is_playground(self):\n        return self.query_id is None\n\n    class Meta:\n        ordering = [\"-run_at\"]\n\n\nclass QueryFavorite(models.Model):\n    query = models.ForeignKey(\n        Query,\n        on_delete=models.CASCADE,\n        related_name=\"favorites\"\n    )\n    user = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        on_delete=models.CASCADE,\n        related_name=\"favorites\"\n    )\n\n    class Meta:\n        unique_together = [\"query\", \"user\"]\n\n\nclass QueryResult:\n\n    def __init__(self, sql, connection):\n\n        self.sql = sql\n        self.connection = connection\n\n        cursor, duration = self.execute_query()\n\n        self._description = cursor.description or []\n        self._data = [list(r) for r in cursor.fetchall()]\n        self.duration = duration\n\n        cursor.close()\n\n        self._headers = self._get_headers()\n        self._summary = {}\n\n    @property\n    def data(self):\n        return self._data or []\n\n    @property\n    def headers(self):\n        return self._headers or []\n\n    @property\n    def header_strings(self):\n        return [str(h) for h in self.headers]\n\n    def _get_headers(self):\n        return [\n            ColumnHeader(d[0]) for d in self._description\n        ] if self._description else [ColumnHeader(\"--\")]\n\n    def _get_numerics(self):\n        if hasattr(self.connection.Database, \"NUMBER\"):\n            return [\n                ix for ix, c in enumerate(self._description)\n                if hasattr(c, \"type_code\") and c.type_code in self.connection.Database.NUMBER.values\n            ]\n        elif self.data:\n            d = self.data[0]\n            return [\n                ix for ix, _ in enumerate(self._description)\n                if not isinstance(d[ix], str) and str(d[ix]).isnumeric()\n            ]\n        return []\n\n    def _get_transforms(self):\n        transforms = dict(app_settings.EXPLORER_TRANSFORMS)\n        return [\n            (ix, transforms[str(h)])\n            for ix, h in enumerate(self.headers) if str(h) in transforms.keys()\n        ]\n\n    def column(self, ix):\n        return [r[ix] for r in self.data]\n\n    def process(self):\n        start_time = time()\n\n        self.process_columns()\n        self.process_rows()\n\n        logger.info(\"Explorer Query Processing took %sms.\" % ((time() - start_time) * 1000))\n\n    def process_columns(self):\n        for ix in self._get_numerics():\n            self.headers[ix].add_summary(self.column(ix))\n\n    def process_rows(self):\n        transforms = self._get_transforms()\n        if transforms:\n            for r in self.data:\n                for ix, t in transforms:\n                    r[ix] = t.format(str(r[ix]))\n\n    def execute_query(self):\n        cursor = self.connection.cursor()\n        start_time = time()\n\n        try:\n            with transaction.atomic(self.connection.alias):\n                cursor.execute(self.sql)\n        except DatabaseError as e:\n            cursor.close()\n            raise e\n\n        return cursor, ((time() - start_time) * 1000)\n\n\nclass ColumnHeader:\n\n    def __init__(self, title):\n        self.title = title.strip()\n        self.summary = None\n\n    def add_summary(self, column):\n        self.summary = ColumnSummary(self, column)\n\n    def __str__(self):\n        return self.title\n\n\nclass ColumnStat:\n\n    def __init__(self, label, statfn, precision=2, handles_null=False):\n        self.label = label\n        self.statfn = statfn\n        self.precision = precision\n        self.handles_null = handles_null\n\n    def __call__(self, coldata):\n        self.value = round(\n            float(self.statfn(coldata)), self.precision\n        ) if coldata else 0\n\n    def __str__(self):\n        return self.label\n\n\nclass ColumnSummary:\n\n    def __init__(self, header, col):\n        self._header = header\n        self._stats = [\n            ColumnStat(\"Sum\", sum),\n            ColumnStat(\"Avg\", lambda x: float(sum(x)) / float(len(x))),\n            ColumnStat(\"Min\", min),\n            ColumnStat(\"Max\", max),\n            ColumnStat(\n                \"NUL\",\n                lambda x: int(sum(map(lambda y: 1 if y is None else 0, x))), 0, True\n            )\n        ]\n        without_nulls = list(map(lambda x: 0 if x is None else x, col))\n\n        for stat in self._stats:\n            stat(col) if stat.handles_null else stat(without_nulls)\n\n    @property\n    def stats(self):\n        return {c.label: c.value for c in self._stats}\n\n    def __str__(self):\n        return str(self._header)\n\n\nclass ExplorerValueManager(models.Manager):\n\n    def get_uuid(self):\n        # If blank or non-existing, generates a new UUID\n        uuid_obj, created = self.get_or_create(\n            key=ExplorerValue.INSTALL_UUID,\n            defaults={\"value\": str(uuid.uuid4())}\n        )\n        if created or uuid_obj.value is None:\n            uuid_obj.value = str(uuid.uuid4())\n            uuid_obj.save()\n        return uuid_obj.value\n\n    def get_startup_last_send(self):\n        # Stored as a Unix timestamp\n        try:\n            timestamp = self.get(key=ExplorerValue.STARTUP_METRIC_LAST_SEND).value\n            if timestamp:\n                return float(timestamp)\n            return None\n        except ExplorerValue.DoesNotExist:\n            return None\n\n    def set_startup_last_send(self, ts):\n        obj, created = self.get_or_create(\n            key=ExplorerValue.STARTUP_METRIC_LAST_SEND,\n            defaults={\"value\": str(ts)}\n        )\n        if not created:\n            obj.value = str(ts)\n            obj.save()\n\n    def get_item(self, key):\n        return self.filter(key=key).first()\n\n\nclass ExplorerValue(models.Model):\n    INSTALL_UUID = \"UUID\"\n    STARTUP_METRIC_LAST_SEND = \"SMLS\"\n    ASSISTANT_SYSTEM_PROMPT = \"ASP\"\n    EXPLORER_SETTINGS_CHOICES = [\n        (INSTALL_UUID, \"Install Unique ID\"),\n        (STARTUP_METRIC_LAST_SEND, \"Startup metric last send\"),\n        (ASSISTANT_SYSTEM_PROMPT, \"System prompt for SQL Assistant\"),\n    ]\n\n    key = models.CharField(max_length=5, choices=EXPLORER_SETTINGS_CHOICES, unique=True)\n    value = models.TextField(null=True, blank=True)\n\n    objects = ExplorerValueManager()\n"
  },
  {
    "path": "explorer/permissions.py",
    "content": "from explorer import app_settings\nfrom explorer.utils import allowed_query_pks, user_can_see_query\n\n\ndef view_permission(request, **kwargs):\n    return app_settings.EXPLORER_PERMISSION_VIEW(request)\\\n        or user_can_see_query(request, **kwargs)\\\n        or (app_settings.EXPLORER_TOKEN_AUTH_ENABLED()\n            and (request.headers.get(\"X-Api-Token\") ==\n                 app_settings.EXPLORER_TOKEN\n                 or request.GET.get(\"token\") == app_settings.EXPLORER_TOKEN))\n\n# Users who can only see some queries can still see the list.\n# Different than the above because it's not checking for any specific\n# query permissions.\n# And token auth does not give you permission to view the list.\n\n\ndef view_permission_list(request, *args, **kwargs):\n    return app_settings.EXPLORER_PERMISSION_VIEW(request)\\\n        or allowed_query_pks(request.user.id)\n\n\ndef change_permission(request, *args, **kwargs):\n    return app_settings.EXPLORER_PERMISSION_CHANGE(request)\n\n\ndef connections_permission(request, *args, **kwargs):\n    return app_settings.EXPLORER_PERMISSION_CONNECTIONS(request)\n"
  },
  {
    "path": "explorer/schema.py",
    "content": "from django.core.cache import cache\nfrom django.db import ProgrammingError\n\nfrom explorer.app_settings import (\n    EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES,\n    EXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES, EXPLORER_SCHEMA_INCLUDE_VIEWS,\n)\nfrom explorer.tasks import build_schema_cache_async\nfrom explorer.utils import InvalidExplorerConnectionException\n\n\n# These wrappers make it easy to mock and test\ndef _get_includes():\n    return EXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES\n\n\ndef _get_excludes():\n    return EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES\n\n\ndef _include_views():\n    return EXPLORER_SCHEMA_INCLUDE_VIEWS is True\n\n\ndef _include_table(t):\n    if _get_includes() is not None:\n        return any([t.startswith(p) for p in _get_includes()])\n    return not any([t.startswith(p) for p in _get_excludes()])\n\n\ndef connection_schema_cache_key(connection_id):\n    return f\"_explorer_cache_key_{connection_id}\"\n\n\ndef connection_schema_json_cache_key(connection_id):\n    return f\"_explorer_cache_key_json_{connection_id}\"\n\n\ndef transform_to_json_schema(schema_info):\n    json_schema = {}\n    for table_name, columns in schema_info:\n        json_schema[table_name] = []\n        for column_name, _ in columns:\n            json_schema[table_name].append(column_name)\n    return json_schema\n\n\ndef schema_json_info(db_connection):\n    key = connection_schema_json_cache_key(db_connection.id)\n    ret = cache.get(key)\n    if ret:\n        return ret\n    try:\n        si = schema_info(db_connection) or []\n    except InvalidExplorerConnectionException:\n        return []\n    json_schema = transform_to_json_schema(si)\n    cache.set(key, json_schema)\n    return json_schema\n\n\ndef schema_info(db_connection):\n    key = connection_schema_cache_key(db_connection.id)\n    ret = cache.get(key)\n    if ret:\n        return ret\n    else:\n        return build_schema_cache_async(db_connection.id)\n\n\ndef clear_schema_cache(db_connection):\n    key = connection_schema_cache_key(db_connection.id)\n    cache.delete(key)\n\n    key = connection_schema_json_cache_key(db_connection.id)\n    cache.delete(key)\n\n\ndef build_schema_info(db_connection):\n    \"\"\"\n        Construct schema information via engine-specific queries of the\n        tables in the DB.\n\n        :return: Schema information of the following form,\n                 sorted by db_table_name.\n            [\n                (\"db_table_name\",\n                    [\n                        (\"db_column_name\", \"DbFieldType\"),\n                        (...),\n                    ]\n                )\n            ]\n\n        \"\"\"\n    connection = db_connection.as_django_connection()\n    ret = []\n    with connection.cursor() as cursor:\n        tables_to_introspect = connection.introspection.table_names(\n            cursor, include_views=_include_views()\n        )\n\n        for table_name in tables_to_introspect:\n            if not _include_table(table_name):\n                continue\n            try:\n                table_description = connection.introspection.get_table_description(\n                    cursor, table_name\n                )\n            # Issue 675. A connection maybe not have permissions to access some tables in the DB.\n            except ProgrammingError:\n                continue\n\n            td = []\n            for row in table_description:\n                column_name = row[0]\n                try:\n                    field_type = connection.introspection.get_field_type(\n                        row[1], row\n                    )\n                except KeyError:\n                    field_type = \"Unknown\"\n                td.append((column_name, field_type))\n            ret.append((table_name, td))\n    return ret\n\n\n"
  },
  {
    "path": "explorer/src/js/assistant.js",
    "content": "import {getCsrfToken} from \"./csrf\";\nimport { marked } from \"marked\";\nimport DOMPurify from \"dompurify\";\nimport * as bootstrap from \"bootstrap\";\nimport { SchemaSvc, getConnElement } from \"./schemaService\"\nimport Choices from \"choices.js\"\n\nfunction getErrorMessage() {\n    const errorElement = document.querySelector('.alert-danger.db-error');\n    return errorElement ? errorElement.textContent.trim() : null;\n}\n\nfunction debounce(func, delay) {\n    let timeout;\n    return function(...args) {\n        clearTimeout(timeout);\n        timeout = setTimeout(() => func.apply(this, args), delay);\n    };\n}\n\nfunction setupTableList() {\n\n    if(window.assistantChoices) {\n        window.assistantChoices.destroy();\n    }\n\n    SchemaSvc.get().then(schema => {\n        const keys = Object.keys(schema);\n        const selectElement = document.createElement('select');\n        selectElement.className = 'js-choice';\n        selectElement.toggleAttribute('multiple');\n        selectElement.toggleAttribute('data-trigger');\n\n        keys.forEach((key) => {\n            const option = document.createElement('option');\n            option.value = key;\n            option.textContent = key;\n            selectElement.appendChild(option);\n        });\n\n        const tableList = document.getElementById('table-list');\n        tableList.innerHTML = '';\n        tableList.appendChild(selectElement);\n\n        const choices = new Choices('.js-choice', {\n            removeItemButton: true,\n            searchEnabled: true,\n            shouldSort: false,\n            position: 'bottom',\n            placeholderValue: \"Click to search for relevant tables\"\n        });\n\n        // TODO - nasty. Should be refactored. Used by submitAssistantAsk to get relevant tables.\n        window.assistantChoices = choices;\n\n        const selectAllButton = document.getElementById('select_all_button');\n        selectAllButton.addEventListener('click', (e) => {\n            e.preventDefault();\n            choices.setChoiceByValue(keys);\n        });\n\n        const deselectAllButton = document.getElementById('deselect_all_button');\n        deselectAllButton.addEventListener('click', (e) => {\n            e.preventDefault();\n            keys.forEach(k => {\n                choices.removeActiveItemsByValue(k);\n            });\n        });\n\n        const refreshTables = document.getElementById('refresh_tables_button');\n        refreshTables.addEventListener('click', (e) => {\n            e.preventDefault();\n            keys.forEach(k => {\n                choices.removeActiveItemsByValue(k);\n            });\n            selectRelevantTablesSql(choices, keys)\n            selectRelevantTablesRequest(choices, keys)\n        });\n\n        selectRelevantTablesSql(choices, keys);\n\n        document.addEventListener('docChanged', debounce(\n            () => selectRelevantTablesSql(choices, keys), 500));\n\n        document.getElementById('id_assistant_input').addEventListener('input', debounce(\n            () => selectRelevantTablesRequest(choices, keys), 300));\n\n    })\n    .catch(error => {\n        console.error('Error retrieving JSON schema:', error);\n    });\n}\n\nfunction selectRelevantTablesSql(choices, keys) {\n    const textContent = window.editor.state.doc.toString().toLowerCase();\n    const textWords = new Set(textContent.split(/\\s+/));\n    const hasKeys = keys.filter(key => textWords.has(key.toLowerCase()));\n    choices.setChoiceByValue(hasKeys);\n}\n\nfunction selectRelevantTablesRequest(choices, keys) {\n    const textContent = document.getElementById(\"id_assistant_input\").value\n    const textWords = new Set(textContent.toLowerCase().split(/\\s+/));\n    const hasKeys = keys.filter(key => textWords.has(key.toLowerCase()));\n    choices.setChoiceByValue(hasKeys);\n}\n\nexport function setUpAssistant(expand = false) {\n\n    getConnElement().addEventListener('change', setupTableList);\n    setupTableList();\n\n    const error = getErrorMessage();\n\n    if (expand || error) {\n        const myCollapseElement = document.getElementById('assistant_collapse');\n        const bsCollapse = new bootstrap.Collapse(myCollapseElement, {\n            toggle: false\n        });\n        bsCollapse.show();\n        if (error) {\n            document.getElementById('id_error_help_message').classList.remove('d-none');\n        }\n    }\n\n    const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle=\"tooltip\"]');\n    [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));\n\n    document.getElementById('id_assistant_input').addEventListener('keydown', function (event) {\n        if ((event.ctrlKey || event.metaKey) && (event.key === 'Enter')) {\n            event.preventDefault();\n            submitAssistantAsk();\n        }\n    });\n\n    document.getElementById('ask_assistant_btn').addEventListener('click', submitAssistantAsk);\n\n    document.getElementById('assistant_history').addEventListener('click', getAssistantHistory);\n\n}\n\nfunction getAssistantHistory() {\n\n    const historyModalId = 'historyModal';\n\n    // Remove any existing modal with the same ID\n    const existingModal = document.getElementById(historyModalId);\n    if (existingModal) {\n        existingModal.remove()\n    }\n\n    const data = {\n        connection_id: document.getElementById(\"id_database_connection\")?.value ?? null\n    };\n\n    fetch(`${window.baseUrlPath}assistant/history/`, {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n            'X-CSRFToken': getCsrfToken()\n        },\n        body: JSON.stringify(data)\n    })\n    .then(response => {\n        if (!response.ok) {\n            throw new Error('Network response was not ok');\n        }\n        return response.json();\n    })\n    .then(data => {\n        // Create table rows from the fetched data\n        let tableRows = '';\n        data.logs.forEach(log => {\n            let md = DOMPurify.sanitize(marked.parse(log.response));\n            tableRows += `\n                <tr>\n                    <td>${log.user_request}</td>\n                    <td>${md}</td>\n                </tr>\n            `;\n        });\n\n        // Create the complete table HTML\n        const tableHtml = `\n            <table class=\"table table-striped\">\n                <thead>\n                    <tr>\n                        <th>User Request</th>\n                        <th>Response</th>\n                    </tr>\n                </thead>\n                <tbody>\n                    ${tableRows}\n                </tbody>\n            </table>\n        `;\n\n        // Insert the table into a new Bootstrap modal\n        const modalHtml = `\n            <div class=\"modal fade\" id=\"${historyModalId}\" tabindex=\"-1\" aria-labelledby=\"historyModalLabel\" aria-hidden=\"true\">\n                <div class=\"modal-dialog modal-lg\">\n                    <div class=\"modal-content\">\n                        <div class=\"modal-header\">\n                            <h5 class=\"modal-title\" id=\"historyModalLabel\">Assistant History</h5>\n                            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n                        </div>\n                        <div class=\"modal-body\">\n                            ${tableHtml}\n                        </div>\n                        <div class=\"modal-footer\">\n                            <button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        `;\n\n        document.body.insertAdjacentHTML('beforeend', modalHtml);\n        const historyModal = new bootstrap.Modal(document.getElementById(historyModalId));\n        historyModal.show();\n\n    })\n    .catch(error => {\n        console.error('There was a problem with the fetch operation:', error);\n    });\n}\n\n\nfunction submitAssistantAsk() {\n\n    const data = {\n        sql: window.editor?.state.doc.toString() ?? null,\n        connection_id: document.getElementById(\"id_database_connection\")?.value ?? null,\n        assistant_request: document.getElementById(\"id_assistant_input\")?.value ?? null,\n        selected_tables: assistantChoices.getValue(true),\n        db_error: getErrorMessage()\n    };\n\n    document.getElementById(\"assistant_response\").innerHTML = '';\n    document.getElementById(\"response_block\").classList.remove('d-none');\n    document.getElementById(\"assistant_spinner\").classList.remove('d-none');\n\n    fetch(`${window.baseUrlPath}assistant/`, {\n        method: 'POST',\n        headers: {\n            'Content-Type': 'application/json',\n            'X-CSRFToken': getCsrfToken()\n        },\n        body: JSON.stringify(data)\n    })\n    .then(response => {\n        if (!response.ok) {\n            throw new Error('Network response was not ok');\n        }\n        return response.json();\n    })\n    .then(data => {\n        const output = DOMPurify.sanitize(marked.parse(data.message));\n        document.getElementById(\"assistant_response\").innerHTML = output;\n        document.getElementById(\"assistant_spinner\").classList.add('d-none');\n\n        // If there is exactly one code block in the response and the SQL editor is empty\n        // then copy the code directly into the editor\n        const preElements = document.querySelectorAll('#assistant_response pre');\n        if (preElements.length === 1 && window.editor?.state.doc.toString().trim() === \"\") {\n            window.editor.dispatch({\n                changes: {\n                    from: 0,\n                    insert: preElements[0].textContent\n                }\n            });\n        }\n\n        // Similarly, if there is no description, copy the prompt into the description\n        const prompt = document.getElementById(\"id_assistant_input\")?.value;\n        const description = document.getElementById(\"id_description\");\n        if (description?.value === \"\") {\n            description.value = prompt;\n        }\n\n        setUpCopyButtons();\n    })\n        .catch(error => {\n        console.error('Error:', error);\n    });\n}\n\nfunction setUpCopyButtons(){\n    document.querySelectorAll('#assistant_response pre').forEach(pre => {\n\n        const btn = document.createElement('i');\n        btn.classList.add('copy-btn');\n        btn.classList.add('bi-copy');\n        const msg = document.createElement('span');\n        msg.textContent = 'Copied!';\n        msg.style.display = 'none';\n        msg.style.marginLeft = '8px';\n        btn.appendChild(msg);\n        pre.appendChild(btn);\n\n        btn.addEventListener('click', function() {\n            const code = this.parentNode.firstElementChild.innerText;\n            navigator.clipboard.writeText(code).then(() => {\n                msg.style.display = 'inline';\n                setTimeout(() => {\n                    msg.style.display = 'none';\n                }, 2000);\n            }).catch(err => {\n                console.error('Error in copying text: ', err);\n            });\n        });\n    });\n}\n"
  },
  {
    "path": "explorer/src/js/codemirror-config.js",
    "content": "import {\n    keymap, highlightSpecialChars, drawSelection, highlightActiveLine, dropCursor,\n    lineNumbers, highlightActiveLineGutter, EditorView\n} from \"@codemirror/view\"\nimport {\n    defaultHighlightStyle, syntaxHighlighting, indentOnInput, bracketMatching,\n    foldGutter, foldKeymap\n} from \"@codemirror/language\"\nimport {defaultKeymap, history, historyKeymap} from \"@codemirror/commands\"\nimport {searchKeymap, highlightSelectionMatches} from \"@codemirror/search\"\nimport {autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, acceptCompletion} from \"@codemirror/autocomplete\"\nimport {lintKeymap} from \"@codemirror/lint\"\nimport { Prec } from \"@codemirror/state\";\nimport {sql} from \"@codemirror/lang-sql\";\nimport { SchemaSvc } from \"./schemaService\"\n\n\nlet updateListenerExtension = EditorView.updateListener.of((update) => {\n  if (update.docChanged) {\n      document.dispatchEvent(new CustomEvent('docChanged', {}));\n  }\n});\n\nconst hideTooltipOnEsc = EditorView.domEventHandlers({\n    keydown(event, view) {\n        if (event.code === 'Escape') {\n            const tooltip = document.getElementById('schema_tooltip');\n            if (tooltip) {\n                tooltip.classList.add('d-none');\n                tooltip.classList.remove('d-block');\n            }\n            return true;\n        }\n        return false;\n    }\n});\n\nfunction displaySchemaTooltip(content) {\n    let tooltip = document.getElementById('schema_tooltip');\n    if (tooltip) {\n        tooltip.classList.remove('d-none');\n        tooltip.classList.add('d-block');\n\n        // Clear existing content\n        tooltip.textContent = '';\n\n        content.forEach(item => {\n            let column = document.createElement('span');\n            column.textContent = item;\n            column.classList.add('mx-1')\n            tooltip.appendChild(column);\n        });\n    }\n}\n\nfunction fetchAndShowSchema(view) {\n    const { state } = view;\n    const pos = state.selection.main.head;\n    const wordRange = state.wordAt(pos);\n\n    if (wordRange) {\n        const tableName = state.doc.sliceString(wordRange.from, wordRange.to);\n        SchemaSvc.get().then(schema => {\n            let formattedSchema;\n            if (schema.hasOwnProperty(tableName)) {\n                displaySchemaTooltip(schema[tableName]);\n            } else {\n                const errorMsg = [`Table '${tableName}' not found in schema for connection`];\n                displaySchemaTooltip(errorMsg);\n            }\n        });\n    }\n    return true;\n}\n\nconst schemaKeymap = [\n    {\n        key: \"Ctrl-S\",\n        mac: \"Cmd-S\",\n        run: (editor) => {\n            fetchAndShowSchema(editor);\n            return true;\n        }\n    },\n    {\n        key: \"Cmd-S\",\n        run: (editor) => {\n            fetchAndShowSchema(editor);\n            return true;\n        }\n    }\n];\n\nconst submitEventFromCM = new CustomEvent('submitEventFromCM', {});\nconst submitKeymapArr = [\n    {\n        key: \"Ctrl-Enter\",\n        run: () => {\n            document.dispatchEvent(submitEventFromCM);\n            return true;\n        }\n    },\n    {\n        key: \"Cmd-Enter\",\n        run: () => {\n            document.dispatchEvent(submitEventFromCM);\n            return true;\n        }\n    }\n]\n\n\nconst formatEventFromCM = new CustomEvent('formatEventFromCM', {});\nconst formatKeymap = [\n    {\n        key: \"Ctrl-F\",\n        mac: \"Cmd-F\",\n        run: () => {\n            document.dispatchEvent(formatEventFromCM);\n            return true;\n        }\n    },\n    {\n        key: \"Ctrl-F\",\n        mac: \"Cmd-F\",\n        run: () => {\n            document.dispatchEvent(formatEventFromCM);\n            return true;\n        }\n    }\n]\n\nconst submitKeymap = Prec.highest(\n    keymap.of(\n      submitKeymapArr\n    )\n)\n\nconst autocompleteKeymap = [{key: \"Tab\", run: acceptCompletion}]\n\n\nexport const explorerSetup = (() => [\n    sql({}),\n    lineNumbers(),\n    highlightActiveLineGutter(),\n    highlightSpecialChars(),\n    history(),\n    foldGutter(),\n    drawSelection(),\n    dropCursor(),\n    indentOnInput(),\n    syntaxHighlighting(defaultHighlightStyle, {fallback: true}),\n    bracketMatching(),\n    closeBrackets(),\n    autocompletion(),\n    highlightActiveLine(),\n    highlightSelectionMatches(),\n    submitKeymap,\n    updateListenerExtension,\n    hideTooltipOnEsc,\n    keymap.of([\n        ...closeBracketsKeymap,\n        ...defaultKeymap,\n        ...searchKeymap,\n        ...historyKeymap,\n        ...foldKeymap,\n        ...completionKeymap,\n        ...lintKeymap,\n        ...autocompleteKeymap,\n        ...schemaKeymap,\n        ...formatKeymap\n    ])\n])()\n"
  },
  {
    "path": "explorer/src/js/csrf.js",
    "content": "import cookie from \"cookiejs\";\n\nconst csrfCookieName = document.getElementById('csrfCookieName').value;\nconst csrfTokenInDOM = document.getElementById('csrfTokenInDOM').value === \"True\";\n\nexport function getCsrfToken() {\n    if (csrfTokenInDOM) {\n        let csrfInput = document.querySelector('input[name=\"csrfmiddlewaretoken\"]');\n        return csrfInput ? csrfInput.value : null;\n    }\n    return cookie.get(csrfCookieName);\n}\n"
  },
  {
    "path": "explorer/src/js/explorer.js",
    "content": "import $ from 'jquery';\nimport { EditorView } from \"codemirror\";\nimport { explorerSetup } from \"./codemirror-config\";\nimport { setUpAssistant } from \"./assistant\";\n\nimport cookie from 'cookiejs';\nimport List from 'list.js'\n\nimport { getCsrfToken } from \"./csrf\";\nimport { toggleFavorite } from \"./favorites\";\n\nimport {schemaCompletionSource, StandardSQL} from \"@codemirror/lang-sql\";\nimport {StateEffect} from \"@codemirror/state\";\nimport {getConnElement, SchemaSvc} from \"./schemaService\";\n\n\nfunction updateSchema() {\n    SchemaSvc.get().then(schema => {\n        window.editor.dispatch({\n            effects: StateEffect.appendConfig.of(\n                StandardSQL.language.data.of({\n                  autocomplete: schemaCompletionSource({schema: schema})\n                })\n            )\n        });\n    });\n\n    $(\"#schema_frame\").attr(\"src\", `${window.baseUrlPath}schema/${getConnElement().value}`);\n}\n\n\nfunction editorFromTextArea(textarea) {\n    let view = new EditorView({\n        doc: textarea.value,\n        extensions: [\n            explorerSetup,\n        ]})\n    textarea.parentNode.insertBefore(view.dom, textarea)\n    textarea.style.display = \"none\"\n    if (textarea.form) textarea.form.addEventListener(\"submit\", () => {\n        textarea.value = view.state.doc.toString()\n    })\n    return view\n}\n\n\nfunction selectConnection() {\n    var urlParams = new URLSearchParams(window.location.search);\n    var connectionId = urlParams.get('connection');\n\n    if (connectionId) {\n        var connectionSelect = document.getElementById('id_database_connection');\n        if (connectionSelect) {\n            connectionSelect.value = connectionId;\n        }\n    }\n}\n\nexport class ExplorerEditor {\n    constructor(queryId) {\n\n        selectConnection();\n\n        const aa = document.getElementById('assistant_accordion');\n        const pa = document.getElementById('nav-preview');\n        if (aa) {\n            // Expand the assistant only if a new query is being created\n            // and no results are yet being shown\n            const expand = !pa && queryId === 'new';\n            setUpAssistant(expand);\n        }\n\n        this.queryId = queryId;\n        this.$rows = $(\"#rows\");\n        this.$form = $(\"form\");\n        this.$snapshotField = $(\"#id_snapshot\");\n        this.docChanged = false;\n\n        this.$submit = $(\"#refresh_play_button, #save_button\");\n        if (!this.$submit.length) {\n            this.$submit = $(\"#refresh_button\");\n        }\n\n        this.editor = editorFromTextArea(document.getElementById(\"id_sql\"));\n\n        window.editor = this.editor;\n\n        document.addEventListener('submitEventFromCM', (e) => {\n            this.$submit.click();\n        });\n\n        document.addEventListener('formatEventFromCM', (e) => {\n            this.formatSql();\n        });\n\n        document.addEventListener('docChanged', (e) => {\n            this.docChanged = true;\n        });\n\n        this.bind();\n\n        if (cookie.get(\"schema_sidebar_open\") === 'true') {\n            this.showSchema(true);\n        }\n    }\n\n    getParams() {\n        let o = false;\n        const params = document.querySelectorAll(\"form .param\");\n        if (params.length) {\n            o = {};\n            params.forEach((param) => {\n                o[param.dataset.param] = param.value;\n            });\n        }\n        return o;\n    }\n\n    serializeParams(params) {\n        var args = [];\n        for(var key in params) {\n            args.push(key + \":\" + params[key]);\n        }\n        return encodeURIComponent(args.join(\"|\"));\n    }\n\n    updateQueryString(key, value, url) {\n        // http://stackoverflow.com/a/11654596/221390\n        if (!url) url = window.location.href;\n        var re = new RegExp(\"([?&])\" + key + \"=.*?(&|#|$)(.*)\", \"gi\"),\n            hash = url.split(\"#\");\n\n        if (re.test(url)) {\n            if (typeof value !== \"undefined\" && value !== null)\n                return url.replace(re, \"$1\" + key + \"=\" + value + \"$2$3\");\n            else {\n                url = hash[0].replace(re, \"$1$3\").replace(/(&|\\?)$/, \"\");\n                if (typeof hash[1] !== \"undefined\" && hash[1] !== null)\n                    url += \"#\" + hash[1];\n                return url;\n            }\n        }\n        else {\n            if (typeof value !== \"undefined\" && value !== null) {\n                var separator = url.indexOf(\"?\") !== -1 ? \"&\" : \"?\";\n                url = hash[0] + separator + key + \"=\" + value;\n                if (typeof hash[1] !== \"undefined\" && hash[1] !== null)\n                    url += \"#\" + hash[1];\n                return url;\n            }\n            else\n                return url;\n        }\n    }\n\n    formatSql() {\n        let sqlText = this.editor.state.doc.toString();\n        let editor = this.editor;\n\n        var formData = new FormData();\n        formData.append('sql', sqlText); // Append the SQL text to the form data\n\n        // Make the fetch call\n        fetch(`${window.baseUrlPath}format/`, {\n            method: \"POST\",\n            headers: {\n                // 'Content-Type': 'application/x-www-form-urlencoded', // Not needed when using FormData, as the browser sets it along with the boundary\n                'X-CSRFToken': getCsrfToken()\n            },\n            body: formData // Use the FormData object as the body\n        })\n        .then(response => response.json()) // Parse the JSON response\n        .then(data => {\n            editor.dispatch({\n                changes: {\n                    from: 0,\n                    to: editor.state.doc.length,\n                    insert: data.formatted\n                }\n            });\n        })\n        .catch(error => console.error('Error:', error));\n    }\n\n    showRows() {\n        let rows = document.getElementById(\"rows\").value;\n        let form = document.getElementById(\"editor\");\n        form.setAttribute(\"action\", this.updateQueryString(\"rows\", rows, window.location.href));\n        form.submit();\n    }\n\n    showSchema(noAutofocus) {\n        if (noAutofocus === true) {\n            $(\"#schema_frame\").addClass(\"no-autofocus\");\n        }\n        $(\"#query_area\").removeClass(\"col\").addClass(\"col-9\");\n        var schema$ = $(\"#schema\");\n        schema$.addClass(\"col-md-3\");\n        schema$.show();\n        $(\"#show_schema_button\").hide();\n        $(\"#hide_schema_button\").show();\n        cookie.set(\"schema_sidebar_open\", 'true');\n        return false;\n    }\n\n    hideSchema() {\n        $(\"#query_area\").removeClass(\"col-9\").addClass(\"col\");\n        var schema$ = $(\"#schema\");\n        schema$.removeClass(\"col-3\");\n        schema$.hide();\n        $(\"#hide_schema_button\").hide();\n        $(\"#show_schema_button\").show();\n        cookie.set(\"schema_sidebar_open\", 'false');\n        return false;\n    }\n\n    handleBeforeUnload = (event) => {\n        if (clientRoute === 'query_detail' && this.docChanged) {\n            const confirmationMessage = \"You have unsaved changes to your query.\";\n            event.returnValue = confirmationMessage;\n            return confirmationMessage;\n        }\n    };\n\n    bind() {\n\n        window.addEventListener(\"beforeunload\", this.handleBeforeUnload)\n\n        document.addEventListener(\"submit\", (event) => {\n            // Disable unsaved changes warning when submitting the editor form\n            if (event.target.id === \"editor\") {\n                window.removeEventListener(\"beforeunload\", this.handleBeforeUnload);\n            }\n        })\n\n        document.querySelectorAll('.query_favorite_toggle').forEach(function(element) {\n            element.addEventListener('click', toggleFavorite);\n        });\n\n        document.getElementById('show_schema_button')?.addEventListener('click', this.showSchema.bind(this));\n        document.getElementById('hide_schema_button')?.addEventListener('click', this.hideSchema.bind(this));\n\n\n        $(\"#format_button\").click(function(e) {\n            e.preventDefault();\n            this.formatSql();\n        }.bind(this));\n\n        $(\"#rows\").keyup(function() {\n            var curUrl = $(\"#fullscreen\").attr(\"href\");\n            var newUrl = curUrl.replace(/rows=\\d+/, \"rows=\" + $(\"#rows\").val());\n            $(\"#fullscreen\").attr(\"href\", newUrl);\n        }.bind(this));\n\n        $(\"#save_button\").click(function() {\n            var params = this.getParams(this);\n            if(params) {\n                this.$form.attr(\"action\", \"../\" + this.queryId + \"/?params=\" + this.serializeParams(params));\n            }\n            this.$snapshotField.hide();\n            this.$form.append(this.$snapshotField);\n        }.bind(this));\n\n        $(\"#save_only_button\").click(function() {\n            var params = this.getParams(this);\n            if(params) {\n                this.$form.attr('action', '../' + this.queryId + '/?show=0&params=' + this.serializeParams(params));\n            } else {\n                this.$form.attr('action', '../' + this.queryId + '/?show=0');\n            }\n            this.$snapshotField.hide();\n            this.$form.append(this.$snapshotField);\n        }.bind(this));\n\n        $(\"#refresh_button\").click(function(e) {\n            e.preventDefault();\n            var params = this.getParams();\n            if(params) {\n                window.location.href = \"../\" + this.queryId + \"/?params=\" + this.serializeParams(params);\n            } else {\n                window.location.href = \"../\" + this.queryId + \"/\";\n            }\n        }.bind(this));\n\n        $(\"#refresh_play_button\").click(function() {\n            this.$form.attr(\"action\", \"../play/\");\n        }.bind(this));\n\n        $(\"#playground_button\").click(function(e) {\n            e.preventDefault();\n            this.$form.attr(\"action\", \"../play/?show=0\");\n            this.$form.submit();\n        }.bind(this));\n\n        $(\"#create_button\").click(function() {\n            this.$form.attr(\"action\", \"../new/\");\n        }.bind(this));\n\n        $(\".download-button\").click(function(e) {\n            var url = \"../download?format=\" + $(e.target).data(\"format\");\n            var params = this.getParams();\n            if(params) {\n                url = url + \"&params=\" + params;\n            }\n            this.$form.attr(\"action\", url);\n        }.bind(this));\n\n        $(\".download-query-button\").click(function(e) {\n            var url = \"../download?format=\" + $(e.target).data(\"format\");\n            var params = this.getParams();\n            if(params) {\n                url = url + \"&params=\" + params;\n            }\n            this.$form.attr(\"action\", url);\n        }.bind(this));\n\n        document.querySelectorAll('.stats-expand').forEach(element => {\n            element.addEventListener('click', function(e) {\n                e.preventDefault();\n                document.querySelectorAll('.stats-expand').forEach(el => el.style.display = 'none');\n                document.querySelectorAll('.stats-wrapper').forEach(el => el.style.display = '');\n            });\n        });\n\n        let counterToggle = document.getElementById('counter-toggle');\n        if (counterToggle) {\n            counterToggle.addEventListener('click', function(e) {\n                e.preventDefault();\n                document.querySelectorAll('.counter').forEach(el => {\n                    el.style.display = el.style.display === 'none' ? '' : 'none';\n                });\n            });\n        }\n\n        // List.js setup for the preview pane to support sorting\n        let previewPane = document.querySelector('#preview');\n        if (previewPane) {\n            let thElements = previewPane.querySelectorAll('th');\n            new List('preview', {\n                valueNames: Array.from(thElements, (_, index) => index)\n            });\n        }\n\n        document.querySelectorAll('.sort').forEach(sortButton => {\n            sortButton.addEventListener('click', function(e) {\n                const target = e.target;\n\n                // Reset icons on all sort buttons\n                document.querySelectorAll('.sort').forEach(btn => {\n                    btn.classList.add('bi-chevron-expand');\n                    btn.classList.remove('bi-chevron-down', 'bi-chevron-up');\n                });\n\n                if ( target.classList.contains('asc') ) {\n                    target.classList.replace('bi-chevron-expand', 'bi-chevron-up');\n                    target.classList.remove('bi-chevron-down');\n                } else {\n                    target.classList.replace('bi-chevron-expand', 'bi-chevron-down');\n                    target.classList.remove('bi-chevron-up');\n                }\n\n            }.bind(this));\n        });\n\n        const tabEl = document.querySelector('button[data-bs-target=\"#nav-pivot\"]')\n        if (tabEl) {\n            tabEl.addEventListener('shown.bs.tab', event => {\n                import('./pivot-setup').then(({pivotSetup}) => pivotSetup($));\n            });\n        }\n\n        // Pretty hacky, but at the moment URL hashes are only used for storing pivot state, so this is a safe\n        // way of checking if we are following a link to a pivot table.\n        if (window.location.hash) {\n            document.querySelector('#nav-pivot-tab').click();\n        }\n\n        this.$rows.change(function() { this.showRows(); }.bind(this));\n        this.$rows.keyup(function(event) {\n            if(event.keyCode === 13){ this.showRows(); }\n        }.bind(this));\n\n        // Set up schema autocomplete in the editor. When the connection changes, load new schema.\n        getConnElement().addEventListener('change', updateSchema);\n        updateSchema();\n    }\n}\n"
  },
  {
    "path": "explorer/src/js/favorites.js",
    "content": "import {getCsrfToken} from \"./csrf\";\n\nexport async function toggleFavorite() {\n    let queryId = this.dataset.id;\n    let favoriteUrl = this.dataset.url;\n\n    try {\n        let response = await fetch(favoriteUrl, {\n            method: 'POST',\n            headers: {\n                'Content-Type': 'application/json',\n                'X-CSRFToken': getCsrfToken()\n            },\n            body: JSON.stringify({})\n        });\n\n        let data = await response.json();\n        let is_favorite = data.is_favorite;\n        let selector = `.query_favorite_toggle[data-id='${queryId}']`;\n        let element = document.querySelector(selector);\n\n        if (element) {\n            if (is_favorite) {\n                element.classList.remove(\"bi-heart\");\n                element.classList.add(\"bi-heart-fill\");\n            } else {\n                element.classList.remove(\"bi-heart-fill\");\n                element.classList.add(\"bi-heart\");\n            }\n        }\n    } catch (error) {\n        console.error('Error:', error);\n        alert(\"error\");\n    }\n}\n"
  },
  {
    "path": "explorer/src/js/main.js",
    "content": "/*\nThis is the entrypoint for the client code, and for the Vite build. The basic\nidea is to map a function to each page/url of the app that sets up the JS\nneeded for that page. The clientRoute and queryId are defined in base.html\ntemplate. clientRoute's value comes from the name of the Django URL pattern for\nthe page. The dynamic import() allows Vite to chunk the JS and only load what's\nnecessary for each page. Concretely, this matters because the pages with SQL\nEditors require fairly heavy JS (CodeMirror).\n*/\nimport * as bootstrap from 'bootstrap';\n\nconst route_initializers = {\n    explorer_index:      () => import('./query-list').then(({setupQueryList}) => setupQueryList()),\n    query_detail:        () => import('./explorer').then(({ExplorerEditor}) =>\n        new ExplorerEditor(document.getElementById('queryIdGlobal').value)),\n    query_create:        () => import('./explorer').then(({ExplorerEditor}) => new ExplorerEditor('new')),\n    explorer_playground: () => import('./explorer').then(({ExplorerEditor}) => new ExplorerEditor('new')),\n    explorer_schema:     () => import('./schema').then(({setupSchema}) => setupSchema()),\n    explorer_upload_create:            () => import('./uploads').then(({setupUploads}) => setupUploads()),\n    explorer_connection_update:            () => import('./uploads').then(({setupUploads}) => setupUploads()),\n    explorer_connection_create:            () => import('./uploads').then(({setupUploads}) => setupUploads()),\n    table_description_create: () => import('./tableDescription').then(({setupTableDescription}) => setupTableDescription()),\n    table_description_update: () => import('./tableDescription').then(({setupTableDescription}) => setupTableDescription()),\n};\n\ndocument.addEventListener('DOMContentLoaded', function() {\n    const clientRoute = document.getElementById('clientRoute').value;\n    window.baseUrlPath = document.getElementById('baseUrlPath').value;\n    if (route_initializers.hasOwnProperty(clientRoute)) {\n        route_initializers[clientRoute]();\n    }\n});\n"
  },
  {
    "path": "explorer/src/js/pivot-setup.js",
    "content": "import {pivotJq} from \"./pivot\";\nimport {csvFromTable} from \"./table-to-csv\";\nimport $ from \"jquery\";\nexport function pivotSetup($) {\n\n    let pivotState = {};\n    if (window.location.hash) {\n        try {\n            pivotState = JSON.parse(atob(window.location.hash.slice(1)));\n        } catch(e) {\n            console.log(e);\n        }\n    }\n    pivotState[\"onRefresh\"] = savePivotState;\n\n    pivotJq($);\n    let pivotInput = $(\"#preview\").clone();\n    pivotInput.find(\"tr.stats-th\").remove();\n    $(\".pivot-table\").pivotUI(pivotInput, pivotState);\n\n    let csvButton = document.querySelector(\"#button-excel\");\n    if (csvButton) {\n        csvButton.addEventListener(\"click\", e => {\n            e.preventDefault();\n            let table = document.querySelector(\".pvtTable\");\n            if (typeof (table) != 'undefined' && table != null) {\n                csvFromTable(table);\n            }\n        });\n    }\n}\n\nfunction savePivotState(state) {\n    const picked = (({ aggregatorName, rows, cols, rendererName, vals }) => ({ aggregatorName, rows, cols, rendererName, vals }))(state);\n    const jsonString = JSON.stringify(picked);\n    let bmark = btoa(jsonString);\n    let el = document.getElementById(\"pivot-bookmark\");\n    if(el) {\n        el.setAttribute(\"href\", el.dataset.baseurl + \"#\" + bmark);\n    }\n}\n"
  },
  {
    "path": "explorer/src/js/pivot.js",
    "content": "import Sortable from 'sortablejs';\n\n\nlet indexOf = [].indexOf || function (item) {\n    for (var i = 0, l = this.length; i < l; i++) {\n        if (i in this && this[i] === item) return i;\n    }\n    return -1;\n}\nlet slice = [].slice\nlet bind = function (fn, me) {\n    return function () {\n        return fn.apply(me, arguments);\n    };\n}\nlet hasProp = {}.hasOwnProperty\n\nexport function pivotJq($) {\n\n    /*\n    Utilities\n     */\n    var PivotData, addSeparators, aggregatorTemplates, aggregators, dayNamesEn, derivers, getSort, locales, mthNamesEn,\n        naturalSort, numberFormat, pivotTableRenderer, rd, renderers, rx, rz, sortAs, usFmt, usFmtInt, usFmtPct,\n        zeroPad;\n    addSeparators = function (nStr, thousandsSep, decimalSep) {\n        var rgx, x, x1, x2;\n        nStr += '';\n        x = nStr.split('.');\n        x1 = x[0];\n        x2 = x.length > 1 ? decimalSep + x[1] : '';\n        rgx = /(\\d+)(\\d{3})/;\n        while (rgx.test(x1)) {\n            x1 = x1.replace(rgx, '$1' + thousandsSep + '$2');\n        }\n        return x1 + x2;\n    };\n    numberFormat = function (opts) {\n        var defaults;\n        defaults = {\n            digitsAfterDecimal: 2,\n            scaler: 1,\n            thousandsSep: \",\",\n            decimalSep: \".\",\n            prefix: \"\",\n            suffix: \"\"\n        };\n        opts = $.extend({}, defaults, opts);\n        return function (x) {\n            var result;\n            if (isNaN(x) || !isFinite(x)) {\n                return \"\";\n            }\n            result = addSeparators((opts.scaler * x).toFixed(opts.digitsAfterDecimal), opts.thousandsSep, opts.decimalSep);\n            return \"\" + opts.prefix + result + opts.suffix;\n        };\n    };\n    usFmt = numberFormat();\n    usFmtInt = numberFormat({\n        digitsAfterDecimal: 0\n    });\n    usFmtPct = numberFormat({\n        digitsAfterDecimal: 1,\n        scaler: 100,\n        suffix: \"%\"\n    });\n    aggregatorTemplates = {\n        count: function (formatter) {\n            if (formatter == null) {\n                formatter = usFmtInt;\n            }\n            return function () {\n                return function (data, rowKey, colKey) {\n                    return {\n                        count: 0,\n                        push: function () {\n                            return this.count++;\n                        },\n                        value: function () {\n                            return this.count;\n                        },\n                        format: formatter\n                    };\n                };\n            };\n        },\n        uniques: function (fn, formatter) {\n            if (formatter == null) {\n                formatter = usFmtInt;\n            }\n            return function (arg) {\n                var attr;\n                attr = arg[0];\n                return function (data, rowKey, colKey) {\n                    return {\n                        uniq: [],\n                        push: function (record) {\n                            var ref;\n                            if (ref = record[attr], indexOf.call(this.uniq, ref) < 0) {\n                                return this.uniq.push(record[attr]);\n                            }\n                        },\n                        value: function () {\n                            return fn(this.uniq);\n                        },\n                        format: formatter,\n                        numInputs: attr != null ? 0 : 1\n                    };\n                };\n            };\n        },\n        sum: function (formatter) {\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var attr;\n                attr = arg[0];\n                return function (data, rowKey, colKey) {\n                    return {\n                        sum: 0,\n                        push: function (record) {\n                            if (!isNaN(parseFloat(record[attr]))) {\n                                return this.sum += parseFloat(record[attr]);\n                            }\n                        },\n                        value: function () {\n                            return this.sum;\n                        },\n                        format: formatter,\n                        numInputs: attr != null ? 0 : 1\n                    };\n                };\n            };\n        },\n        extremes: function (mode, formatter) {\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var attr;\n                attr = arg[0];\n                return function (data, rowKey, colKey) {\n                    return {\n                        val: null,\n                        sorter: getSort(data != null ? data.sorters : void 0, attr),\n                        push: function (record) {\n                            var ref, ref1, ref2, x;\n                            x = record[attr];\n                            if (mode === \"min\" || mode === \"max\") {\n                                x = parseFloat(x);\n                                if (!isNaN(x)) {\n                                    this.val = Math[mode](x, (ref = this.val) != null ? ref : x);\n                                }\n                            }\n                            if (mode === \"first\") {\n                                if (this.sorter(x, (ref1 = this.val) != null ? ref1 : x) <= 0) {\n                                    this.val = x;\n                                }\n                            }\n                            if (mode === \"last\") {\n                                if (this.sorter(x, (ref2 = this.val) != null ? ref2 : x) >= 0) {\n                                    return this.val = x;\n                                }\n                            }\n                        },\n                        value: function () {\n                            return this.val;\n                        },\n                        format: function (x) {\n                            if (isNaN(x)) {\n                                return x;\n                            } else {\n                                return formatter(x);\n                            }\n                        },\n                        numInputs: attr != null ? 0 : 1\n                    };\n                };\n            };\n        },\n        quantile: function (q, formatter) {\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var attr;\n                attr = arg[0];\n                return function (data, rowKey, colKey) {\n                    return {\n                        vals: [],\n                        push: function (record) {\n                            var x;\n                            x = parseFloat(record[attr]);\n                            if (!isNaN(x)) {\n                                return this.vals.push(x);\n                            }\n                        },\n                        value: function () {\n                            var i;\n                            if (this.vals.length === 0) {\n                                return null;\n                            }\n                            this.vals.sort(function (a, b) {\n                                return a - b;\n                            });\n                            i = (this.vals.length - 1) * q;\n                            return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0;\n                        },\n                        format: formatter,\n                        numInputs: attr != null ? 0 : 1\n                    };\n                };\n            };\n        },\n        runningStat: function (mode, ddof, formatter) {\n            if (mode == null) {\n                mode = \"mean\";\n            }\n            if (ddof == null) {\n                ddof = 1;\n            }\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var attr;\n                attr = arg[0];\n                return function (data, rowKey, colKey) {\n                    return {\n                        n: 0.0,\n                        m: 0.0,\n                        s: 0.0,\n                        push: function (record) {\n                            var m_new, x;\n                            x = parseFloat(record[attr]);\n                            if (isNaN(x)) {\n                                return;\n                            }\n                            this.n += 1.0;\n                            if (this.n === 1.0) {\n                                return this.m = x;\n                            } else {\n                                m_new = this.m + (x - this.m) / this.n;\n                                this.s = this.s + (x - this.m) * (x - m_new);\n                                return this.m = m_new;\n                            }\n                        },\n                        value: function () {\n                            if (mode === \"mean\") {\n                                if (this.n === 0) {\n                                    return 0 / 0;\n                                } else {\n                                    return this.m;\n                                }\n                            }\n                            if (this.n <= ddof) {\n                                return 0;\n                            }\n                            switch (mode) {\n                                case \"var\":\n                                    return this.s / (this.n - ddof);\n                                case \"stdev\":\n                                    return Math.sqrt(this.s / (this.n - ddof));\n                            }\n                        },\n                        format: formatter,\n                        numInputs: attr != null ? 0 : 1\n                    };\n                };\n            };\n        },\n        sumOverSum: function (formatter) {\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var denom, num;\n                num = arg[0], denom = arg[1];\n                return function (data, rowKey, colKey) {\n                    return {\n                        sumNum: 0,\n                        sumDenom: 0,\n                        push: function (record) {\n                            if (!isNaN(parseFloat(record[num]))) {\n                                this.sumNum += parseFloat(record[num]);\n                            }\n                            if (!isNaN(parseFloat(record[denom]))) {\n                                return this.sumDenom += parseFloat(record[denom]);\n                            }\n                        },\n                        value: function () {\n                            return this.sumNum / this.sumDenom;\n                        },\n                        format: formatter,\n                        numInputs: (num != null) && (denom != null) ? 0 : 2\n                    };\n                };\n            };\n        },\n        sumOverSumBound80: function (upper, formatter) {\n            if (upper == null) {\n                upper = true;\n            }\n            if (formatter == null) {\n                formatter = usFmt;\n            }\n            return function (arg) {\n                var denom, num;\n                num = arg[0], denom = arg[1];\n                return function (data, rowKey, colKey) {\n                    return {\n                        sumNum: 0,\n                        sumDenom: 0,\n                        push: function (record) {\n                            if (!isNaN(parseFloat(record[num]))) {\n                                this.sumNum += parseFloat(record[num]);\n                            }\n                            if (!isNaN(parseFloat(record[denom]))) {\n                                return this.sumDenom += parseFloat(record[denom]);\n                            }\n                        },\n                        value: function () {\n                            var sign;\n                            sign = upper ? 1 : -1;\n                            return (0.821187207574908 / this.sumDenom + this.sumNum / this.sumDenom + 1.2815515655446004 * sign * Math.sqrt(0.410593603787454 / (this.sumDenom * this.sumDenom) + (this.sumNum * (1 - this.sumNum / this.sumDenom)) / (this.sumDenom * this.sumDenom))) / (1 + 1.642374415149816 / this.sumDenom);\n                        },\n                        format: formatter,\n                        numInputs: (num != null) && (denom != null) ? 0 : 2\n                    };\n                };\n            };\n        },\n        fractionOf: function (wrapped, type, formatter) {\n            if (type == null) {\n                type = \"total\";\n            }\n            if (formatter == null) {\n                formatter = usFmtPct;\n            }\n            return function () {\n                var x;\n                x = 1 <= arguments.length ? slice.call(arguments, 0) : [];\n                return function (data, rowKey, colKey) {\n                    return {\n                        selector: {\n                            total: [[], []],\n                            row: [rowKey, []],\n                            col: [[], colKey]\n                        }[type],\n                        inner: wrapped.apply(null, x)(data, rowKey, colKey),\n                        push: function (record) {\n                            return this.inner.push(record);\n                        },\n                        format: formatter,\n                        value: function () {\n                            return this.inner.value() / data.getAggregator.apply(data, this.selector).inner.value();\n                        },\n                        numInputs: wrapped.apply(null, x)().numInputs\n                    };\n                };\n            };\n        }\n    };\n    aggregatorTemplates.countUnique = function (f) {\n        return aggregatorTemplates.uniques((function (x) {\n            return x.length;\n        }), f);\n    };\n    aggregatorTemplates.listUnique = function (s) {\n        return aggregatorTemplates.uniques((function (x) {\n            return x.sort(naturalSort).join(s);\n        }), (function (x) {\n            return x;\n        }));\n    };\n    aggregatorTemplates.max = function (f) {\n        return aggregatorTemplates.extremes('max', f);\n    };\n    aggregatorTemplates.min = function (f) {\n        return aggregatorTemplates.extremes('min', f);\n    };\n    aggregatorTemplates.first = function (f) {\n        return aggregatorTemplates.extremes('first', f);\n    };\n    aggregatorTemplates.last = function (f) {\n        return aggregatorTemplates.extremes('last', f);\n    };\n    aggregatorTemplates.median = function (f) {\n        return aggregatorTemplates.quantile(0.5, f);\n    };\n    aggregatorTemplates.average = function (f) {\n        return aggregatorTemplates.runningStat(\"mean\", 1, f);\n    };\n    aggregatorTemplates[\"var\"] = function (ddof, f) {\n        return aggregatorTemplates.runningStat(\"var\", ddof, f);\n    };\n    aggregatorTemplates.stdev = function (ddof, f) {\n        return aggregatorTemplates.runningStat(\"stdev\", ddof, f);\n    };\n    aggregators = (function (tpl) {\n        return {\n            \"Count\": tpl.count(usFmtInt),\n            \"Count Unique Values\": tpl.countUnique(usFmtInt),\n            \"List Unique Values\": tpl.listUnique(\", \"),\n            \"Sum\": tpl.sum(usFmt),\n            \"Integer Sum\": tpl.sum(usFmtInt),\n            \"Average\": tpl.average(usFmt),\n            \"Median\": tpl.median(usFmt),\n            \"Sample Variance\": tpl[\"var\"](1, usFmt),\n            \"Sample Standard Deviation\": tpl.stdev(1, usFmt),\n            \"Minimum\": tpl.min(usFmt),\n            \"Maximum\": tpl.max(usFmt),\n            \"First\": tpl.first(usFmt),\n            \"Last\": tpl.last(usFmt),\n            \"Sum over Sum\": tpl.sumOverSum(usFmt),\n            \"80% Upper Bound\": tpl.sumOverSumBound80(true, usFmt),\n            \"80% Lower Bound\": tpl.sumOverSumBound80(false, usFmt),\n            \"Sum as Fraction of Total\": tpl.fractionOf(tpl.sum(), \"total\", usFmtPct),\n            \"Sum as Fraction of Rows\": tpl.fractionOf(tpl.sum(), \"row\", usFmtPct),\n            \"Sum as Fraction of Columns\": tpl.fractionOf(tpl.sum(), \"col\", usFmtPct),\n            \"Count as Fraction of Total\": tpl.fractionOf(tpl.count(), \"total\", usFmtPct),\n            \"Count as Fraction of Rows\": tpl.fractionOf(tpl.count(), \"row\", usFmtPct),\n            \"Count as Fraction of Columns\": tpl.fractionOf(tpl.count(), \"col\", usFmtPct)\n        };\n    })(aggregatorTemplates);\n    renderers = {\n        \"Table\": function (data, opts) {\n            return pivotTableRenderer(data, opts);\n        },\n        \"Table Barchart\": function (data, opts) {\n            return $(pivotTableRenderer(data, opts)).barchart();\n        },\n        \"Heatmap\": function (data, opts) {\n            return $(pivotTableRenderer(data, opts)).heatmap(\"heatmap\", opts);\n        },\n        \"Row Heatmap\": function (data, opts) {\n            return $(pivotTableRenderer(data, opts)).heatmap(\"rowheatmap\", opts);\n        },\n        \"Col Heatmap\": function (data, opts) {\n            return $(pivotTableRenderer(data, opts)).heatmap(\"colheatmap\", opts);\n        }\n    };\n    locales = {\n        en: {\n            aggregators: aggregators,\n            renderers: renderers,\n            localeStrings: {\n                renderError: \"An error occurred rendering the PivotTable results.\",\n                computeError: \"An error occurred computing the PivotTable results.\",\n                uiRenderError: \"An error occurred rendering the PivotTable UI.\",\n                selectAll: \"Select All\",\n                selectNone: \"Select None\",\n                tooMany: \"(too many to list)\",\n                filterResults: \"Filter values\",\n                apply: \"Apply\",\n                cancel: \"Cancel\",\n                totals: \"Totals\",\n                vs: \"vs\",\n                by: \"by\"\n            }\n        }\n    };\n    mthNamesEn = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n    dayNamesEn = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\n    zeroPad = function (number) {\n        return (\"0\" + number).substr(-2, 2);\n    };\n    derivers = {\n        bin: function (col, binWidth) {\n            return function (record) {\n                return record[col] - record[col] % binWidth;\n            };\n        },\n        dateFormat: function (col, formatString, utcOutput, mthNames, dayNames) {\n            var utc;\n            if (utcOutput == null) {\n                utcOutput = false;\n            }\n            if (mthNames == null) {\n                mthNames = mthNamesEn;\n            }\n            if (dayNames == null) {\n                dayNames = dayNamesEn;\n            }\n            utc = utcOutput ? \"UTC\" : \"\";\n            return function (record) {\n                var date;\n                date = new Date(Date.parse(record[col]));\n                if (isNaN(date)) {\n                    return \"\";\n                }\n                return formatString.replace(/%(.)/g, function (m, p) {\n                    switch (p) {\n                        case \"y\":\n                            return date[\"get\" + utc + \"FullYear\"]();\n                        case \"m\":\n                            return zeroPad(date[\"get\" + utc + \"Month\"]() + 1);\n                        case \"n\":\n                            return mthNames[date[\"get\" + utc + \"Month\"]()];\n                        case \"d\":\n                            return zeroPad(date[\"get\" + utc + \"Date\"]());\n                        case \"w\":\n                            return dayNames[date[\"get\" + utc + \"Day\"]()];\n                        case \"x\":\n                            return date[\"get\" + utc + \"Day\"]();\n                        case \"H\":\n                            return zeroPad(date[\"get\" + utc + \"Hours\"]());\n                        case \"M\":\n                            return zeroPad(date[\"get\" + utc + \"Minutes\"]());\n                        case \"S\":\n                            return zeroPad(date[\"get\" + utc + \"Seconds\"]());\n                        default:\n                            return \"%\" + p;\n                    }\n                });\n            };\n        }\n    };\n    rx = /(\\d+)|(\\D+)/g;\n    rd = /\\d/;\n    rz = /^0/;\n    naturalSort = (function (_this) {\n        return function (as, bs) {\n            var a, a1, b, b1, nas, nbs;\n            if ((bs != null) && (as == null)) {\n                return -1;\n            }\n            if ((as != null) && (bs == null)) {\n                return 1;\n            }\n            if (typeof as === \"number\" && isNaN(as)) {\n                return -1;\n            }\n            if (typeof bs === \"number\" && isNaN(bs)) {\n                return 1;\n            }\n            nas = +as;\n            nbs = +bs;\n            if (nas < nbs) {\n                return -1;\n            }\n            if (nas > nbs) {\n                return 1;\n            }\n            if (typeof as === \"number\" && typeof bs !== \"number\") {\n                return -1;\n            }\n            if (typeof bs === \"number\" && typeof as !== \"number\") {\n                return 1;\n            }\n            if (typeof as === \"number\" && typeof bs === \"number\") {\n                return 0;\n            }\n            if (isNaN(nbs) && !isNaN(nas)) {\n                return -1;\n            }\n            if (isNaN(nas) && !isNaN(nbs)) {\n                return 1;\n            }\n            a = String(as);\n            b = String(bs);\n            if (a === b) {\n                return 0;\n            }\n            if (!(rd.test(a) && rd.test(b))) {\n                return (a > b ? 1 : -1);\n            }\n            a = a.match(rx);\n            b = b.match(rx);\n            while (a.length && b.length) {\n                a1 = a.shift();\n                b1 = b.shift();\n                if (a1 !== b1) {\n                    if (rd.test(a1) && rd.test(b1)) {\n                        return a1.replace(rz, \".0\") - b1.replace(rz, \".0\");\n                    } else {\n                        return (a1 > b1 ? 1 : -1);\n                    }\n                }\n            }\n            return a.length - b.length;\n        };\n    })(this);\n    sortAs = function (order) {\n        var i, l_mapping, mapping, x;\n        mapping = {};\n        l_mapping = {};\n        for (i in order) {\n            x = order[i];\n            mapping[x] = i;\n            if (typeof x === \"string\") {\n                l_mapping[x.toLowerCase()] = i;\n            }\n        }\n        return function (a, b) {\n            if ((mapping[a] != null) && (mapping[b] != null)) {\n                return mapping[a] - mapping[b];\n            } else if (mapping[a] != null) {\n                return -1;\n            } else if (mapping[b] != null) {\n                return 1;\n            } else if ((l_mapping[a] != null) && (l_mapping[b] != null)) {\n                return l_mapping[a] - l_mapping[b];\n            } else if (l_mapping[a] != null) {\n                return -1;\n            } else if (l_mapping[b] != null) {\n                return 1;\n            } else {\n                return naturalSort(a, b);\n            }\n        };\n    };\n    getSort = function (sorters, attr) {\n        var sort;\n        if (sorters != null) {\n            if ($.isFunction(sorters)) {\n                sort = sorters(attr);\n                if ($.isFunction(sort)) {\n                    return sort;\n                }\n            } else if (sorters[attr] != null) {\n                return sorters[attr];\n            }\n        }\n        return naturalSort;\n    };\n\n    /*\n    Data Model class\n     */\n    PivotData = (function () {\n        function PivotData(input, opts) {\n            var ref, ref1, ref2, ref3, ref4, ref5, ref6, ref7, ref8, ref9;\n            if (opts == null) {\n                opts = {};\n            }\n            this.getAggregator = bind(this.getAggregator, this);\n            this.getRowKeys = bind(this.getRowKeys, this);\n            this.getColKeys = bind(this.getColKeys, this);\n            this.sortKeys = bind(this.sortKeys, this);\n            this.arrSort = bind(this.arrSort, this);\n            this.input = input;\n            this.aggregator = (ref = opts.aggregator) != null ? ref : aggregatorTemplates.count()();\n            this.aggregatorName = (ref1 = opts.aggregatorName) != null ? ref1 : \"Count\";\n            this.colAttrs = (ref2 = opts.cols) != null ? ref2 : [];\n            this.rowAttrs = (ref3 = opts.rows) != null ? ref3 : [];\n            this.valAttrs = (ref4 = opts.vals) != null ? ref4 : [];\n            this.sorters = (ref5 = opts.sorters) != null ? ref5 : {};\n            this.rowOrder = (ref6 = opts.rowOrder) != null ? ref6 : \"key_a_to_z\";\n            this.colOrder = (ref7 = opts.colOrder) != null ? ref7 : \"key_a_to_z\";\n            this.derivedAttributes = (ref8 = opts.derivedAttributes) != null ? ref8 : {};\n            this.filter = (ref9 = opts.filter) != null ? ref9 : (function () {\n                return true;\n            });\n            this.tree = {};\n            this.rowKeys = [];\n            this.colKeys = [];\n            this.rowTotals = {};\n            this.colTotals = {};\n            this.allTotal = this.aggregator(this, [], []);\n            this.sorted = false;\n            PivotData.forEachRecord(this.input, this.derivedAttributes, (function (_this) {\n                return function (record) {\n                    if (_this.filter(record)) {\n                        return _this.processRecord(record);\n                    }\n                };\n            })(this));\n        }\n\n        PivotData.forEachRecord = function (input, derivedAttributes, f) {\n            var addRecord, compactRecord, i, j, k, l, len1, record, ref, results, results1, tblCols;\n            if ($.isEmptyObject(derivedAttributes)) {\n                addRecord = f;\n            } else {\n                addRecord = function (record) {\n                    var k, ref, v;\n                    for (k in derivedAttributes) {\n                        v = derivedAttributes[k];\n                        record[k] = (ref = v(record)) != null ? ref : record[k];\n                    }\n                    return f(record);\n                };\n            }\n            if ($.isFunction(input)) {\n                return input(addRecord);\n            } else if ($.isArray(input)) {\n                if ($.isArray(input[0])) {\n                    results = [];\n                    for (i in input) {\n                        if (!hasProp.call(input, i)) continue;\n                        compactRecord = input[i];\n                        if (!(i > 0)) {\n                            continue;\n                        }\n                        record = {};\n                        ref = input[0];\n                        for (j in ref) {\n                            if (!hasProp.call(ref, j)) continue;\n                            k = ref[j];\n                            record[k] = compactRecord[j];\n                        }\n                        results.push(addRecord(record));\n                    }\n                    return results;\n                } else {\n                    results1 = [];\n                    for (l = 0, len1 = input.length; l < len1; l++) {\n                        record = input[l];\n                        results1.push(addRecord(record));\n                    }\n                    return results1;\n                }\n            } else if (input instanceof $) {\n                tblCols = [];\n                $(\"thead > tr > th\", input).each(function (i) {\n                    return tblCols.push($(this).text());\n                });\n                return $(\"tbody > tr\", input).each(function (i) {\n                    record = {};\n                    $(\"td\", this).each(function (j) {\n                        return record[tblCols[j]] = $(this).text();\n                    });\n                    return addRecord(record);\n                });\n            } else {\n                throw new Error(\"unknown input format\");\n            }\n        };\n\n        PivotData.prototype.forEachMatchingRecord = function (criteria, callback) {\n            return PivotData.forEachRecord(this.input, this.derivedAttributes, (function (_this) {\n                return function (record) {\n                    var k, ref, v;\n                    if (!_this.filter(record)) {\n                        return;\n                    }\n                    for (k in criteria) {\n                        v = criteria[k];\n                        if (v !== ((ref = record[k]) != null ? ref : \"null\")) {\n                            return;\n                        }\n                    }\n                    return callback(record);\n                };\n            })(this));\n        };\n\n        PivotData.prototype.arrSort = function (attrs) {\n            var a, sortersArr;\n            sortersArr = (function () {\n                var l, len1, results;\n                results = [];\n                for (l = 0, len1 = attrs.length; l < len1; l++) {\n                    a = attrs[l];\n                    results.push(getSort(this.sorters, a));\n                }\n                return results;\n            }).call(this);\n            return function (a, b) {\n                var comparison, i, sorter;\n                for (i in sortersArr) {\n                    if (!hasProp.call(sortersArr, i)) continue;\n                    sorter = sortersArr[i];\n                    comparison = sorter(a[i], b[i]);\n                    if (comparison !== 0) {\n                        return comparison;\n                    }\n                }\n                return 0;\n            };\n        };\n\n        PivotData.prototype.sortKeys = function () {\n            var v;\n            if (!this.sorted) {\n                this.sorted = true;\n                v = (function (_this) {\n                    return function (r, c) {\n                        return _this.getAggregator(r, c).value();\n                    };\n                })(this);\n                switch (this.rowOrder) {\n                    case \"value_a_to_z\":\n                        this.rowKeys.sort((function (_this) {\n                            return function (a, b) {\n                                return naturalSort(v(a, []), v(b, []));\n                            };\n                        })(this));\n                        break;\n                    case \"value_z_to_a\":\n                        this.rowKeys.sort((function (_this) {\n                            return function (a, b) {\n                                return -naturalSort(v(a, []), v(b, []));\n                            };\n                        })(this));\n                        break;\n                    default:\n                        this.rowKeys.sort(this.arrSort(this.rowAttrs));\n                }\n                switch (this.colOrder) {\n                    case \"value_a_to_z\":\n                        return this.colKeys.sort((function (_this) {\n                            return function (a, b) {\n                                return naturalSort(v([], a), v([], b));\n                            };\n                        })(this));\n                    case \"value_z_to_a\":\n                        return this.colKeys.sort((function (_this) {\n                            return function (a, b) {\n                                return -naturalSort(v([], a), v([], b));\n                            };\n                        })(this));\n                    default:\n                        return this.colKeys.sort(this.arrSort(this.colAttrs));\n                }\n            }\n        };\n\n        PivotData.prototype.getColKeys = function () {\n            this.sortKeys();\n            return this.colKeys;\n        };\n\n        PivotData.prototype.getRowKeys = function () {\n            this.sortKeys();\n            return this.rowKeys;\n        };\n\n        PivotData.prototype.processRecord = function (record) {\n            var colKey, flatColKey, flatRowKey, l, len1, len2, n, ref, ref1, ref2, ref3, rowKey, x;\n            colKey = [];\n            rowKey = [];\n            ref = this.colAttrs;\n            for (l = 0, len1 = ref.length; l < len1; l++) {\n                x = ref[l];\n                colKey.push((ref1 = record[x]) != null ? ref1 : \"null\");\n            }\n            ref2 = this.rowAttrs;\n            for (n = 0, len2 = ref2.length; n < len2; n++) {\n                x = ref2[n];\n                rowKey.push((ref3 = record[x]) != null ? ref3 : \"null\");\n            }\n            flatRowKey = rowKey.join(String.fromCharCode(0));\n            flatColKey = colKey.join(String.fromCharCode(0));\n            this.allTotal.push(record);\n            if (rowKey.length !== 0) {\n                if (!this.rowTotals[flatRowKey]) {\n                    this.rowKeys.push(rowKey);\n                    this.rowTotals[flatRowKey] = this.aggregator(this, rowKey, []);\n                }\n                this.rowTotals[flatRowKey].push(record);\n            }\n            if (colKey.length !== 0) {\n                if (!this.colTotals[flatColKey]) {\n                    this.colKeys.push(colKey);\n                    this.colTotals[flatColKey] = this.aggregator(this, [], colKey);\n                }\n                this.colTotals[flatColKey].push(record);\n            }\n            if (colKey.length !== 0 && rowKey.length !== 0) {\n                if (!this.tree[flatRowKey]) {\n                    this.tree[flatRowKey] = {};\n                }\n                if (!this.tree[flatRowKey][flatColKey]) {\n                    this.tree[flatRowKey][flatColKey] = this.aggregator(this, rowKey, colKey);\n                }\n                return this.tree[flatRowKey][flatColKey].push(record);\n            }\n        };\n\n        PivotData.prototype.getAggregator = function (rowKey, colKey) {\n            var agg, flatColKey, flatRowKey;\n            flatRowKey = rowKey.join(String.fromCharCode(0));\n            flatColKey = colKey.join(String.fromCharCode(0));\n            if (rowKey.length === 0 && colKey.length === 0) {\n                agg = this.allTotal;\n            } else if (rowKey.length === 0) {\n                agg = this.colTotals[flatColKey];\n            } else if (colKey.length === 0) {\n                agg = this.rowTotals[flatRowKey];\n            } else {\n                agg = this.tree[flatRowKey][flatColKey];\n            }\n            return agg != null ? agg : {\n                value: (function () {\n                    return null;\n                }),\n                format: function () {\n                    return \"\";\n                }\n            };\n        };\n\n        return PivotData;\n\n    })();\n    $.pivotUtilities = {\n        aggregatorTemplates: aggregatorTemplates,\n        aggregators: aggregators,\n        renderers: renderers,\n        derivers: derivers,\n        locales: locales,\n        naturalSort: naturalSort,\n        numberFormat: numberFormat,\n        sortAs: sortAs,\n        PivotData: PivotData\n    };\n\n    /*\n    Default Renderer for hierarchical table layout\n     */\n    pivotTableRenderer = function (pivotData, opts) {\n        var aggregator, c, colAttrs, colKey, colKeys, defaults, getClickHandler, i, j, r, result, rowAttrs, rowKey,\n            rowKeys, spanSize, tbody, td, th, thead, totalAggregator, tr, txt, val, x;\n        defaults = {\n            table: {\n                clickCallback: null,\n                rowTotals: true,\n                colTotals: true\n            },\n            localeStrings: {\n                totals: \"Totals\"\n            }\n        };\n        opts = $.extend(true, {}, defaults, opts);\n        colAttrs = pivotData.colAttrs;\n        rowAttrs = pivotData.rowAttrs;\n        rowKeys = pivotData.getRowKeys();\n        colKeys = pivotData.getColKeys();\n        if (opts.table.clickCallback) {\n            getClickHandler = function (value, rowValues, colValues) {\n                var attr, filters, i;\n                filters = {};\n                for (i in colAttrs) {\n                    if (!hasProp.call(colAttrs, i)) continue;\n                    attr = colAttrs[i];\n                    if (colValues[i] != null) {\n                        filters[attr] = colValues[i];\n                    }\n                }\n                for (i in rowAttrs) {\n                    if (!hasProp.call(rowAttrs, i)) continue;\n                    attr = rowAttrs[i];\n                    if (rowValues[i] != null) {\n                        filters[attr] = rowValues[i];\n                    }\n                }\n                return function (e) {\n                    return opts.table.clickCallback(e, value, filters, pivotData);\n                };\n            };\n        }\n        result = document.createElement(\"table\");\n        result.className = \"pvtTable\";\n        spanSize = function (arr, i, j) {\n            var l, len, n, noDraw, ref, ref1, stop, x;\n            if (i !== 0) {\n                noDraw = true;\n                for (x = l = 0, ref = j; 0 <= ref ? l <= ref : l >= ref; x = 0 <= ref ? ++l : --l) {\n                    if (arr[i - 1][x] !== arr[i][x]) {\n                        noDraw = false;\n                    }\n                }\n                if (noDraw) {\n                    return -1;\n                }\n            }\n            len = 0;\n            while (i + len < arr.length) {\n                stop = false;\n                for (x = n = 0, ref1 = j; 0 <= ref1 ? n <= ref1 : n >= ref1; x = 0 <= ref1 ? ++n : --n) {\n                    if (arr[i][x] !== arr[i + len][x]) {\n                        stop = true;\n                    }\n                }\n                if (stop) {\n                    break;\n                }\n                len++;\n            }\n            return len;\n        };\n        thead = document.createElement(\"thead\");\n        for (j in colAttrs) {\n            if (!hasProp.call(colAttrs, j)) continue;\n            c = colAttrs[j];\n            tr = document.createElement(\"tr\");\n            if (parseInt(j) === 0 && rowAttrs.length !== 0) {\n                th = document.createElement(\"th\");\n                th.setAttribute(\"colspan\", rowAttrs.length);\n                th.setAttribute(\"rowspan\", colAttrs.length);\n                tr.appendChild(th);\n            }\n            th = document.createElement(\"th\");\n            th.className = \"pvtAxisLabel\";\n            th.textContent = c;\n            tr.appendChild(th);\n            for (i in colKeys) {\n                if (!hasProp.call(colKeys, i)) continue;\n                colKey = colKeys[i];\n                x = spanSize(colKeys, parseInt(i), parseInt(j));\n                if (x !== -1) {\n                    th = document.createElement(\"th\");\n                    th.className = \"pvtColLabel\";\n                    th.textContent = colKey[j];\n                    th.setAttribute(\"colspan\", x);\n                    if (parseInt(j) === colAttrs.length - 1 && rowAttrs.length !== 0) {\n                        th.setAttribute(\"rowspan\", 2);\n                    }\n                    tr.appendChild(th);\n                }\n            }\n            if (parseInt(j) === 0 && opts.table.rowTotals) {\n                th = document.createElement(\"th\");\n                th.className = \"pvtTotalLabel pvtRowTotalLabel\";\n                th.innerHTML = opts.localeStrings.totals;\n                th.setAttribute(\"rowspan\", colAttrs.length + (rowAttrs.length === 0 ? 0 : 1));\n                tr.appendChild(th);\n            }\n            thead.appendChild(tr);\n        }\n        if (rowAttrs.length !== 0) {\n            tr = document.createElement(\"tr\");\n            for (i in rowAttrs) {\n                if (!hasProp.call(rowAttrs, i)) continue;\n                r = rowAttrs[i];\n                th = document.createElement(\"th\");\n                th.className = \"pvtAxisLabel\";\n                th.textContent = r;\n                tr.appendChild(th);\n            }\n            th = document.createElement(\"th\");\n            if (colAttrs.length === 0) {\n                th.className = \"pvtTotalLabel pvtRowTotalLabel\";\n                th.innerHTML = opts.localeStrings.totals;\n            }\n            tr.appendChild(th);\n            thead.appendChild(tr);\n        }\n        result.appendChild(thead);\n        tbody = document.createElement(\"tbody\");\n        for (i in rowKeys) {\n            if (!hasProp.call(rowKeys, i)) continue;\n            rowKey = rowKeys[i];\n            tr = document.createElement(\"tr\");\n            for (j in rowKey) {\n                if (!hasProp.call(rowKey, j)) continue;\n                txt = rowKey[j];\n                x = spanSize(rowKeys, parseInt(i), parseInt(j));\n                if (x !== -1) {\n                    th = document.createElement(\"th\");\n                    th.className = \"pvtRowLabel\";\n                    th.textContent = txt;\n                    th.setAttribute(\"rowspan\", x);\n                    if (parseInt(j) === rowAttrs.length - 1 && colAttrs.length !== 0) {\n                        th.setAttribute(\"colspan\", 2);\n                    }\n                    tr.appendChild(th);\n                }\n            }\n            for (j in colKeys) {\n                if (!hasProp.call(colKeys, j)) continue;\n                colKey = colKeys[j];\n                aggregator = pivotData.getAggregator(rowKey, colKey);\n                val = aggregator.value();\n                td = document.createElement(\"td\");\n                td.className = \"pvtVal row\" + i + \" col\" + j;\n                td.textContent = aggregator.format(val);\n                td.setAttribute(\"data-value\", val);\n                if (getClickHandler != null) {\n                    td.onclick = getClickHandler(val, rowKey, colKey);\n                }\n                tr.appendChild(td);\n            }\n            if (opts.table.rowTotals || colAttrs.length === 0) {\n                totalAggregator = pivotData.getAggregator(rowKey, []);\n                val = totalAggregator.value();\n                td = document.createElement(\"td\");\n                td.className = \"pvtTotal rowTotal\";\n                td.textContent = totalAggregator.format(val);\n                td.setAttribute(\"data-value\", val);\n                if (getClickHandler != null) {\n                    td.onclick = getClickHandler(val, rowKey, []);\n                }\n                td.setAttribute(\"data-for\", \"row\" + i);\n                tr.appendChild(td);\n            }\n            tbody.appendChild(tr);\n        }\n        if (opts.table.colTotals || rowAttrs.length === 0) {\n            tr = document.createElement(\"tr\");\n            if (opts.table.colTotals || rowAttrs.length === 0) {\n                th = document.createElement(\"th\");\n                th.className = \"pvtTotalLabel pvtColTotalLabel\";\n                th.innerHTML = opts.localeStrings.totals;\n                th.setAttribute(\"colspan\", rowAttrs.length + (colAttrs.length === 0 ? 0 : 1));\n                tr.appendChild(th);\n            }\n            for (j in colKeys) {\n                if (!hasProp.call(colKeys, j)) continue;\n                colKey = colKeys[j];\n                totalAggregator = pivotData.getAggregator([], colKey);\n                val = totalAggregator.value();\n                td = document.createElement(\"td\");\n                td.className = \"pvtTotal colTotal\";\n                td.textContent = totalAggregator.format(val);\n                td.setAttribute(\"data-value\", val);\n                if (getClickHandler != null) {\n                    td.onclick = getClickHandler(val, [], colKey);\n                }\n                td.setAttribute(\"data-for\", \"col\" + j);\n                tr.appendChild(td);\n            }\n            if (opts.table.rowTotals || colAttrs.length === 0) {\n                totalAggregator = pivotData.getAggregator([], []);\n                val = totalAggregator.value();\n                td = document.createElement(\"td\");\n                td.className = \"pvtGrandTotal\";\n                td.textContent = totalAggregator.format(val);\n                td.setAttribute(\"data-value\", val);\n                if (getClickHandler != null) {\n                    td.onclick = getClickHandler(val, [], []);\n                }\n                tr.appendChild(td);\n            }\n            tbody.appendChild(tr);\n        }\n        result.appendChild(tbody);\n        result.setAttribute(\"data-numrows\", rowKeys.length);\n        result.setAttribute(\"data-numcols\", colKeys.length);\n        return result;\n    };\n\n    /*\n    Pivot Table core: create PivotData object and call Renderer on it\n     */\n    $.fn.pivot = function (input, inputOpts, locale) {\n        var defaults, e, localeDefaults, localeStrings, opts, pivotData, result, x;\n        if (locale == null) {\n            locale = \"en\";\n        }\n        if (locales[locale] == null) {\n            locale = \"en\";\n        }\n        defaults = {\n            cols: [],\n            rows: [],\n            vals: [],\n            rowOrder: \"key_a_to_z\",\n            colOrder: \"key_a_to_z\",\n            dataClass: PivotData,\n            filter: function () {\n                return true;\n            },\n            aggregator: aggregatorTemplates.count()(),\n            aggregatorName: \"Count\",\n            sorters: {},\n            derivedAttributes: {},\n            renderer: pivotTableRenderer\n        };\n        localeStrings = $.extend(true, {}, locales.en.localeStrings, locales[locale].localeStrings);\n        localeDefaults = {\n            rendererOptions: {\n                localeStrings: localeStrings\n            },\n            localeStrings: localeStrings\n        };\n        opts = $.extend(true, {}, localeDefaults, $.extend({}, defaults, inputOpts));\n        result = null;\n        try {\n            pivotData = new opts.dataClass(input, opts);\n            try {\n                result = opts.renderer(pivotData, opts.rendererOptions);\n            } catch (error) {\n                e = error;\n                if (typeof console !== \"undefined\" && console !== null) {\n                    console.error(e.stack);\n                }\n                result = $(\"<span>\").html(opts.localeStrings.renderError);\n            }\n        } catch (error) {\n            e = error;\n            if (typeof console !== \"undefined\" && console !== null) {\n                console.error(e.stack);\n            }\n            result = $(\"<span>\").html(opts.localeStrings.computeError);\n        }\n        x = this[0];\n        while (x.hasChildNodes()) {\n            x.removeChild(x.lastChild);\n        }\n        return this.append(result);\n    };\n\n    /*\n    Pivot Table UI: calls Pivot Table core above with options set by user\n     */\n    $.fn.pivotUI = function (input, inputOpts, overwrite, locale) {\n        var a, aggregator, attr, attrLength, attrValues, c, colOrderArrow, defaults, e, existingOpts, fn1, i,\n            initialRender, l, len1, len2, len3, localeDefaults, localeStrings, materializedInput, n, o, opts, ordering,\n            pivotTable, recordsProcessed, ref, ref1, ref2, ref3, refresh, refreshDelayed, renderer, rendererControl,\n            rowOrderArrow, shownAttributes, shownInAggregators, shownInDragDrop, tr1, tr2, uiTable, unused,\n            unusedAttrsVerticalAutoCutoff, unusedAttrsVerticalAutoOverride, x;\n        if (overwrite == null) {\n            overwrite = false;\n        }\n        if (locale == null) {\n            locale = \"en\";\n        }\n        if (locales[locale] == null) {\n            locale = \"en\";\n        }\n        defaults = {\n            derivedAttributes: {},\n            aggregators: locales[locale].aggregators,\n            renderers: locales[locale].renderers,\n            hiddenAttributes: [],\n            hiddenFromAggregators: [],\n            hiddenFromDragDrop: [],\n            menuLimit: 500,\n            cols: [],\n            rows: [],\n            vals: [],\n            rowOrder: \"key_a_to_z\",\n            colOrder: \"key_a_to_z\",\n            dataClass: PivotData,\n            exclusions: {},\n            inclusions: {},\n            unusedAttrsVertical: 85,\n            autoSortUnusedAttrs: false,\n            onRefresh: null,\n            showUI: true,\n            filter: function () {\n                return true;\n            },\n            sorters: {}\n        };\n        localeStrings = $.extend(true, {}, locales.en.localeStrings, locales[locale].localeStrings);\n        localeDefaults = {\n            rendererOptions: {\n                localeStrings: localeStrings\n            },\n            localeStrings: localeStrings\n        };\n        existingOpts = this.data(\"pivotUIOptions\");\n        if ((existingOpts == null) || overwrite) {\n            opts = $.extend(true, {}, localeDefaults, $.extend({}, defaults, inputOpts));\n        } else {\n            opts = existingOpts;\n        }\n        try {\n            attrValues = {};\n            materializedInput = [];\n            recordsProcessed = 0;\n            PivotData.forEachRecord(input, opts.derivedAttributes, function (record) {\n                var attr, base, ref, value;\n                if (!opts.filter(record)) {\n                    return;\n                }\n                materializedInput.push(record);\n                for (attr in record) {\n                    if (!hasProp.call(record, attr)) continue;\n                    if (attrValues[attr] == null) {\n                        attrValues[attr] = {};\n                        if (recordsProcessed > 0) {\n                            attrValues[attr][\"null\"] = recordsProcessed;\n                        }\n                    }\n                }\n                for (attr in attrValues) {\n                    value = (ref = record[attr]) != null ? ref : \"null\";\n                    if ((base = attrValues[attr])[value] == null) {\n                        base[value] = 0;\n                    }\n                    attrValues[attr][value]++;\n                }\n                return recordsProcessed++;\n            });\n            uiTable = $(\"<table>\", {\n                \"class\": \"pvtUi\"\n            }).attr(\"cellpadding\", 5);\n            rendererControl = $(\"<td>\").addClass(\"pvtUiCell\");\n            renderer = $(\"<select>\").addClass('pvtRenderer').appendTo(rendererControl).bind(\"change\", function () {\n                return refresh();\n            });\n            ref = opts.renderers;\n            for (x in ref) {\n                if (!hasProp.call(ref, x)) continue;\n                $(\"<option>\").val(x).html(x).appendTo(renderer);\n            }\n            unused = $(\"<td>\").addClass('pvtAxisContainer pvtUnused pvtUiCell');\n            shownAttributes = (function () {\n                var results;\n                results = [];\n                for (a in attrValues) {\n                    if (indexOf.call(opts.hiddenAttributes, a) < 0) {\n                        results.push(a);\n                    }\n                }\n                return results;\n            })();\n            shownInAggregators = (function () {\n                var l, len1, results;\n                results = [];\n                for (l = 0, len1 = shownAttributes.length; l < len1; l++) {\n                    c = shownAttributes[l];\n                    if (indexOf.call(opts.hiddenFromAggregators, c) < 0) {\n                        results.push(c);\n                    }\n                }\n                return results;\n            })();\n            shownInDragDrop = (function () {\n                var l, len1, results;\n                results = [];\n                for (l = 0, len1 = shownAttributes.length; l < len1; l++) {\n                    c = shownAttributes[l];\n                    if (indexOf.call(opts.hiddenFromDragDrop, c) < 0) {\n                        results.push(c);\n                    }\n                }\n                return results;\n            })();\n            unusedAttrsVerticalAutoOverride = false;\n            if (opts.unusedAttrsVertical === \"auto\") {\n                unusedAttrsVerticalAutoCutoff = 120;\n            } else {\n                unusedAttrsVerticalAutoCutoff = parseInt(opts.unusedAttrsVertical);\n            }\n            if (!isNaN(unusedAttrsVerticalAutoCutoff)) {\n                attrLength = 0;\n                for (l = 0, len1 = shownInDragDrop.length; l < len1; l++) {\n                    a = shownInDragDrop[l];\n                    attrLength += a.length;\n                }\n                unusedAttrsVerticalAutoOverride = attrLength > unusedAttrsVerticalAutoCutoff;\n            }\n            if (opts.unusedAttrsVertical === true || unusedAttrsVerticalAutoOverride) {\n                unused.addClass('pvtVertList');\n            } else {\n                unused.addClass('pvtHorizList');\n            }\n            fn1 = function (attr) {\n                var attrElem, checkContainer, closeFilterBox, controls, filterItem, filterItemExcluded, finalButtons,\n                    hasExcludedItem, len2, n, placeholder, ref1, sorter, triangleLink, v, value, valueCount, valueList,\n                    values;\n                values = (function () {\n                    var results;\n                    results = [];\n                    for (v in attrValues[attr]) {\n                        results.push(v);\n                    }\n                    return results;\n                })();\n                hasExcludedItem = false;\n                valueList = $(\"<div>\").addClass('pvtFilterBox').hide();\n                valueList.append($(\"<h4>\").append($(\"<span>\").text(attr), $(\"<span>\").addClass(\"count\").text(\"(\" + values.length + \")\")));\n                if (values.length > opts.menuLimit) {\n                    valueList.append($(\"<p>\").html(opts.localeStrings.tooMany));\n                } else {\n                    if (values.length > 5) {\n                        controls = $(\"<p>\").appendTo(valueList);\n                        sorter = getSort(opts.sorters, attr);\n                        placeholder = opts.localeStrings.filterResults;\n                        $(\"<input>\", {\n                            type: \"text\"\n                        }).appendTo(controls).attr({\n                            placeholder: placeholder,\n                            \"class\": \"pvtSearch\"\n                        }).bind(\"keyup\", function () {\n                            var accept, accept_gen, filter;\n                            filter = $(this).val().toLowerCase().trim();\n                            accept_gen = function (prefix, accepted) {\n                                return function (v) {\n                                    var real_filter, ref1;\n                                    real_filter = filter.substring(prefix.length).trim();\n                                    if (real_filter.length === 0) {\n                                        return true;\n                                    }\n                                    return ref1 = Math.sign(sorter(v.toLowerCase(), real_filter)), indexOf.call(accepted, ref1) >= 0;\n                                };\n                            };\n                            accept = filter.indexOf(\">=\") === 0 ? accept_gen(\">=\", [1, 0]) : filter.indexOf(\"<=\") === 0 ? accept_gen(\"<=\", [-1, 0]) : filter.indexOf(\">\") === 0 ? accept_gen(\">\", [1]) : filter.indexOf(\"<\") === 0 ? accept_gen(\"<\", [-1]) : filter.indexOf(\"~\") === 0 ? function (v) {\n                                if (filter.substring(1).trim().length === 0) {\n                                    return true;\n                                }\n                                return v.toLowerCase().match(filter.substring(1));\n                            } : function (v) {\n                                return v.toLowerCase().indexOf(filter) !== -1;\n                            };\n                            return valueList.find('.pvtCheckContainer p label span.value').each(function () {\n                                if (accept($(this).text())) {\n                                    return $(this).parent().parent().show();\n                                } else {\n                                    return $(this).parent().parent().hide();\n                                }\n                            });\n                        });\n                        controls.append($(\"<br>\"));\n                        $(\"<button>\", {\n                            type: \"button\"\n                        }).appendTo(controls).html(opts.localeStrings.selectAll).bind(\"click\", function () {\n                            valueList.find(\"input:visible:not(:checked)\").prop(\"checked\", true).toggleClass(\"changed\");\n                            return false;\n                        });\n                        $(\"<button>\", {\n                            type: \"button\"\n                        }).appendTo(controls).html(opts.localeStrings.selectNone).bind(\"click\", function () {\n                            valueList.find(\"input:visible:checked\").prop(\"checked\", false).toggleClass(\"changed\");\n                            return false;\n                        });\n                    }\n                    checkContainer = $(\"<div>\").addClass(\"pvtCheckContainer\").appendTo(valueList);\n                    ref1 = values.sort(getSort(opts.sorters, attr));\n                    for (n = 0, len2 = ref1.length; n < len2; n++) {\n                        value = ref1[n];\n                        valueCount = attrValues[attr][value];\n                        filterItem = $(\"<label>\");\n                        filterItemExcluded = false;\n                        if (opts.inclusions[attr]) {\n                            filterItemExcluded = (indexOf.call(opts.inclusions[attr], value) < 0);\n                        } else if (opts.exclusions[attr]) {\n                            filterItemExcluded = (indexOf.call(opts.exclusions[attr], value) >= 0);\n                        }\n                        hasExcludedItem || (hasExcludedItem = filterItemExcluded);\n                        $(\"<input>\").attr(\"type\", \"checkbox\").addClass('pvtFilter').attr(\"checked\", !filterItemExcluded).data(\"filter\", [attr, value]).appendTo(filterItem).bind(\"change\", function () {\n                            return $(this).toggleClass(\"changed\");\n                        });\n                        filterItem.append($(\"<span>\").addClass(\"value\").text(value));\n                        filterItem.append($(\"<span>\").addClass(\"count\").text(\"(\" + valueCount + \")\"));\n                        checkContainer.append($(\"<p>\").append(filterItem));\n                    }\n                }\n                closeFilterBox = function () {\n                    if (valueList.find(\"[type='checkbox']\").length > valueList.find(\"[type='checkbox']:checked\").length) {\n                        attrElem.addClass(\"pvtFilteredAttribute\");\n                    } else {\n                        attrElem.removeClass(\"pvtFilteredAttribute\");\n                    }\n                    valueList.find('.pvtSearch').val('');\n                    valueList.find('.pvtCheckContainer p').show();\n                    return valueList.hide();\n                };\n                finalButtons = $(\"<p>\").appendTo(valueList);\n                if (values.length <= opts.menuLimit) {\n                    $(\"<button>\", {\n                        type: \"button\"\n                    }).text(opts.localeStrings.apply).appendTo(finalButtons).bind(\"click\", function () {\n                        if (valueList.find(\".changed\").removeClass(\"changed\").length) {\n                            refresh();\n                        }\n                        return closeFilterBox();\n                    });\n                }\n                $(\"<button>\", {\n                    type: \"button\"\n                }).text(opts.localeStrings.cancel).appendTo(finalButtons).bind(\"click\", function () {\n                    valueList.find(\".changed:checked\").removeClass(\"changed\").prop(\"checked\", false);\n                    valueList.find(\".changed:not(:checked)\").removeClass(\"changed\").prop(\"checked\", true);\n                    return closeFilterBox();\n                });\n                triangleLink = $(\"<span>\").addClass('pvtTriangle').html(\" &#x25BE;\").bind(\"click\", function (e) {\n                    var left, ref2, top;\n                    ref2 = $(e.currentTarget).position(), left = ref2.left, top = ref2.top;\n                    return valueList.css({\n                        left: left + 10,\n                        top: top + 10\n                    }).show();\n                });\n                attrElem = $(\"<li>\").addClass(\"axis_\" + i).append($(\"<span>\").addClass('pvtAttr').text(attr).data(\"attrName\", attr).append(triangleLink));\n                if (hasExcludedItem) {\n                    attrElem.addClass('pvtFilteredAttribute');\n                }\n                return unused.append(attrElem).append(valueList);\n            };\n            for (i in shownInDragDrop) {\n                if (!hasProp.call(shownInDragDrop, i)) continue;\n                attr = shownInDragDrop[i];\n                fn1(attr);\n            }\n            tr1 = $(\"<tr>\").appendTo(uiTable);\n            aggregator = $(\"<select>\").addClass('pvtAggregator').bind(\"change\", function () {\n                return refresh();\n            });\n            ref1 = opts.aggregators;\n            for (x in ref1) {\n                if (!hasProp.call(ref1, x)) continue;\n                aggregator.append($(\"<option>\").val(x).html(x));\n            }\n            ordering = {\n                key_a_to_z: {\n                    rowSymbol: \"&varr;\",\n                    colSymbol: \"&harr;\",\n                    next: \"value_a_to_z\"\n                },\n                value_a_to_z: {\n                    rowSymbol: \"&darr;\",\n                    colSymbol: \"&rarr;\",\n                    next: \"value_z_to_a\"\n                },\n                value_z_to_a: {\n                    rowSymbol: \"&uarr;\",\n                    colSymbol: \"&larr;\",\n                    next: \"key_a_to_z\"\n                }\n            };\n            rowOrderArrow = $(\"<a>\", {\n                role: \"button\"\n            }).addClass(\"pvtRowOrder\").data(\"order\", opts.rowOrder).html(ordering[opts.rowOrder].rowSymbol).bind(\"click\", function () {\n                $(this).data(\"order\", ordering[$(this).data(\"order\")].next);\n                $(this).html(ordering[$(this).data(\"order\")].rowSymbol);\n                return refresh();\n            });\n            colOrderArrow = $(\"<a>\", {\n                role: \"button\"\n            }).addClass(\"pvtColOrder\").data(\"order\", opts.colOrder).html(ordering[opts.colOrder].colSymbol).bind(\"click\", function () {\n                $(this).data(\"order\", ordering[$(this).data(\"order\")].next);\n                $(this).html(ordering[$(this).data(\"order\")].colSymbol);\n                return refresh();\n            });\n            $(\"<td>\").addClass('pvtVals pvtUiCell').appendTo(tr1).append(aggregator).append(rowOrderArrow).append(colOrderArrow).append($(\"<br>\"));\n            $(\"<td>\").addClass('pvtAxisContainer pvtHorizList pvtCols pvtUiCell').appendTo(tr1);\n            tr2 = $(\"<tr>\").appendTo(uiTable);\n            tr2.append($(\"<td>\").addClass('pvtAxisContainer pvtRows pvtUiCell').attr(\"valign\", \"top\"));\n            pivotTable = $(\"<td>\").attr(\"valign\", \"top\").addClass('pvtRendererArea').appendTo(tr2);\n            if (opts.unusedAttrsVertical === true || unusedAttrsVerticalAutoOverride) {\n                uiTable.find('tr:nth-child(1)').prepend(rendererControl);\n                uiTable.find('tr:nth-child(2)').prepend(unused);\n            } else {\n                uiTable.prepend($(\"<tr>\").append(rendererControl).append(unused));\n            }\n            this.html(uiTable);\n            ref2 = opts.cols;\n            for (n = 0, len2 = ref2.length; n < len2; n++) {\n                x = ref2[n];\n                this.find(\".pvtCols\").append(this.find(\".axis_\" + ($.inArray(x, shownInDragDrop))));\n            }\n            ref3 = opts.rows;\n            for (o = 0, len3 = ref3.length; o < len3; o++) {\n                x = ref3[o];\n                this.find(\".pvtRows\").append(this.find(\".axis_\" + ($.inArray(x, shownInDragDrop))));\n            }\n            if (opts.aggregatorName != null) {\n                this.find(\".pvtAggregator\").val(opts.aggregatorName);\n            }\n            if (opts.rendererName != null) {\n                this.find(\".pvtRenderer\").val(opts.rendererName);\n            }\n            if (!opts.showUI) {\n                this.find(\".pvtUiCell\").hide();\n            }\n            initialRender = true;\n            refreshDelayed = (function (_this) {\n                return function () {\n                    var exclusions, inclusions, len4, newDropdown, numInputsToProcess, pivotUIOptions, pvtVals, ref4,\n                        ref5, subopts, t, u, unusedAttrsContainer, vals;\n                    subopts = {\n                        derivedAttributes: opts.derivedAttributes,\n                        localeStrings: opts.localeStrings,\n                        rendererOptions: opts.rendererOptions,\n                        sorters: opts.sorters,\n                        cols: [],\n                        rows: [],\n                        dataClass: opts.dataClass\n                    };\n                    numInputsToProcess = (ref4 = opts.aggregators[aggregator.val()]([])().numInputs) != null ? ref4 : 0;\n                    vals = [];\n                    _this.find(\".pvtRows li span.pvtAttr\").each(function () {\n                        return subopts.rows.push($(this).data(\"attrName\"));\n                    });\n                    _this.find(\".pvtCols li span.pvtAttr\").each(function () {\n                        return subopts.cols.push($(this).data(\"attrName\"));\n                    });\n                    _this.find(\".pvtVals select.pvtAttrDropdown\").each(function () {\n                        if (numInputsToProcess === 0) {\n                            return $(this).remove();\n                        } else {\n                            numInputsToProcess--;\n                            if ($(this).val() !== \"\") {\n                                return vals.push($(this).val());\n                            }\n                        }\n                    });\n                    if (numInputsToProcess !== 0) {\n                        pvtVals = _this.find(\".pvtVals\");\n                        for (x = t = 0, ref5 = numInputsToProcess; 0 <= ref5 ? t < ref5 : t > ref5; x = 0 <= ref5 ? ++t : --t) {\n                            newDropdown = $(\"<select>\").addClass('pvtAttrDropdown').append($(\"<option>\")).bind(\"change\", function () {\n                                return refresh();\n                            });\n                            for (u = 0, len4 = shownInAggregators.length; u < len4; u++) {\n                                attr = shownInAggregators[u];\n                                newDropdown.append($(\"<option>\").val(attr).text(attr));\n                            }\n                            pvtVals.append(newDropdown);\n                        }\n                    }\n                    if (initialRender) {\n                        vals = opts.vals;\n                        i = 0;\n                        _this.find(\".pvtVals select.pvtAttrDropdown\").each(function () {\n                            $(this).val(vals[i]);\n                            return i++;\n                        });\n                        initialRender = false;\n                    }\n                    subopts.aggregatorName = aggregator.val();\n                    subopts.vals = vals;\n                    subopts.aggregator = opts.aggregators[aggregator.val()](vals);\n                    subopts.renderer = opts.renderers[renderer.val()];\n                    subopts.rowOrder = rowOrderArrow.data(\"order\");\n                    subopts.colOrder = colOrderArrow.data(\"order\");\n                    exclusions = {};\n                    _this.find('input.pvtFilter').not(':checked').each(function () {\n                        var filter;\n                        filter = $(this).data(\"filter\");\n                        if (exclusions[filter[0]] != null) {\n                            return exclusions[filter[0]].push(filter[1]);\n                        } else {\n                            return exclusions[filter[0]] = [filter[1]];\n                        }\n                    });\n                    inclusions = {};\n                    _this.find('input.pvtFilter:checked').each(function () {\n                        var filter;\n                        filter = $(this).data(\"filter\");\n                        if (exclusions[filter[0]] != null) {\n                            if (inclusions[filter[0]] != null) {\n                                return inclusions[filter[0]].push(filter[1]);\n                            } else {\n                                return inclusions[filter[0]] = [filter[1]];\n                            }\n                        }\n                    });\n                    subopts.filter = function (record) {\n                        var excludedItems, k, ref6, ref7;\n                        if (!opts.filter(record)) {\n                            return false;\n                        }\n                        for (k in exclusions) {\n                            excludedItems = exclusions[k];\n                            if (ref6 = \"\" + ((ref7 = record[k]) != null ? ref7 : 'null'), indexOf.call(excludedItems, ref6) >= 0) {\n                                return false;\n                            }\n                        }\n                        return true;\n                    };\n                    pivotTable.pivot(materializedInput, subopts);\n                    pivotUIOptions = $.extend({}, opts, {\n                        cols: subopts.cols,\n                        rows: subopts.rows,\n                        colOrder: subopts.colOrder,\n                        rowOrder: subopts.rowOrder,\n                        vals: vals,\n                        exclusions: exclusions,\n                        inclusions: inclusions,\n                        inclusionsInfo: inclusions,\n                        aggregatorName: aggregator.val(),\n                        rendererName: renderer.val()\n                    });\n                    _this.data(\"pivotUIOptions\", pivotUIOptions);\n                    if (opts.autoSortUnusedAttrs) {\n                        unusedAttrsContainer = _this.find(\"td.pvtUnused.pvtAxisContainer\");\n                        $(unusedAttrsContainer).children(\"li\").sort(function (a, b) {\n                            return naturalSort($(a).text(), $(b).text());\n                        }).appendTo(unusedAttrsContainer);\n                    }\n                    pivotTable.css(\"opacity\", 1);\n                    if (opts.onRefresh != null) {\n                        return opts.onRefresh(pivotUIOptions);\n                    }\n                };\n            })(this);\n            refresh = (function (_this) {\n                return function () {\n                    pivotTable.css(\"opacity\", 0.5);\n                    return setTimeout(refreshDelayed, 10);\n                };\n            })(this);\n            refresh();\n\n            document.querySelectorAll('.pvtAxisContainer').forEach(function(el) {\n                Sortable.create(el, {\n                    group: 'pvtAxisContainer', // This allows for dragging between containers\n                    draggable: 'li', // Specifies that only <li> elements should be draggable\n                    animation: 150, // Animation speed\n                    ghostClass: 'pvtPlaceholder', // Class name for the drop placeholder\n                    onEnd: function (evt) {\n                        refresh();\n                    },\n                });\n            });\n\n        } catch (error) {\n            e = error;\n            if (typeof console !== \"undefined\" && console !== null) {\n                console.error(e.stack);\n            }\n            this.html(opts.localeStrings.uiRenderError);\n        }\n        return this;\n    };\n\n    /*\n    Heatmap post-processing\n     */\n    $.fn.heatmap = function (scope, opts) {\n        var colorScaleGenerator, heatmapper, i, j, l, n, numCols, numRows, ref, ref1, ref2;\n        if (scope == null) {\n            scope = \"heatmap\";\n        }\n        numRows = this.data(\"numrows\");\n        numCols = this.data(\"numcols\");\n        colorScaleGenerator = opts != null ? (ref = opts.heatmap) != null ? ref.colorScaleGenerator : void 0 : void 0;\n        if (colorScaleGenerator == null) {\n            colorScaleGenerator = function (values) {\n                var max, min;\n                min = Math.min.apply(Math, values);\n                max = Math.max.apply(Math, values);\n                return function (x) {\n                    var nonRed;\n                    nonRed = 255 - Math.round(255 * (x - min) / (max - min));\n                    return \"rgb(255,\" + nonRed + \",\" + nonRed + \")\";\n                };\n            };\n        }\n        heatmapper = (function (_this) {\n            return function (scope) {\n                var colorScale, forEachCell, values;\n                forEachCell = function (f) {\n                    return _this.find(scope).each(function () {\n                        var x;\n                        x = $(this).data(\"value\");\n                        if ((x != null) && isFinite(x)) {\n                            return f(x, $(this));\n                        }\n                    });\n                };\n                values = [];\n                forEachCell(function (x) {\n                    return values.push(x);\n                });\n                colorScale = colorScaleGenerator(values);\n                return forEachCell(function (x, elem) {\n                    return elem.css(\"background-color\", colorScale(x));\n                });\n            };\n        })(this);\n        switch (scope) {\n            case \"heatmap\":\n                heatmapper(\".pvtVal\");\n                break;\n            case \"rowheatmap\":\n                for (i = l = 0, ref1 = numRows; 0 <= ref1 ? l < ref1 : l > ref1; i = 0 <= ref1 ? ++l : --l) {\n                    heatmapper(\".pvtVal.row\" + i);\n                }\n                break;\n            case \"colheatmap\":\n                for (j = n = 0, ref2 = numCols; 0 <= ref2 ? n < ref2 : n > ref2; j = 0 <= ref2 ? ++n : --n) {\n                    heatmapper(\".pvtVal.col\" + j);\n                }\n        }\n        heatmapper(\".pvtTotal.rowTotal\");\n        heatmapper(\".pvtTotal.colTotal\");\n        return this;\n    };\n\n    /*\n    Barchart post-processing\n     */\n    return $.fn.barchart = function (opts) {\n        var barcharter, i, l, numCols, numRows, ref;\n        numRows = this.data(\"numrows\");\n        numCols = this.data(\"numcols\");\n        barcharter = (function (_this) {\n            return function (scope) {\n                var forEachCell, max, min, range, scaler, values;\n                forEachCell = function (f) {\n                    return _this.find(scope).each(function () {\n                        var x;\n                        x = $(this).data(\"value\");\n                        if ((x != null) && isFinite(x)) {\n                            return f(x, $(this));\n                        }\n                    });\n                };\n                values = [];\n                forEachCell(function (x) {\n                    return values.push(x);\n                });\n                max = Math.max.apply(Math, values);\n                if (max < 0) {\n                    max = 0;\n                }\n                range = max;\n                min = Math.min.apply(Math, values);\n                if (min < 0) {\n                    range = max - min;\n                }\n                scaler = function (x) {\n                    return 100 * x / (1.4 * range);\n                };\n                return forEachCell(function (x, elem) {\n                    var bBase, bgColor, text, wrapper;\n                    text = elem.text();\n                    wrapper = $(\"<div>\").css({\n                        \"position\": \"relative\",\n                        \"height\": \"55px\"\n                    });\n                    bgColor = \"gray\";\n                    bBase = 0;\n                    if (min < 0) {\n                        bBase = scaler(-min);\n                    }\n                    if (x < 0) {\n                        bBase += scaler(x);\n                        bgColor = \"darkred\";\n                        x = -x;\n                    }\n                    wrapper.append($(\"<div>\").css({\n                        \"position\": \"absolute\",\n                        \"bottom\": bBase + \"%\",\n                        \"left\": 0,\n                        \"right\": 0,\n                        \"height\": scaler(x) + \"%\",\n                        \"background-color\": bgColor\n                    }));\n                    wrapper.append($(\"<div>\").text(text).css({\n                        \"position\": \"relative\",\n                        \"padding-left\": \"5px\",\n                        \"padding-right\": \"5px\"\n                    }));\n                    return elem.css({\n                        \"padding\": 0,\n                        \"padding-top\": \"5px\",\n                        \"text-align\": \"center\"\n                    }).html(wrapper);\n                });\n            };\n        })(this);\n        for (i = l = 0, ref = numRows; 0 <= ref ? l < ref : l > ref; i = 0 <= ref ? ++l : --l) {\n            barcharter(\".pvtVal.row\" + i);\n        }\n        barcharter(\".pvtTotal.colTotal\");\n        return this;\n    };\n}\n"
  },
  {
    "path": "explorer/src/js/query-list.js",
    "content": "import List from \"list.js\";\nimport {getCsrfToken} from \"./csrf\";\nimport {toggleFavorite} from \"./favorites\";\nimport * as bootstrap from 'bootstrap'; // eslint-disable-line no-unused-vars\n\nfunction searchFocus() {\n    const searchElement = document.querySelector('.search');\n    if (searchElement) {\n        searchElement.focus();\n    }\n}\nfunction expandAll(param) {\n    const searchTerm = document.querySelector('.search').value;\n    if (searchTerm.trim() !== \"\") {\n        document.querySelectorAll('.collapse').forEach(function (element) {\n            element.classList.add('show');\n        });\n    }\n}\nexport function setupQueryList() {\n\n    document.querySelectorAll('.query_favorite_toggle').forEach(function (element) {\n        element.addEventListener('click', toggleFavorite);\n    });\n\n    let options = {\n        valueNames: ['sort-name', 'sort-created', 'sort-created', 'sort-last-run', 'sort-run-count', 'sort-connection'],\n        handlers: {'updated': [searchFocus],\n                   'searchComplete': [expandAll]},\n        searchDelay: 250,\n        searchColumns: ['sort-name']\n    };\n    new List('queries', options);\n\n    setUpEmailCsv();\n}\n\nfunction setUpEmailCsv() {\n    let emailModal = new bootstrap.Modal('#emailCsvModal', {});\n    let curQueryEmailId = null;\n\n    let isValidEmail = function (email) {\n        return /^(([^<>()[\\]\\.,;:\\s@\\\"]+(\\.[^<>()[\\]\\.,;:\\s@\\\"]+)*)|(\\\".+\\\"))@(([^<>()[\\]\\.,;:\\s@\\\"]+\\.)+[^<>()[\\]\\.,;:\\s@\\\"]{2,})$/i.test(email);\n    };\n\n    let showEmailSuccess = () => {\n        const msgSuccess = document.getElementById('email-success-msg');\n        const msgAlert = document.getElementById('email-error-msg');\n        msgSuccess.style.display = 'block';\n        msgAlert.style.display = 'none';\n        setTimeout(() => emailModal.hide(), 2000);\n    }\n\n    let showEmailError = (msg) => {\n        const msgSuccess = document.getElementById('email-success-msg');\n        const msgAlert = document.getElementById('email-error-msg');\n        msgAlert.innerHTML = msg; // Equivalent to .html(msg) in jQuery\n        msgSuccess.style.display = 'none';\n        msgAlert.style.display = 'block';\n    }\n    let handleEmailCsvSubmit = function (e) {\n        let email = document.querySelector('#emailCsvInput').value;\n        let url =`${window.baseUrlPath}${curQueryEmailId}/email_csv?email=${email}`;\n        if (isValidEmail(email)) {\n            fetch(url, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                    'X-CSRFToken': getCsrfToken()\n                },\n                body: JSON.stringify({\n                    email: email\n                }),\n            })\n            .then(response => {\n                if (!response.ok) {\n                    throw new Error('Network response was not ok');\n                }\n                return response.json();\n            })\n            .then(data => {\n                showEmailSuccess(data);\n            })\n            .catch((error) => {\n                showEmailError(error.message);\n            });\n        } else {\n            showEmailError('Email is invalid');\n        }\n    }\n\n    document.querySelectorAll('#btnSubmitCsvEmail').forEach(function(element) {\n        element.addEventListener('click', handleEmailCsvSubmit);\n    });\n\n    document.querySelectorAll('.email-csv').forEach(element => {\n        element.addEventListener('click', function(e) {\n            e.preventDefault();\n            curQueryEmailId = this.getAttribute('data-query-id');\n            emailModal.show();\n        });\n    });\n\n    const emailModalEl = document.getElementById('emailCsvModal');\n    emailModalEl.addEventListener('hidden.bs.modal', event => {\n        document.getElementById('email-success-msg').style.display = 'none';\n        document.getElementById('email-error-msg').style.display = 'none';\n    });\n}\n"
  },
  {
    "path": "explorer/src/js/schema.js",
    "content": "import List from \"list.js\";\n\nfunction searchFocus() {\n    let schemaFrame = window.parent.document.getElementById(\"schema_frame\");\n    if (!schemaFrame.classList.contains('no-autofocus')) {\n        document.querySelector(\".search\").focus();\n    }\n}\n\nexport function setupSchema() {\n\n    let options = {\n        valueNames: ['app-name'],\n        handlers: {'updated': [searchFocus]}\n    };\n\n    new List('schema-contents', options);\n\n    document.getElementById('collapse_all').addEventListener('click', function () {\n        document.querySelectorAll('.schema-table').forEach(function (element) {\n            element.style.display = 'none';\n        });\n    });\n\n    document.getElementById('expand_all').addEventListener('click', function () {\n        document.querySelectorAll('.schema-table').forEach(function (element) {\n            element.style.display = '';\n        });\n    });\n\n    document.querySelectorAll('.schema-header').forEach(function (header) {\n        header.addEventListener('click', function () {\n            let schemaTable = this.parentElement.querySelector('.schema-table');\n            if (schemaTable.style.display === 'none' || schemaTable.style.display === '') {\n                schemaTable.style.display = 'block';\n            } else {\n                schemaTable.style.display = 'none';\n            }\n        });\n    });\n\n    document.querySelectorAll('.copyable').forEach(function (fieldName) {\n        fieldName.addEventListener('click', () => {\n            navigator.clipboard.writeText(fieldName.innerHTML);\n            let oldText = fieldName.innerHTML;\n            fieldName.innerHTML = 'Copied';\n            setTimeout(()=>fieldName.innerHTML=oldText, 1000);\n        });\n    });\n}\n"
  },
  {
    "path": "explorer/src/js/schemaService.js",
    "content": "const schemaCache = {};\n\nconst fetchSchema = async () => {\n\n    const conn = getConnElement().value;\n\n    if (schemaCache[conn]) {\n        return schemaCache[conn];\n    }\n\n    try {\n        const response = await fetch(`${window.baseUrlPath}schema.json/${conn}`);\n        if (!response.ok) {\n            throw new Error(`HTTP error! Status: ${response.status}`);\n        }\n        const schema = await response.json();\n        schemaCache[conn] = schema;  // Cache the schema\n        return schema;\n    } catch (error) {\n        console.error('Error fetching table schema:', error);\n        throw error;  // Re-throw to handle it in the calling function\n    }\n};\n\nexport const SchemaSvc = {\n    get: fetchSchema\n};\n\nexport function getConnElement() {\n    return document.querySelector('#id_database_connection');\n}\n"
  },
  {
    "path": "explorer/src/js/table-to-csv.js",
    "content": "function tableToCSV(tableEl) {\n    let csv_data = [];\n\n    let rows = tableEl.getElementsByTagName('tr');\n    for (let i = 0; i < rows.length; i++) {\n        let cols = rows[i].querySelectorAll('td,th');\n\n        let csvrow = [];\n        for (let j = 0; j < cols.length; j++) {\n            csvrow.push(cols[j].innerText);\n        }\n        csv_data.push(csvrow.join(\",\"));\n    }\n    csv_data = csv_data.join('\\n');\n\n    return csv_data;\n}\n\nexport function csvFromTable(className) {\n\n    let csv_data = tableToCSV(className);\n\n    let CSVFile = new Blob([csv_data], { type: \"text/csv\" });\n\n    let temp_link = document.createElement('a');\n\n    temp_link.download = \"pivot.csv\";\n    temp_link.href = window.URL.createObjectURL(CSVFile);\n\n    temp_link.style.display = \"none\";\n    document.body.appendChild(temp_link);\n\n    temp_link.click();\n    document.body.removeChild(temp_link);\n}\n"
  },
  {
    "path": "explorer/src/js/tableDescription.js",
    "content": "import {getConnElement, SchemaSvc} from \"./schemaService\"\nimport Choices from \"choices.js\"\n\n\nfunction populateTableList() {\n\n    if (window.tableChoices) {\n        window.tableChoices.destroy();\n        document.getElementById('id_table_name').innerHTML = '';\n    }\n\n    SchemaSvc.get().then(schema => {\n\n        const tables = Object.keys(schema);\n        const selectElement = document.getElementById('id_table_name');\n        selectElement.toggleAttribute('data-trigger');\n\n        selectElement.appendChild(document.createElement('option'));\n\n        tables.forEach((t) => {\n            const option = document.createElement('option');\n            option.value = t;\n            option.textContent = t;\n            selectElement.appendChild(option);\n        });\n\n        window.tableChoices = new Choices('#id_table_name', {\n            searchEnabled: true,\n            shouldSort: true,\n            placeholder: true,\n            placeholderValue: 'Select table',\n            position: 'bottom'\n        });\n\n    });\n}\n\nfunction updateSchema() {\n    document.getElementById(\"schema_frame\").src = `${window.baseUrlPath}schema/${getConnElement().value}`;\n}\n\nexport function setupTableDescription() {\n    getConnElement().addEventListener('change', populateTableList);\n    populateTableList();\n\n    getConnElement().addEventListener('change', updateSchema);\n    updateSchema();\n}\n"
  },
  {
    "path": "explorer/src/js/uploads.js",
    "content": "import { getCsrfToken } from \"./csrf\";\n\nexport function setupUploads() {\n    var dropArea = document.getElementById('drop-area');\n    var fileElem = document.getElementById('fileElem');\n    var progressBar = document.getElementById('progress-bar');\n    var uploadStatus = document.getElementById('upload-status');\n\n    if (dropArea) {\n        dropArea.onclick = function() {\n            fileElem.click();\n        };\n\n        dropArea.addEventListener('dragover', function(e) {\n            e.preventDefault();\n            dropArea.classList.add('bg-info');\n        });\n\n        dropArea.addEventListener('dragleave', function(e) {\n            dropArea.classList.remove('bg-info');\n        });\n\n        dropArea.addEventListener('drop', function(e) {\n            e.preventDefault();\n            dropArea.classList.remove('bg-info');\n\n            let files = e.dataTransfer.files;\n            if (files.length) {\n                handleFiles(files[0]); // Assuming only one file is dropped\n            }\n        });\n\n        fileElem.onchange = function() {\n            if (this.files.length) {\n                handleFiles(this.files[0]);\n            }\n        };\n    }\n\n    function handleFiles(file) {\n        uploadFile(file);\n    }\n\n    function uploadFile(file) {\n        let formData = new FormData();\n        formData.append('file', file);\n\n        let appendElem = document.getElementById('append');\n        let appendValue = appendElem.value;\n        if (appendValue) {\n            formData.append('append', appendValue);\n        }\n\n        let xhr = new XMLHttpRequest();\n        xhr.open('POST', `${window.baseUrlPath}connections/upload/`, true);\n        xhr.setRequestHeader('X-CSRFToken', getCsrfToken());\n\n        xhr.upload.onprogress = function(event) {\n            if (event.lengthComputable) {\n                let percentComplete = (event.loaded / event.total) * 100;\n                progressBar.style.width = percentComplete + '%';\n                progressBar.setAttribute('aria-valuenow', percentComplete);\n                progressBar.innerHTML = percentComplete.toFixed(0) + '%';\n                if (percentComplete > 99) {\n                    uploadStatus.innerHTML = \"Upload complete. Parsing and saving to S3...\";\n                }\n            }\n        };\n\n        xhr.onload = function() {\n            if (xhr.status === 200) {\n                let highlightValue = appendValue ? appendElem.options[appendElem.selectedIndex].text : file.name.substring(0, file.name.lastIndexOf('.')) || file.name;\n                 window.location.href = `../?highlight=${encodeURIComponent(highlightValue)}`;\n            } else {\n                console.error('Error:', xhr.response);\n                uploadStatus.innerHTML = xhr.response;\n            }\n        };\n\n        xhr.onerror = function() {\n            console.error('Error:', xhr.statusText);\n            uploadStatus.innerHTML = xhr.response;\n        };\n\n        xhr.send(formData);\n    }\n\n    let testConnBtn = document.getElementById(\"test-connection-btn\");\n    if (testConnBtn) {\n        testConnBtn.addEventListener(\"click\", function() {\n            let form = document.getElementById(\"db-connection-form\");\n            let formData = new FormData(form);\n\n            fetch(`${window.baseUrlPath}connections/validate/`, {\n                method: \"POST\",\n                body: formData,\n                headers: {\n                    \"X-CSRFToken\": getCsrfToken()\n                }\n            })\n            .then(response => response.json())\n            .then(data => {\n                if (data.success) {\n                    alert(\"Connection successful!\");\n                } else {\n                    alert(\"Connection failed: \" + data.error);\n                }\n            })\n            .catch(error => console.error(\"Error:\", error));\n        });\n    }\n}\n"
  },
  {
    "path": "explorer/src/scss/assistant.scss",
    "content": "#assistant_description_label {\n    white-space: pre-wrap;\n}\n\n#assistant_response pre {\n    position: relative;\n    background: antiquewhite;\n}\n\n#assistant_response .copy-btn {\n    position: absolute;\n    top: 5px;\n    right: 5px;\n    cursor: pointer;\n}\n\n#assistant_input_parent {\n    max-height: 120px;\n    overflow: hidden;\n}\n\n.assistant-icons {\n    width: 1rem;\n    position: absolute;\n    right: .75rem;\n}\n\n#table-list {\n    .choices__inner {\n        min-height: 7rem !important;\n    }\n}\n"
  },
  {
    "path": "explorer/src/scss/choices.scss",
    "content": "@import \"variables\";\n\n.choices {\n  position: relative;\n  overflow: hidden;\n  margin-bottom: 24px;\n}\n.choices:focus {\n  outline: none;\n}\n.choices:last-child {\n  margin-bottom: 0;\n}\n.choices.is-open {\n  overflow: visible;\n}\n.choices.is-disabled .choices__inner,\n.choices.is-disabled .choices__input {\n  background-color: #eaeaea;\n  cursor: not-allowed;\n  -webkit-user-select: none;\n          user-select: none;\n}\n.choices.is-disabled .choices__item {\n  cursor: not-allowed;\n}\n.choices [hidden] {\n  display: none !important;\n}\n\n.choices[data-type*=select-one] {\n  cursor: pointer;\n}\n.choices[data-type*=select-one] .choices__inner {\n  padding-bottom: 7.5px;\n}\n.choices[data-type*=select-one] .choices__input {\n  display: block;\n  width: 100%;\n  padding: 10px;\n  border-bottom: 1px solid #ddd;\n  background-color: #fff;\n  margin: 0;\n}\n.choices[data-type*=select-one] .choices__button {\n  background-image: url(\"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==\");\n  padding: 0;\n  background-size: 8px;\n  position: absolute;\n  top: 50%;\n  right: 0;\n  margin-top: -10px;\n  margin-right: 25px;\n  height: 20px;\n  width: 20px;\n  border-radius: 10em;\n  opacity: 0.25;\n}\n.choices[data-type*=select-one] .choices__button:hover, .choices[data-type*=select-one] .choices__button:focus {\n  opacity: 1;\n}\n.choices[data-type*=select-one] .choices__button:focus {\n  box-shadow: 0 0 0 2px #005F75;\n}\n.choices[data-type*=select-one] .choices__item[data-placeholder] .choices__button {\n  display: none;\n}\n.choices[data-type*=select-one]::after {\n  content: \"\";\n  height: 0;\n  width: 0;\n  border-style: solid;\n  border-color: #333 transparent transparent transparent;\n  border-width: 5px;\n  position: absolute;\n  right: 11.5px;\n  top: 50%;\n  margin-top: -2.5px;\n  pointer-events: none;\n}\n.choices[data-type*=select-one].is-open::after {\n  border-color: transparent transparent #333;\n  margin-top: -7.5px;\n}\n.choices[data-type*=select-one][dir=rtl]::after {\n  left: 11.5px;\n  right: auto;\n}\n.choices[data-type*=select-one][dir=rtl] .choices__button {\n  right: auto;\n  left: 0;\n  margin-left: 25px;\n  margin-right: 0;\n}\n\n.choices[data-type*=select-multiple] .choices__inner,\n.choices[data-type*=text] .choices__inner {\n  cursor: text;\n}\n.choices[data-type*=select-multiple] .choices__button,\n.choices[data-type*=text] .choices__button {\n  position: relative;\n  display: inline-block;\n  margin-top: 0;\n  margin-right: -4px;\n  margin-bottom: 0;\n  margin-left: 8px;\n  padding-left: 16px;\n  border-left: 1px solid #003642;\n  background-image: url(\"data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==\");\n  background-size: 8px;\n  width: 8px;\n  line-height: 1;\n  opacity: 0.75;\n  border-radius: 0;\n}\n.choices[data-type*=select-multiple] .choices__button:hover, .choices[data-type*=select-multiple] .choices__button:focus,\n.choices[data-type*=text] .choices__button:hover,\n.choices[data-type*=text] .choices__button:focus {\n  opacity: 1;\n}\n\n.choices__inner {\n  display: inline-block;\n  vertical-align: top;\n  width: 100%;\n  background-color: #f9f9f9;\n  padding: 7.5px 7.5px 3.75px;\n  border: 1px solid #ddd;\n  border-radius: 6px;\n  overflow: hidden;\n}\n.is-focused .choices__inner, .is-open .choices__inner {\n  border-color: #b7b7b7;\n}\n.is-open .choices__inner {\n  border-radius: 2.5px 2.5px 0 0;\n}\n.is-flipped.is-open .choices__inner {\n  border-radius: 0 0 2.5px 2.5px;\n}\n\n.choices__list {\n  margin: 0;\n  padding-left: 0;\n  list-style: none;\n}\n.choices__list--single {\n  display: inline-block;\n  padding: 4px 16px 4px 4px;\n  width: 100%;\n}\n[dir=rtl] .choices__list--single {\n  padding-right: 4px;\n  padding-left: 16px;\n}\n.choices__list--single .choices__item {\n  width: 100%;\n}\n\n.choices__list--multiple {\n  display: inline;\n}\n.choices__list--multiple .choices__item {\n  display: inline-block;\n  vertical-align: middle;\n  border-radius: 20px;\n  padding: 4px 10px;\n  font-weight: 500;\n  margin-right: 3.75px;\n  margin-bottom: 3.75px;\n  background-color: $dark-lightened;\n  border: 1px solid $dark;\n  color: #fff;\n  word-break: break-all;\n  box-sizing: border-box;\n}\n.choices__list--multiple .choices__item[data-deletable] {\n  padding-right: 5px;\n}\n[dir=rtl] .choices__list--multiple .choices__item {\n  margin-right: 0;\n  margin-left: 3.75px;\n}\n.is-disabled .choices__list--multiple .choices__item {\n  background-color: #aaaaaa;\n  border: 1px solid #919191;\n}\n\n.choices__list--dropdown, .choices__list[aria-expanded] {\n  display: none;\n  z-index: 1;\n  position: absolute;\n  width: 100%;\n  background-color: #fff;\n  border: 1px solid #ddd;\n  top: 100%;\n  margin-top: -1px;\n  border-bottom-left-radius: 2.5px;\n  border-bottom-right-radius: 2.5px;\n  overflow: hidden;\n  word-break: break-all;\n}\n.is-active.choices__list--dropdown, .is-active.choices__list[aria-expanded] {\n  display: block;\n}\n.is-open .choices__list--dropdown, .is-open .choices__list[aria-expanded] {\n  border-color: #b7b7b7;\n}\n.is-flipped .choices__list--dropdown, .is-flipped .choices__list[aria-expanded] {\n  top: auto;\n  bottom: 100%;\n  margin-top: 0;\n  margin-bottom: -1px;\n  border-radius: 0.25rem 0.25rem 0 0;\n}\n.choices__list--dropdown .choices__list, .choices__list[aria-expanded] .choices__list {\n  position: relative;\n  max-height: 300px;\n  overflow: auto;\n  -webkit-overflow-scrolling: touch;\n  will-change: scroll-position;\n}\n.choices__list--dropdown .choices__item, .choices__list[aria-expanded] .choices__item {\n  position: relative;\n  padding: 10px;\n}\n[dir=rtl] .choices__list--dropdown .choices__item, [dir=rtl] .choices__list[aria-expanded] .choices__item {\n  text-align: right;\n}\n@media (min-width: 640px) {\n  .choices__list--dropdown .choices__item--selectable[data-select-text], .choices__list[aria-expanded] .choices__item--selectable[data-select-text] {\n    padding-right: 100px;\n  }\n  .choices__list--dropdown .choices__item--selectable[data-select-text]::after, .choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after {\n    content: attr(data-select-text);\n    opacity: 0;\n    position: absolute;\n    right: 10px;\n    top: 50%;\n    transform: translateY(-50%);\n  }\n  [dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text], [dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text] {\n    text-align: right;\n    padding-left: 100px;\n    padding-right: 10px;\n  }\n  [dir=rtl] .choices__list--dropdown .choices__item--selectable[data-select-text]::after, [dir=rtl] .choices__list[aria-expanded] .choices__item--selectable[data-select-text]::after {\n    right: auto;\n    left: 10px;\n  }\n}\n.choices__list--dropdown .choices__item--selectable.is-highlighted, .choices__list[aria-expanded] .choices__item--selectable.is-highlighted {\n  background-color: #f2f2f2;\n}\n.choices__list--dropdown .choices__item--selectable.is-highlighted::after, .choices__list[aria-expanded] .choices__item--selectable.is-highlighted::after {\n  opacity: 0.5;\n}\n\n.choices__item {\n  cursor: default;\n}\n\n.choices__item--selectable {\n  cursor: pointer;\n}\n\n.choices__item--disabled {\n  cursor: not-allowed;\n  -webkit-user-select: none;\n          user-select: none;\n  opacity: 0.5;\n}\n\n.choices__heading {\n  font-weight: 600;\n  padding: 10px;\n  border-bottom: 1px solid #f7f7f7;\n  color: gray;\n}\n\n.choices__button {\n  text-indent: -9999px;\n  appearance: none;\n  border: 0;\n  background-color: transparent;\n  background-repeat: no-repeat;\n  background-position: center;\n  cursor: pointer;\n}\n.choices__button:focus {\n  outline: none;\n}\n\n.choices__input {\n  display: inline-block;\n  vertical-align: baseline;\n  background-color: #f9f9f9;\n  margin-bottom: 5px;\n  border: 0;\n  border-radius: 0;\n  max-width: 100%;\n  padding: 4px 0 4px 2px;\n}\n.choices__input:focus {\n  outline: 0;\n}\n.choices__input::-webkit-search-decoration, .choices__input::-webkit-search-cancel-button, .choices__input::-webkit-search-results-button, .choices__input::-webkit-search-results-decoration {\n  display: none;\n}\n.choices__input::-ms-clear, .choices__input::-ms-reveal {\n  display: none;\n  width: 0;\n  height: 0;\n}\n[dir=rtl] .choices__input {\n  padding-right: 2px;\n  padding-left: 0;\n}\n\n.choices__placeholder {\n  opacity: 0.5;\n}\n"
  },
  {
    "path": "explorer/src/scss/explorer.scss",
    "content": "a {\n    text-decoration: none;\n}\n\n.cm-editor {\n    outline: none !important;\n}\n\n.cm-scroller {\n    overflow: auto;\n    min-height: 400px;\n    max-height: 400px;\n}\n\n.cm-content, .cm-gutter {\n    min-height: 400px !important;\n}\n\n.link-primary {\n    cursor: pointer;\n}\n\n.rows-input {\n    text-align: center;\n    width: 40px;\n}\n\n.overflow-wrapper {\n    overflow: auto; height: 500px;\n}\n\n.data-headers {\n    background: white;\n    position: sticky;\n    top: 0;\n}\n\n.log-sql {\n    word-wrap: break-word;\n    white-space: normal !important;\n}\n\n.list {\n    -webkit-padding-start: 0;\n    list-style: none;\n}\n\n.schema-wrapper {\n    overflow: hidden;\n}\n\n.toggle {\n    cursor: pointer;\n}\n\ntd.name.indented {\n    padding-left: 30px;\n}\n\n.stats-expand {\n    cursor: pointer;\n}\n\n.stats-th .counter{\n    border: 0 solid white;\n}\n\n.table>thead>tr>th.preview-header {\n    white-space: nowrap;\n    border: 0 solid white;\n}\n\ndiv.sort {\n    cursor: pointer;\n}\n\n#schema_frame {\n    width: 100%;\n    border: 0;\n}\n\n.schema-header {\n    cursor: pointer;\n}\n\n.counter {\n    background-color: #ecf0f1;\n    font-family: monospace;\n}\n\n.sql {\n    width: 33%;\n}\n\n.query_favorite_toggle {\n    cursor: pointer;\n}\n\n.query_favourite_detail {\n    float: right;\n}\n\n.btn-save-only {\n    border-radius: 0 !important;\n}\n\n.vite-not-running-canary {\n    display: none;\n}\n\n.dropdown-toggle::after {\n    margin-left: 0 !important;\n}\n\n#sql_editor_container {\n    position: relative;\n}\n\n#schema_tooltip {\n    position: absolute;\n    bottom: 1rem;\n    z-index: 1000;\n    background-color: white;\n    border: 1px solid black;\n    padding: 5px;\n    border-radius: 4px;\n    box-shadow: 0 2px 5px rgba(0,0,0,0.2);\n}\n\n.logo-image {\n    height: 2rem;\n}\n\n.stats-wrapper {\n    td {\n        padding-top: 0;\n        padding-bottom: 0;\n        border: none;\n    }\n}\n\n.copyable {\n    cursor: copy;\n}\n"
  },
  {
    "path": "explorer/src/scss/pivot.css",
    "content": ".pvtUi { color: #333; }\n\n\ntable.pvtTable {\n    font-size: 8pt;\n    text-align: left;\n    border-collapse: collapse;\n}\ntable.pvtTable thead tr th, table.pvtTable tbody tr th {\n    background-color: #e6EEEE;\n    border: 1px solid #CDCDCD;\n    font-size: 8pt;\n    padding: 5px;\n}\n\ntable.pvtTable .pvtColLabel {text-align: center;}\ntable.pvtTable .pvtTotalLabel {text-align: right;}\n\ntable.pvtTable tbody tr td {\n    color: #3D3D3D;\n    padding: 5px;\n    background-color: #FFF;\n    border: 1px solid #CDCDCD;\n    vertical-align: top;\n    text-align: right;\n}\n\n.pvtTotal, .pvtGrandTotal { font-weight: bold; }\n\n.pvtVals { text-align: center; white-space: nowrap;}\n.pvtRowOrder, .pvtColOrder {\n    cursor:pointer;\n    width: 15px;\n    margin-left: 5px;\n    display: inline-block; }\n.pvtAggregator { margin-bottom: 5px ;}\n\n.pvtAxisContainer, .pvtVals {\n    border: 1px solid gray;\n    background: #EEE;\n    padding: 5px;\n    min-width: 20px;\n    min-height: 20px;\n\n    user-select: none;\n    -webkit-user-select: none;\n    -moz-user-select: none;\n    -khtml-user-select: none;\n    -ms-user-select: none;\n}\n.pvtAxisContainer li {\n    padding: 8px 6px;\n    list-style-type: none;\n    cursor:move;\n}\n.pvtAxisContainer li.pvtPlaceholder {\n    -webkit-border-radius: 5px;\n    padding: 3px 15px;\n    -moz-border-radius: 5px;\n    border-radius: 5px;\n    border: 1px dashed #aaa;\n}\n\n.pvtAxisContainer li span.pvtAttr {\n    -webkit-text-size-adjust: 100%;\n    background: #F3F3F3;\n    border: 1px solid #DEDEDE;\n    padding: 2px 5px;\n    white-space:nowrap;\n    -webkit-border-radius: 5px;\n    -moz-border-radius: 5px;\n    border-radius: 5px;\n}\n\n.pvtTriangle {\n    cursor:pointer;\n    color: grey;\n}\n\n.pvtHorizList li { display: inline; }\n.pvtVertList { vertical-align: top; }\n\n.pvtFilteredAttribute { font-style: italic }\n\n.pvtFilterBox{\n    z-index: 100;\n    width: 300px;\n    border: 1px solid gray;\n    background-color: #fff;\n    position: absolute;\n    text-align: center;\n}\n\n.pvtFilterBox h4{ margin: 15px; }\n.pvtFilterBox p { margin: 10px auto; }\n.pvtFilterBox label { font-weight: normal; }\n.pvtFilterBox input[type='checkbox'] { margin-right: 10px; margin-left: 10px; }\n.pvtFilterBox input[type='text'] { width: 230px; }\n.pvtFilterBox .count { color: gray; font-weight: normal; margin-left: 3px;}\n\n.pvtCheckContainer{\n    text-align: left;\n    font-size: 14px;\n    white-space: nowrap;\n    overflow-y: scroll;\n    width: 100%;\n    max-height: 250px;\n    border-top: 1px solid lightgrey;\n    border-bottom: 1px solid lightgrey;\n}\n\n.pvtCheckContainer p{ margin: 5px; }\n\n.pvtRendererArea { padding: 5px;}\n"
  },
  {
    "path": "explorer/src/scss/styles.scss",
    "content": "@import \"variables\";\n\n@import \"~bootstrap/scss/bootstrap\";\n\n$bootstrap-icons-font-dir: \"../../../node_modules/bootstrap-icons/font/fonts\";\n@import \"~bootstrap-icons/font/bootstrap-icons.css\";\n\n@import \"explorer\";\n@import \"assistant\";\n@import \"choices\";\n\n@import \"pivot.css\";\n"
  },
  {
    "path": "explorer/src/scss/variables.scss",
    "content": "@import \"../../../node_modules/bootstrap/scss/functions\";\n\n$orange: rgb(255, 80, 1);\n$blue: rgb(3, 68, 220);\n$green: rgb(127, 176, 105);\n$primary: $blue;\n$dark: rgb(1, 32, 63);\n$dark-lightened: rgba(1, 32, 63, 0.75);\n$secondary: $orange;\n$warning: $orange;\n$danger: $orange;\n$success: $green;\n$info: rgb(106, 141, 146);\n$code-color: $info;\n$font-size-base: .8rem;\n\n.btn-secondary, .btn-info {\n    --bs-btn-color: white !important;\n}\n\n.table-active {\n    --bs-table-bg-state: rgb(127, 176, 105) !important;\n}\n\n$card-border-radius: 0;\n$card-spacer-x: 0;\n.card-header {\n    padding-left: 1rem !important;\n    padding-right: 1rem !important;\n}\n\n.card {\n    border-top: 0 !important;\n}\n\n\n"
  },
  {
    "path": "explorer/tasks.py",
    "content": "import io\nimport random\nimport string\nimport os\nfrom datetime import date, datetime, timedelta\n\nfrom django.core.cache import cache\nfrom django.core.mail import send_mail\nfrom django.utils import timezone\n\nfrom explorer import app_settings\nfrom explorer.exporters import get_exporter_class\nfrom explorer.models import Query, QueryLog\nfrom explorer.ee.db_connections.models import DatabaseConnection\n\n\nif app_settings.ENABLE_TASKS:\n    from celery import shared_task\n    from celery.utils.log import get_task_logger\n\n    from explorer.utils import s3_csv_upload\n\n    logger = get_task_logger(__name__)\nelse:\n    import logging\n\n    from explorer.utils import noop_decorator as shared_task\n\n    logger = logging.getLogger(__name__)\n\n\n@shared_task\ndef execute_query(query_id, email_address):\n    q = Query.objects.get(pk=query_id)\n    send_mail(\n        \"[SQL Explorer] Your query is running...\",\n        f\"{q.title} is running and should be in your inbox soon!\",\n        app_settings.FROM_EMAIL,\n        [email_address],\n    )\n\n    exporter = get_exporter_class(\"csv\")(q)\n    random_part = \"\".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))\n    try:\n        url = s3_csv_upload(f\"{random_part}.csv\", convert_csv_to_bytesio(exporter))\n        subj = f'[SQL Explorer] Report \"{q.title}\" is ready'\n        msg = f\"Download results:\\n\\r{url}\"\n    except Exception as e:\n        subj = f\"[SQL Explorer] Error running report {q.title}\"\n        msg = f\"Error: {e}\\nPlease contact an administrator\"\n        logger.exception(f\"{subj}: {e}\")\n    send_mail(subj, msg, app_settings.FROM_EMAIL, [email_address])\n\n\n# I am sure there is a much more efficient way to do this but boto3 expects a binary file basically\ndef convert_csv_to_bytesio(csv_exporter):\n    csv_file_io = csv_exporter.get_file_output()\n    csv_file_io.seek(0)\n    csv_data: str = csv_file_io.read()\n    bio = io.BytesIO(bytes(csv_data, \"utf-8\"))\n    return bio\n\n\n@shared_task\ndef snapshot_query(query_id):\n    try:\n        logger.info(f\"Starting snapshot for query {query_id}...\")\n        q = Query.objects.get(pk=query_id)\n        exporter = get_exporter_class(\"csv\")(q)\n        k = \"query-{}/snap-{}.csv\".format(q.id, date.today().strftime(\"%Y%m%d-%H:%M:%S\"))\n        logger.info(f\"Uploading snapshot for query {query_id} as {k}...\")\n        url = s3_csv_upload(k, convert_csv_to_bytesio(exporter))\n        logger.info(f\"Done uploading snapshot for query {query_id}. URL: {url}\")\n    except Exception as e:\n        logger.warning(f\"Failed to snapshot query {query_id} ({e}). Retrying...\")\n        snapshot_query.retry()\n\n\n@shared_task\ndef snapshot_queries():\n    logger.info(\"Starting query snapshots...\")\n    qs = Query.objects.filter(snapshot=True).values_list(\"id\", flat=True)\n    logger.info(f\"Found {len(qs)} queries to snapshot. Creating snapshot tasks...\")\n    for qid in qs:\n        snapshot_query.delay(qid)\n    logger.info(\"Done creating tasks.\")\n\n\n@shared_task\ndef truncate_querylogs(days):\n    t = timezone.make_aware(datetime.now() - timedelta(days=days), timezone.get_default_timezone())\n    qs = QueryLog.objects.filter(run_at__lt=t)\n    logger.info(f\"Deleting {qs.count()} QueryLog objects older than {days} days.\")\n    qs.delete()\n    logger.info(\"Done deleting QueryLog objects.\")\n\n\n@shared_task\ndef build_schema_cache_async(db_connection_id):\n    from .schema import (\n        build_schema_info,\n        connection_schema_cache_key,\n        connection_schema_json_cache_key,\n        transform_to_json_schema,\n    )\n    db_connection = DatabaseConnection.objects.get(id=db_connection_id)\n    ret = build_schema_info(db_connection)\n    cache.set(connection_schema_cache_key(db_connection.id), ret)\n    cache.set(connection_schema_json_cache_key(db_connection.id), transform_to_json_schema(ret))\n    return ret\n\n\n@shared_task\ndef remove_unused_sqlite_dbs():\n    days = app_settings.EXPLORER_PRUNE_LOCAL_UPLOAD_COPY_DAYS_INACTIVITY\n    t = timezone.make_aware(datetime.now() - timedelta(days=days), timezone.get_default_timezone())\n    for db in DatabaseConnection.objects.uploads():\n        if os.path.exists(db.local_name):\n            recent_run = QueryLog.objects.filter(database_connection=db).first()\n            if recent_run and t > recent_run.run_at:\n                os.remove(db.local_name)\n\n\n@shared_task\ndef build_async_schemas():\n    from explorer.schema import schema_info\n    for c in DatabaseConnection.objects.non_uploads().all():\n        schema_info(c.alias)\n"
  },
  {
    "path": "explorer/telemetry.py",
    "content": "# Anonymous usage stats\n# Opt-out by setting EXPLORER_ENABLE_ANONYMOUS_STATS = False in settings\n\nimport logging\nimport time\nimport requests\nimport json\nimport threading\nfrom enum import Enum, auto\nfrom django.core.cache import cache\nfrom django.db import connection\nfrom django.db.models import Count\nfrom django.db.migrations.recorder import MigrationRecorder\nfrom django.conf import settings\n\nlogger = logging.getLogger(__name__)\n\n\ndef instance_identifier():\n    from explorer.models import ExplorerValue\n    key = \"explorer_instance_uuid\"\n    r = cache.get(key)\n    if not r:\n        r = ExplorerValue.objects.get_uuid()\n        cache.set(key, r, 60 * 60 * 24)\n    return r\n\n\nclass SelfNamedEnum(Enum):\n\n    @staticmethod\n    def _generate_next_value_(name, start, count, last_values):\n        return name\n\n\nclass StatNames(SelfNamedEnum):\n\n    QUERY_RUN = auto()\n    QUERY_STREAM = auto()\n    STARTUP_STATS = auto()\n    ASSISTANT_RUN = auto()\n\n\nclass Stat:\n\n    STAT_COLLECTION_INTERVAL = 60 * 60 * 12  # Twelve hours\n    STARTUP_STAT_COLLECTION_INTERVAL = 60 * 60 * 24 * 7  # A week\n\n    def __init__(self, name: StatNames, value):\n        self.instanceId = instance_identifier()\n        self.time = time.time()\n        self.value = value\n        self.name = name.value\n\n    @property\n    def is_summary(self):\n        return self.name == StatNames.STARTUP_STATS.value\n\n    def should_send_summary_stats(self):\n        from explorer.models import ExplorerValue\n        last_send = ExplorerValue.objects.get_startup_last_send()\n        if not last_send:\n            return True\n        else:\n            return self.time - last_send >= self.STARTUP_STAT_COLLECTION_INTERVAL\n\n    def send_summary_stats(self):\n        from explorer.models import ExplorerValue\n        payload = _gather_summary_stats()\n        Stat(StatNames.STARTUP_STATS, payload).track()\n        ExplorerValue.objects.set_startup_last_send(self.time)\n\n    def track(self):\n        from explorer import app_settings\n\n        if not app_settings.EXPLORER_ENABLE_ANONYMOUS_STATS:\n            return\n\n        cache_key = \"last_stat_sent_time\"\n        last_sent_time = cache.get(cache_key, 0)\n        # Summary stats are tracked with a different time interval\n        if self.is_summary or self.time - last_sent_time >= self.STAT_COLLECTION_INTERVAL:\n            data = json.dumps(self.__dict__)\n            thread = threading.Thread(target=_send, args=(data,))\n            thread.start()\n            cache.set(cache_key, self.time)\n\n        # Every time we send any tracking, see if we have recently sent overall summary stats\n        # Of course, sending the summary stats calls .track(), so we need to NOT call track()\n        # again if we are in fact already in the process of sending summary stats. Otherwise,\n        # we will end up in infinite recursion of track() calls.\n        if not self.is_summary and self.should_send_summary_stats():\n            self.send_summary_stats()\n\n\ndef _send(data):\n    from explorer import app_settings\n    try:\n        requests.post(app_settings.EXPLORER_COLLECT_ENDPOINT_URL,\n                      data=data,\n                      headers={\"Content-Type\": \"application/json\"})\n    except Exception as e:\n        logger.warning(f\"Failed to send stats: {e}\")\n\n\ndef _get_install_quarter():\n    first_migration = MigrationRecorder.Migration.objects. \\\n        filter(app=\"explorer\").order_by(\"applied\").first()\n\n    if first_migration is not None:\n        quarter = (first_migration.applied.month - 1) // 3 + 1  # Calculate the quarter, for anonymization\n        year = first_migration.applied.year\n        quarter_str = f\"Q{quarter}-{year}\"\n    else:\n        quarter_str = None\n    return quarter_str\n\n\ndef _gather_summary_stats():\n\n    from explorer import app_settings\n    from explorer.models import Query, QueryLog\n    from explorer.ee.db_connections.models import DatabaseConnection\n    import explorer\n\n    try:\n        ql_stats = QueryLog.objects.aggregate(\n            total_count=Count(\"*\"),\n            unique_run_by_user_count=Count(\"run_by_user_id\", distinct=True)\n        )\n\n        q_stats = Query.objects.aggregate(\n            total_count=Count(\"*\"),\n            unique_connection_count=Count(\"database_connection_id\", distinct=True)\n        )\n\n        # Round the counts to provide additional anonymity\n        return {\n            \"total_log_count\": round(ql_stats[\"total_count\"] * 0.01) * 100,  # Nearest 100\n            \"unique_run_by_user_count\": round(ql_stats[\"unique_run_by_user_count\"] * 0.2) * 5,  # Nearest 5\n            \"total_query_count\": round(q_stats[\"total_count\"] * 0.1) * 10,  # Nearest 10\n            \"unique_connection_count\": round(q_stats[\"unique_connection_count\"] * 0.2) * 5,\n            \"default_database\": connection.vendor,\n            \"explorer_install_quarter\": _get_install_quarter(),\n            \"debug\": settings.DEBUG,\n            \"tasks_enabled\": app_settings.ENABLE_TASKS,\n            \"unsafe_rendering\": app_settings.UNSAFE_RENDERING,\n            \"transform_count\": len(app_settings.EXPLORER_TRANSFORMS),\n            \"assistant_enabled\": app_settings.has_assistant(),\n            \"user_dbs\": DatabaseConnection.objects.count(),\n            \"version\": explorer.get_version(),\n            \"charts_enabled\": app_settings.EXPLORER_CHARTS_ENABLED\n        }\n    except Exception as e:\n        return {\"error\": f\"error gathering stats: {e}\"}\n"
  },
  {
    "path": "explorer/templates/assistant/table_description_confirm_delete.html",
    "content": "{% extends \"explorer/base.html\" %}\n\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n  <h1>Confirm Delete</h1>\n  <p>Are you sure you want to delete the table description for \"{{ object.table_name }}\" in {{ object.connection.alias }}?</p>\n  <form method=\"post\">\n    {% csrf_token %}\n    <button type=\"submit\" class=\"btn btn-secondary\">Confirm Delete</button>\n    <a href=\"{% url 'table_description_list' %}\" class=\"btn btn-info\">Cancel</a>\n  </form>\n</div>\n{% endblock %}\n\n"
  },
  {
    "path": "explorer/templates/assistant/table_description_form.html",
    "content": "{% extends \"explorer/base.html\" %}\n\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n    <div class=\"row\">\n        <div class=\"col-9\">\n          <h1>{% if form.instance.pk %}Edit{% else %}Create{% endif %} Table Description</h1>\n          {% if form.errors %}\n            {% for field in form %}\n                {% for error in field.errors %}\n                    <div class=\"alert alert-danger\">\n                        <strong>{{ error|escape }}</strong>\n                    </div>\n                {% endfor %}\n            {% endfor %}\n            {% for error in form.non_field_errors %}\n                <div class=\"alert alert-danger\">\n                    <strong>{{ error|escape }}</strong>\n                </div>\n            {% endfor %}\n          {% endif %}\n          <form method=\"post\" id=\"table-description-form\">\n            {% csrf_token %}\n            <div>\n              <div class=\"mb-3 form-floating\">\n                {{ form.database_connection }}\n                <label for=\"{{ form.database_connection.id_for_label }}\" class=\"form-label\">Connection</label>\n              </div>\n              <div class=\"mb-3 form-floating z-3\">\n                <small>Table Name</small>\n                {{ form.table_name }}\n              </div>\n              <div class=\"mb-3 form-floating\">\n                {{ form.description }}\n                <label for=\"{{ form.description.id_for_label }}\" class=\"form-label\">Description</label>\n              </div>\n            </div>\n            <button type=\"submit\" class=\"btn btn-primary\">{% if form.instance.pk %}Update{% else %}Save{% endif %} Annotation</button>\n            <a href=\"{% url 'table_description_list' %}\" class=\"btn btn-secondary\">Cancel</a>\n          </form>\n        </div>\n        <div class=\"col-md-3\">\n            <div id=\"schema\">\n                <iframe class=\"no-autofocus\" src=\"\" height=\"828px\" frameBorder=\"0\" id=\"schema_frame\"></iframe>\n            </div>\n        </div>\n    </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/assistant/table_description_list.html",
    "content": "{% extends \"explorer/base.html\" %}\n\n{% block sql_explorer_content %}\n<div class=\"container\">\n    <h3>Table Annotations</h3>\n    <p>Write some notes about your tables to help the AI Assistant do its job. Relevant annotations will be automatically injected into AI assistant requests.</p>\n    <p>Good annotations may describe the purposes of columns that are not obvious from their name alone, common joins to other tables, or the semantic meaning of enum values.</p>\n    <div class=\"mt-3\">\n        <a href=\"{% url 'table_description_create' %}\" class=\"btn btn-primary mb-3\">Create Annotation</a>\n        <table class=\"table table-striped table-hover\">\n            <thead>\n                <tr>\n                    <th>Connection</th>\n                    <th>Table Name</th>\n                    <th>Description</th>\n                    <th>Actions</th>\n                </tr>\n            </thead>\n            <tbody>\n                {% for table_description in table_descriptions %}\n                    <tr>\n                        <td>{{ table_description.database_connection }}</td>\n                        <td>{{ table_description.table_name }}</td>\n                        <td>{{ table_description.description|truncatewords:20 }}</td>\n                        <td>\n                            <a href=\"{% url 'table_description_update' table_description.pk %}\" class=\"px-2\"><i class=\"bi-pencil-square\"></i></a>\n                            <a href=\"{% url 'table_description_delete' table_description.pk %}\"><i class=\"bi-trash\"></i></a>\n                        </td>\n                    </tr>\n                {% empty %}\n                    <tr>\n                        <td colspan=\"4\" class=\"text-center\">No table descriptions available.</td>\n                    </tr>\n                {% endfor %}\n            </tbody>\n        </table>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/connections/connection_upload.html",
    "content": "{% extends 'explorer/base.html' %}\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n    <div class=\"pt-3\">\n        <h4>Create a connection from an uploaded file</h4>\n        <p>Supports .csv, .json, .db, and .sqlite files. JSON files with one JSON document per line are also supported. CSV/JSON data will be parsed and converted to SQLite. SQLite databases must <i>not</i> be password protected.</p>\n        <p>Appending to an existing connection will add a new table to the SQLite database, named after the uploaded file. If a table with the filename already exists, it will be replaced with the uploaded data.</p>\n        <form id=\"upload-form\">\n            <div class=\"form-floating mb-3\">\n                <select id=\"append\" name=\"append\" class=\"form-select\">\n                    <option value=\"\" selected></option>\n                    {% for connection in valid_connections %}\n                        <option value=\"{{ connection.id }}\">{{ connection.alias }}</option>\n                    {% endfor %}\n                </select>\n                <label for=\"append\">Optional: Append to existing connection:</label>\n            </div>\n            <div id=\"drop-area\" class=\"p-3 mb-4 bg-light border rounded\" style=\"cursor: pointer\">\n                <p class=\"lead mb-0\"><span class=\"fw-bold\">Upload: </span>Drag and drop, or click to upload .csv, .json, .db, .sqlite.</p>\n                <input type=\"file\" id=\"fileElem\" style=\"display:none\" accept=\".db,.csv,.sqlite,.json\">\n                <div class=\"progress mt-3\" style=\"height: 20px;\">\n                    <div id=\"progress-bar\" class=\"progress-bar\" role=\"progressbar\" style=\"width: 0;\" aria-valuenow=\"0\" aria-valuemin=\"0\" aria-valuemax=\"100\">0%</div>\n                </div>\n                <p id=\"upload-status\" class=\"mt-2\"></p>\n            </div>\n        </form>\n    </div>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/connections/connections.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load explorer_tags i18n %}\n\n{% block sql_explorer_content %}\n<div class=\"container\">\n    <h3>Connections</h3>\n    <div class=\"mt-3\">\n        <div class=\"d-flex align-items-center gap-2 mb-3\">\n            <a href=\"{% url 'explorer_connection_create' %}\" class=\"btn btn-primary\">Add New Connection</a>\n            <a href=\"{% url 'explorer_upload_create' %}\" class=\"btn btn-primary\">Upload File</a>\n            {% if object_list|length == 0 %}\n                <span class=\"text-secondary d-flex align-items-center fw-bold\">\n                    <i class=\"bi-arrow-left me-1\"></i>Connect to an existing database, or upload a csv, json, or sqlite file.\n                </span>\n            {% endif %}\n        </div>\n        <table class=\"table table-striped\" id=\"connections_table\">\n            <thead>\n                <tr>\n                    <th>Alias</th>\n                    <th>Name</th>\n                    <th>Engine</th>\n                    <th>Actions</th>\n                </tr>\n            </thead>\n            <tbody>\n                {% for connection in object_list %}\n                <tr>\n                    <td>\n                        {% if connection.id %}\n                            <a href=\"{% url 'explorer_connection_detail' connection.pk %}\">{{ connection.alias }}</a>\n                        {% else %}\n                            {{ connection.alias }}\n                        {% endif %}\n                    </td>\n                    <td>{{ connection.name }}</td>\n                    <td>{{ connection.get_engine_display }}{% if connection.is_upload %} (uploaded){% endif %}</td>\n                    <td>\n                        <a href=\"../play/?connection={{ connection.id }}\" class=\"px-2\"><i class=\"bi-arrow-up-right-square small me-1\"></i>Query</a>\n                        {% if connection.id %}\n                            <a href=\"{% url 'explorer_connection_update' connection.pk %}\" class=\"px-2\"><i class=\"bi-pencil-square\"></i></a>\n                            <a href=\"{% url 'explorer_connection_delete' connection.pk %}\"><i class=\"bi-trash\"></i></a>\n                            <a title=\"Refresh schema and data. For uploads, will force a refresh from S3.\" href=\"{% url 'explorer_connection_refresh' connection.pk %}\"><i class=\"bi-arrow-repeat\"></i></a>\n                        {% endif %}\n                    </td>\n                </tr>\n                {% endfor %}\n            </tbody>\n        </table>\n    </div>\n</div>\n\n<script>\n    document.addEventListener('DOMContentLoaded', function() {\n        function getQueryParam(param) {\n            let params = new URLSearchParams(window.location.search);\n            return params.get(param);\n        }\n\n        let highlight = getQueryParam('highlight');\n        if (highlight) {\n            let table = document.getElementById('connections_table');\n            let rows = table.getElementsByTagName('tr');\n            for (let i = 1; i < rows.length; i++) { // Start from 1 to skip the header row\n                let aliasCell = rows[i].getElementsByTagName('td')[0];\n                if (aliasCell && aliasCell.textContent.includes(highlight)) {\n                    rows[i].classList.add('table-active');\n                    break;\n                }\n            }\n        }\n    });\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/connections/database_connection_confirm_delete.html",
    "content": "{% extends 'explorer/base.html' %}\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n    <h2>Delete Database Connection</h2>\n    <p>Are you sure you want to delete \"{{ object }}\"?</p>\n    <form method=\"post\">\n        {% csrf_token %}\n        <button type=\"submit\" class=\"btn btn-secondary\">Delete</button>\n        <a href=\"{% url 'explorer_connections' %}\" class=\"btn btn-info\">Cancel</a>\n    </form>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/connections/database_connection_detail.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n    <h2>Connection Details</h2>\n    <table class=\"table table-bordered\">\n        <tr>\n            <th>Alias</th>\n            <td>{{ object.alias }}</td>\n        </tr>\n        <tr>\n            <th>Name</th>\n            <td>{{ object.name }}</td>\n        </tr>\n        <tr>\n            <th>Engine</th>\n            <td>{{ object.get_engine_display }}</td>\n        </tr>\n        <tr>\n            <th>Host</th>\n            <td>{{ object.host }}</td>\n        </tr>\n        {% if not object.is_upload %}\n        <tr>\n            <th>User</th>\n            <td>{{ object.user }}</td>\n        </tr>\n        <tr>\n            <th>Port</th>\n            <td>{{ object.port }}</td>\n        </tr>\n        <tr>\n            <th>Extras</th>\n            <td>{{ object.extras }}</td>\n        </tr>\n        {% endif %}\n    </table>\n    {% if object.is_upload %}\n        <span class=\"text-info-emphasis\">The source of this connection is an uploaded file.</span>\n    {% endif %}\n    <a href=\"{% url 'explorer_connection_update' object.pk %}\" class=\"btn btn-info\">Edit</a>\n    \n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/connections/database_connection_form.html",
    "content": "{% extends 'explorer/base.html' %}\n{% block sql_explorer_content %}\n<div class=\"container mt-5\">\n    <h2>{% if object %}Edit{% else %}Create New{% endif %} Connection</h2>\n{% if object.is_upload %}\n    <span class=\"text-danger\">The source of this connection is an uploaded file. In all likelihood you should not be editing it.</span>\n{% endif %}\n    <form method=\"post\" id=\"db-connection-form\">\n        {% csrf_token %}\n        <div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.alias }}\n                <label for=\"id_alias\" class=\"form-label\">Alias</label>\n                <span class=\"form-text text-muted\">Required. How the connection will appear in SQL Explorer.</span>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.engine }}\n                <label for=\"id_engine\" class=\"form-label\">Engine</label>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.name }}\n                <label for=\"id_name\" class=\"form-label\">Database Name</label>\n                <span class=\"form-text text-muted\">Required. The name of the database itself.</span>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.user }}\n                <label for=\"id_user\" class=\"form-label\">User</label>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.password }}\n                <label for=\"id_password\" class=\"form-label\">Password</label>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.host }}\n                <label for=\"id_host\" class=\"form-label\">Host</label>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.port }}\n                <label for=\"id_port\" class=\"form-label\">Port</label>\n            </div>\n            <div class=\"mb-3 form-floating\">\n                {{ form.extras }}\n                <label for=\"id_extras\" class=\"form-label\">Extras</label>\n                <span class=\"form-text text-muted\">Optionally provide JSON that will get merged into the final connection object.\n                      The result should be a valid Django database connection dictionary. This is somewhat rarely used.</span>\n            </div>\n        </div>\n        <button type=\"submit\" class=\"btn btn-primary\">{% if object %}Update{% else %}Add{% endif %} Connection</button>\n        <a href=\"{% url 'explorer_connections' %}\" class=\"btn btn-secondary\">Cancel</a>\n        <button type=\"button\" class=\"btn btn-info\" id=\"test-connection-btn\">Test Connection</button>\n    </form>\n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/assistant.html",
    "content": "{% load i18n %}\n<div class =\"accordion accordion-flush mt-4\" id=\"assistant_accordion\">\n    <div class=\"accordion-item\">\n        <div class=\"accordion-header\" id=\"assistant_accordion_header\">\n            <button class=\"accordion-button bg-light collapsed\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#assistant_collapse\" aria-expanded=\"false\" aria-controls=\"assistant_collapse\">\n                <label for=\"id_assistant_input\">SQL Assistant</label>\n            </button>\n        </div>\n    </div>\n    <div id=\"assistant_collapse\" class=\"accordion-collapse collapse\" aria-labelledby=\"assistant_accordion_header\" data-bs-parent=\"#assistant_accordion\">\n        <div class=\"accordion-body card\">\n            <div id=\"response_block\" class=\"position-relative d-none\">\n                <div class=\"mb-3 p-2 rounded-2 border bg-light\">\n                    <div id=\"assistant_response\"></div>\n                    <p class=\"spinner-border text-primary d-none\" id=\"assistant_spinner\" role=\"status\">\n                        <span class=\"visually-hidden\">Loading...</span>\n                    </p>\n                </div>\n            </div>\n            <div class=\"row assistant_input_parent\">\n                <div class=\"col-8\" id=\"assistant_input_wrapper\">\n                    <textarea\n                        class=\"form-control\" id=\"id_assistant_input\"\n                        name=\"sql_assistant\" rows=\"5\" placeholder=\"What do you need help with?\"></textarea>\n                    <label for=\"id_assistant_input\" class=\"form-label d-none\" id=\"id_assistant_input_label\">Assistant prompt</label>\n                    <div id=\"id_error_help_message\" class=\"d-none text-secondary small\">\n                        \"Ask Assistant\" to try and automatically fix the issue. The assistant is already aware of error messages & context.\n                    </div>\n                </div>\n                <div id=\"additional_table_container\" class=\"col-3\" style=\"width: 31% !important\">\n                    <div id=\"table-list\"></div>\n                </div>\n            </div>\n            <div class=\"assistant-icons\" style=\"\">\n                <div>\n                    <i class=\"bi-check-all\" id=\"select_all_button\" style=\"cursor: pointer;\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" data-bs-title=\"Add all\"></i>\n                </div>\n                <div>\n                    <i class=\"bi-trash\" id=\"deselect_all_button\" style=\"cursor: pointer;\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" data-bs-title=\"Remove all\"></i>\n                </div>\n                <div>\n                    <i class=\"bi-repeat\" id=\"refresh_tables_button\" style=\"cursor: pointer;\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" data-bs-title=\"Refresh autodetect\"></i>\n                </div>\n                <div>\n                    <i class=\"bi-card-list\" id=\"assistant_history\" style=\"cursor: pointer;\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" data-bs-title=\"History\"></i>\n                </div>\n                <div>\n                    <i class=\"bi-question-circle\" style=\"cursor: pointer;\"\n                    data-bs-toggle=\"tooltip\" data-bs-placement=\"top\"\n                    data-bs-title=\"SQL Assistant builds a prompt with your query, your request, and the tables (schema, sample data, and annotations) referenced here.\"></i>\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"col-12 text-center\">\n                    <div class=\"btn-group mt-3\" role=\"group\">\n                        <button type=\"button\" class=\"btn btn-outline-primary\" id=\"ask_assistant_btn\">Ask Assistant</button>\n                    </div>\n                </div>\n            </div>\n\n        </div>\n    </div>\n</div>\n\n"
  },
  {
    "path": "explorer/templates/explorer/base.html",
    "content": "{% load i18n static %}\n{% load vite %}\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{% translate \"SQL Explorer\" %}{% if query %} - {{ query.title }}{% elif title %} - {{ title }}{% endif %}</title>\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"{% vite_asset 'images/logo.png' %}\">\n\n    {% block style %}\n        {% vite_asset 'scss/styles.scss' %}\n    {% endblock style %}\n\n</head>\n\n<body>\n    <input type=\"hidden\" id=\"csrfCookieName\" value=\"{% firstof csrf_cookie_name 'csrftoken' %}\">\n    <input type=\"hidden\" id=\"csrfTokenInDOM\" value=\"{% firstof csrf_token_in_dom False %}\">\n    <input type=\"hidden\" id=\"clientRoute\" value=\"{{ request.resolver_match.url_name }}\">\n    <input type=\"hidden\" id=\"baseUrlPath\" value=\"{% url 'explorer_index' %}\">\n{% if vite_dev_mode %}\n    <div class=\"vite-not-running-canary\" style=\"text-align:center;\">\n        <hr>\n        <h1>Looks like Vite isn't running</h1>\n        <h2>This is easy to fix, I promise!</h2>\n        <div>You can run:</div>\n            <pre>\nnpm run dev\n            </pre>\n        <div>Then refresh this page, and you'll get all of your styles, JS, and hot-reloading.</div>\n        <div>If this is the first time you are running the project, then run:</div>\n        <pre>\nnvm install\nnvm use\nnpm install\nnpm run dev\n        </pre>\n        <hr>\n    </div>\n{% endif %}\n\n{% block sql_explorer_content_takeover %}\n    <nav class=\"navbar navbar-expand-lg navbar-dark mb-3\">\n        <div class=\"container bd-gutter flex-wrap flex-lg-nowrap\">\n            <a class=\"navbar-brand d-flex align-items-center\"\n                href=\"{% url 'explorer_index' %}\">\n                <img src=\"{% vite_asset 'images/logo-main.svg' %}\" alt=\"Logo\" class=\"me-2 logo-image\" >\n                {% translate \"SQL Explorer\" %}\n            </a>\n            <ul class=\"nav nav-pills\">\n                {% if can_change %}\n                    <li class=\"nav-item\">\n                        <a class=\"nav-link{% if not query and view_name == 'query_create' %} active{% endif %}\"\n                           href=\"{% url 'query_create' %}\"><i class=\"small me-1 bi-plus-circle\"></i>{% translate \"New Query\" %}</a>\n                    </li>\n                    <li class=\"nav-item\">\n                        <a class=\"nav-link{% if not query and view_name == 'explorer_playground' %} active{% endif %}\"\n                           href=\"{% url 'explorer_playground' %}\"><i class=\"small me-1 bi-arrow-up-right-square\"></i>{% translate \"Playground\" %}</a>\n                    </li>\n                    {% if db_connections_enabled and can_manage_connections %}\n                        <li class=\"nav-item\">\n                            <a class=\"nav-link{% if view_name == 'explorer_connections' %} active{% endif %}\"\n                               href=\"{% url 'explorer_connections' %}\"><i class=\"small me-1 bi-globe\"></i>{% translate \"Connections\" %}</a>\n                        </li>\n                        {% if assistant_enabled %}\n                            <li class=\"nav-item\">\n                                <a class=\"nav-link{% if view_name == 'table_description_list' %} active{% endif %}\"\n                                   href=\"{% url 'table_description_list' %}\"><i class=\"small me-1 bi-bank2\"></i>{% translate \"Annotations\" %}</a>\n                            </li>\n                        {% endif %}\n                    {% endif %}\n                {% endif %}\n                <li class=\"nav-item\">\n                    <a class=\"nav-link{% if not query and view_name == 'explorer_logs' %} active{% endif %}\"\n                       href=\"{% url 'explorer_logs' %}\"><i class=\"small me-1 bi-card-list\"></i>{% translate \"Logs\" %}</a>\n                </li>\n                <li class=\"nav-item\">\n                    <a class=\"nav-link{% if not query and view_name == 'query_favorites' %} active{% endif %}\"\n                       href=\"{% url 'query_favorites' %}\"><i class=\"small me-1 bi-heart\"></i>{% translate \"Favorites\" %}</a>\n                </li>\n            {% if hosted %}\n                <li class=\"nav-item\">\n                    <a class=\"nav-link\"\n                       href=\"/\"><i class=\"small me-1 bi-arrow-return-left\"></i>Manage</a>\n                </li>\n            {% endif %}\n            </ul>\n        </div>\n    </nav>\n{% block sql_explorer_content %}{% endblock %}\n{% endblock %}\n{% block sql_explorer_footer %}\n    <div class=\"container\">\n        <footer class=\"py-3 my-4\">\n            <p class=\"text-center text-body-secondary border-top pt-3\">\n                Powered by <a href=\"https://www.sqlexplorer.io/\">SQL Explorer</a>. Rendered at {% now \"SHORT_DATETIME_FORMAT\" %}\n            </p>\n        </footer>\n    </div>\n{% endblock %}\n{% block bottom_script %}\n    {% vite_hmr_client %}\n    {% vite_asset 'js/main.js' %}\n{% endblock bottom_script %}\n{% block sql_explorer_scripts %}{% endblock %}\n</body>\n\n</html>\n"
  },
  {
    "path": "explorer/templates/explorer/export_buttons.html",
    "content": "{% load explorer_tags i18n %}\n\n<div class=\"btn-group\" role=\"group\">\n    <button id=\"download_options\"\n            class=\"btn btn-outline-primary dropdown-toggle\"\n            data-bs-toggle=\"dropdown\" aria-expanded=\"false\">\n        {% translate 'Download...' %}\n    </button>\n    <ul class=\"dropdown-menu\" aria-labelledby=\"download_options\">\n        {% for key, name in exporters %}\n            {% if query and query.id %}\n                {% if query.params %}\n                    <li><a class=\"dropdown-item\"\n                           href=\"{% url 'download_query' query.id %}?format={{ key }}&params={{ query.params_for_url }}\">{{ name }}</a>\n                    </li>\n                {% else %}\n                    <li><a class=\"dropdown-item\"\n                           href=\"{% url 'download_query' query.id %}?format={{ key }}\">{{ name }}</a></li>\n                {% endif %}\n            {% else %}\n                <li><input type=\"submit\" value=\"{{ name }}\" data-format=\"{{ key }}\"\n                           class=\"download-button dropdown-item\"/></li>\n            {% endif %}\n        {% endfor %}\n    </ul>\n</div>\n\n\n\n"
  },
  {
    "path": "explorer/templates/explorer/fullscreen.html",
    "content": "{% load i18n static %}\n{% load vite %}\n\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"utf-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>{% translate \"SQL Explorer\" %}{% if query %} - {{ query.title }}{% elif title %} - {{ title }}{% endif %}</title>\n    {% block style %}\n        {% vite_asset 'scss/styles.scss' %}\n    {% endblock style %}\n</head>\n\n<body class=\"m-3\">\n  <h2>\n      {% if query %}\n          {{ query.title }}{% if shared %}<small>&nbsp;&nbsp;shared</small>{% endif %}\n      {% else %}\n          {% translate \"New Query\" %}\n      {% endif %}\n  </h2>\n  <table class=\"table table-striped\">\n      <thead>\n          <tr>\n              {% for h in headers %}\n                  <th>{{ h }}</th>\n              {% endfor %}\n          </tr>\n      </thead>\n      <tbody class=\"list\">\n          {% if data %}\n              {% for row in data %}\n                  <tr class=\"data-row\">\n                      {% for i in row %}\n                          {% if unsafe_rendering %}\n                              <td>{% autoescape off %}{{ i }}{% endautoescape %}</td>\n                          {% else %}\n                              <td>{{ i }}</td>\n                          {% endif %}\n                      {% endfor %}\n                  </tr>\n              {% endfor %}\n          {% else %}\n              <tr class=\"text-center\"><td colspan=\"{{ headers|length }}\">{% translate \"Empty Resultset\" %}</td></tr>\n          {% endif %}\n      </tbody>\n  </table>\n</body>\n</html>\n"
  },
  {
    "path": "explorer/templates/explorer/params.html",
    "content": "{% if params %}\n    <div class=\"mt-3 row\">\n        <label class=\"form-label\">Params</label>\n        {% for k, v in params.items %}\n            <div class=\"col\">\n                <div class=\"form-floating\">\n                <input type=\"text\" data-param=\"{{ k }}\"\n                       class=\"param form-control\"\n                       name=\"{{ k }}_param\" id=\"{{ k }}_param\"\n                       value=\"{{ v.val }}\" />\n                <label for=\"{{ k }}_param\">{{ v.label }}</label>\n                </div>\n            </div>\n        {% endfor %}\n    </div>\n{% endif %}\n"
  },
  {
    "path": "explorer/templates/explorer/pdf_template.html",
    "content": "<html>\n<head>\n    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n</head>\n<body>\n    <table>\n        <thead>\n            <tr>\n                {% for h in headers %}\n                    <th>{{ h }}</th>\n                {% endfor %}\n            </tr>\n        </thead>\n        <tbody>\n            {% for row in data %}\n            <tr>\n                {% for col in row %}\n                    <td>{{ col }}</td>\n                {% endfor %}\n            </tr>\n            {% endfor %}\n        </tbody>\n    </table>\n</body>\n</html>"
  },
  {
    "path": "explorer/templates/explorer/play.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load explorer_tags i18n %}\n\n{% block sql_explorer_content %}\n<div class=\"container\">\n    <div class=\"row\">\n        <div id=\"query_area\">\n            <h2>{% translate \"Playground\" %}</h2>\n            <p>\n                {% blocktranslate trimmed %}\n                    The playground is for experimenting and writing ad-hoc queries. By default, nothing you do here will be saved.\n                {% endblocktranslate %}\n            </p>\n            <form role=\"form\" action=\"{% url 'explorer_playground' %}\" method=\"post\" id=\"editor\" class=\"playground-form form-horizontal\">{% csrf_token %}\n                {% if error %}\n                    <div class=\"alert alert-danger db-error\">{{ error|escape }}</div>\n                {% endif %}\n                {{ form.non_field_errors }}\n                {% if can_change %}\n                    <div class=\"mb-3 form-floating\">\n                        {{ form.database_connection }}\n                        <label for=\"id_database_connection\" class=\"form-label\">{% translate \"Connection\" %}</label>\n                    </div>\n                {% else %}\n                    {# still need to submit the connection, just hide the UI element #}\n                    <div class=\"d-none\">\n                      {{ form.database_connection }}\n                    </div>\n                {% endif %}\n                <div class=\"row\">\n                    <div class=\"col\">\n                        <label for=\"id_sql\" class=\"form-label\">SQL</label>\n                    </div>\n                    <div class=\"col text-end\">\n                        {% if ql_id %}\n                            <a href=\"{% url 'explorer_playground' %}?querylog_id={{ ql_id }}\">\n                                <i class=\"bi-link\"></i>\n                            </a>\n                        {% endif %}\n                    </div>\n                </div>\n                <div class=\"row\" id=\"sql_editor_container\">\n                    <textarea class=\"form-control\" cols=\"40\" id=\"id_sql\" name=\"sql\" rows=\"20\">{{ query.sql }}</textarea>\n                    <div id=\"schema_tooltip\" class=\"d-none\"></div>\n                </div>\n\n                <div class=\"mt-3 text-center\">\n                    <div class=\"position-relative float-end\">\n                        <small>\n                            <a href=\"#\" title=\"Format code (Cmd/ctrl+shift+f)\" id=\"format_button\">\n                                <i class=\"bi-list-nested\"></i>\n                            </a>\n                        </small>\n                    </div>\n                    <div class=\"btn-group\" role=\"group\">\n                        <button type=\"submit\" id=\"refresh_play_button\"\n                                class=\"btn btn-primary\">{% translate 'Refresh' %}</button>\n                        <button type=\"submit\" id=\"create_button\"\n                                class=\"btn btn-outline-primary\">{% translate 'Save As New' %}</button>\n                        {% export_buttons query %}\n\n                        <button type=\"button\" class=\"btn btn-outline-primary\" id=\"show_schema_button\">\n                            {% translate \"Show Schema\" %}\n                        </button>\n                        <button type=\"button\" class=\"btn btn-outline-primary\" id=\"hide_schema_button\"\n                                style=\"display: none;\">\n                            {% translate \"Hide Schema\" %}\n                        </button>\n                    </div>\n                </div>\n                <input type=\"hidden\" value=\"{% translate 'Playground Query' %}\" name=\"title\" />\n                {% if assistant_enabled %}\n                    {% include 'explorer/assistant.html' %}\n                {% endif %}\n            </form>\n        </div>\n        <div id=\"schema\" style=\"display: none;\">\n            <iframe src=\"about:blank\" height=\"828px\" frameBorder=\"0\" id=\"schema_frame\"></iframe>\n        </div>\n    </div>\n</div>\n{% include 'explorer/preview_pane.html' %}\n\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/preview_pane.html",
    "content": "{% load i18n %}\n\n{% if headers %}\n<div class=\"container mt-4\">\n    <nav>\n        <div class=\"nav nav-tabs\" role=\"tablist\" id=\"nav-tab\">\n            <button class=\"nav-link active\" id=\"nav-preview-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#nav-preview\" type=\"button\" role=\"tab\" area-controls=\"nav-preview\" aria-selected=\"true\">{% translate \"Preview\" %}</button>\n            {% if query.id and query.snapshot %}\n                <button class=\"nav-link\" id=\"nav-snapshots-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#nav-snapshots\" type=\"button\" role=\"tab\" area-controls=\"nav-snapshots\" aria-selected=\"false\">{% translate \"Snapshots\" %}</button>\n            {% endif %}\n            {% if data %}\n                <button class=\"nav-link\" id=\"nav-pivot-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#nav-pivot\" type=\"button\" role=\"tab\" area-controls=\"nav-pivot\" aria-selected=\"false\">{% translate \"Pivot\" %}</button>\n                {% if charts_enabled and line_chart_svg %}\n                    <button class=\"nav-link\" id=\"nav-linechart-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#nav-linechart\" type=\"button\" role=\"tab\" area-controls=\"nav-linechart\" aria-selected=\"false\">{% translate \"Line Chart\" %}</button>\n                    <button class=\"nav-link\" id=\"nav-barchart-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#nav-barchart\" type=\"button\" role=\"tab\" area-controls=\"nav-barchart\" aria-selected=\"false\">{% translate \"Bar Chart\" %}</button>\n                {% endif %}\n            {% endif %}\n        </div>\n    </nav>\n    <div class=\"tab-content\" id=\"nav-tabContent\">\n        <div class=\"tab-pane show active\" id=\"nav-preview\" role=\"tabpanel\" area-labelledby=\"nav-preview-tab\">\n            <div role=\"tabpanel\" class=\"tab-pane active\" id=\"previewpane\">\n                <div class=\"card\">\n                    <div class=\"card-header\">\n                        <div class=\"row\">\n                            <div class=\"col\">\n                                {% if data %}\n                                    <a title=\"Show row numbers\" id=\"counter-toggle\" href=\"#\"><i class=\"bi-hash\"></i></a>&nbsp\n                                {% endif %}\n                                {% blocktranslate trimmed with duration=duration|floatformat:2 %}\n                                    Execution time: {{ duration }} ms\n                                {% endblocktranslate %}\n                            </div>\n                            <div class=\"col text-end\">\n                                <span class=\"me-1\">\n                                    {% if rows > total_rows %}\n                                        {% translate \"Showing\" %}&nbsp;\n                                        <input class=\"rows-input\" type=\"text\" name=\"rows\" id=\"rows\"\n                                               value=\"{{ total_rows }}\"/>\n                                    {% else %}\n                                        {% translate \"First\" %}&nbsp;\n                                        <input class=\"rows-input\" type=\"text\" name=\"rows\" id=\"rows\"\n                                               value=\"{{ rows }}\"/>\n                                    {% endif %}\n                                    {% blocktranslate %}of {{ total_rows }} total rows.{% endblocktranslate %}\n                                </span>\n                                <a id=\"fullscreen\" href=\"./?{{ fullscreen_params }}\" target=\"_blank\"\n                                   title=\"Fullscreen results\">\n                                    <i class=\"bi-arrows-angle-expand\"></i>\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                    <div class=\"card-body\">\n                        <div class=\"overflow-wrapper\">\n                            <table class=\"table table-striped table-hover\" id=\"preview\">\n                                <thead class=\"data-headers\">\n                                    <tr>\n                                        <th class=\"preview-header counter\" style=\"display: none;\"></th>\n                                        {% for h in headers %}\n                                            <th class=\"preview-header\"><span><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"{{ forloop.counter0 }}\"\n                                                         data-dir=\"asc\"></i>{{ h }}{% if h.summary %}<i class=\"stats-expand bi-calculator text-info ps-1\"></i>{% endif %}</span></th>\n                                        {% endfor %}\n                                    </tr>\n                                    {% if has_stats %}\n                                        <tr class=\"stats-th\">\n                                            <th class=\"counter\" style=\"display: none;\"></th>\n                                            {% for h in headers %}\n                                                <th>\n                                                    {% if h.summary %}\n                                                        <table class=\"stats-wrapper table table-sm fw-normal small\" style=\"display: none;\">\n                                                            {% for label, value in h.summary.stats.items %}\n                                                                <tr>\n                                                                    <td class=\"text-info\">{{ label }}</td>\n                                                                    <td>{{ value }}</td>\n                                                                </tr>\n                                                            {% endfor %}\n                                                        </table>\n                                                    {% endif %}\n                                                </th>\n                                            {% endfor %}\n                                        </tr>\n                                    {% endif %}\n                                </thead>\n                                <tbody class=\"list\">\n                                    {% if data %}\n                                        {% for row in data %}\n                                        <tr class=\"data-row\">\n                                            <td class=\"counter\" style=\"display: none;\">{{ forloop.counter0 }}</td>\n                                            {% for i in row %}\n                                                {% if unsafe_rendering %}\n                                                    <td class=\"{{ forloop.counter0 }}\">\n                                                        {% autoescape off %}{{ i }}{% endautoescape %}\n                                                    </td>\n                                                {% else %}\n                                                    <td class=\"{{ forloop.counter0 }}\">{{ i }}</td>\n                                                {% endif %}\n                                            {% endfor %}\n                                        </tr>\n                                        {% endfor %}\n                                    {% else %}\n                                        <tr class=\"text-center\">\n                                            <td colspan=\"{{ headers|length }}\">\n                                                {% translate \"Empty Resultset\" %}\n                                            </td>\n                                        </tr>\n                                    {% endif %}\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n        {% if query.id and query.snapshot and query.snapshots %}\n            <div class=\"tab-pane\" id=\"nav-snapshots\" role=\"tabpanel\" area-labelledby=\"nav-snapshots-tab\">\n                <h3>{{ snapshots|length }} Snapshots <small>(oldest first)</small></h3>\n                <div class=\"overflow-wrapper\">\n                    <p>\n                    <ul>\n                        {% for s in snapshots %}\n                            <li><a href='{{ s.url }}'>{{ s.last_modified }}</a></li>\n                        {% endfor %}\n                    </ul>\n                    </p>\n                </div>\n            </div>\n        {% endif %}\n\n        {% if data %}\n            <div class=\"tab-pane\" id=\"nav-pivot\" role=\"tabpanel\" area-labelledby=\"nav-pivot-tab\">\n                <div class=\"card p-3\">\n                    <ul class=\"nav justify-content-end\">\n                        <li class=\"nav-item\">\n                            <a id=\"pivot-bookmark\" class=\"nav-link\"\n                           data-baseurl=\"{% url 'explorer_playground' %}?querylog_id={{ ql_id }}\"\n                           href=\"#\">\n                            <i class=\"bi-link\"></i> Link to this\n                        </a>\n                        </li>\n                        <li class=\"nav-item\">\n                            <a id=\"button-excel\" class=\"nav-link\" href=\"#\"><i class=\"bi-download\"></i> Download CSV</a>\n                         </li>\n                    </ul>\n                    <div class=\"overflow-wrapper\">\n                        <div class=\"pivot-table\"></div>\n                    </div>\n                </div>\n            </div>\n            {% if charts_enabled and line_chart_svg %}\n                <div class=\"tab-pane\" id=\"nav-linechart\" role=\"tabpanel\" area-labelledby=\"nav-linechart-tab\">\n                    <div class=\"overflow-wrapper\">\n                        <div style=\"margin: 2em;\">\n                            {{ line_chart_svg | safe }}\n                        </div>\n                    </div>\n                </div>\n                <div class=\"tab-pane\" id=\"nav-barchart\" role=\"tabpanel\" area-labelledby=\"nav-barchart-tab\">\n                    <div class=\"overflow-wrapper\">\n                        <div style=\"margin: 2em;\">\n                            {{ bar_chart_svg | safe }}\n                        </div>\n                    </div>\n                </div>\n            {% endif %}\n        {% endif %}\n    </div>\n</div>\n{% endif %}\n"
  },
  {
    "path": "explorer/templates/explorer/query.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load explorer_tags i18n %}\n\n{% block sql_explorer_content %}\n\n<input type=\"hidden\" id=\"queryIdGlobal\" value=\"{{ query.id }}\">\n\n<div class=\"container\">\n    <div class=\"row\">\n        <div id=\"query_area\">\n            {% if query %}\n                {% query_favorite_button query.id is_favorite 'query_favorite_toggle query_favourite_detail'%}\n            {% endif %}\n            <h2>\n                {% if query %}\n                    {{ query.title }}\n                {% else %}\n                    {% translate \"New Query\" %}\n                {% endif %}\n            </h2>\n            {% if shared %}<small>&nbsp;&nbsp;shared</small>{% endif %}\n            {% if message %}\n                <div class=\"alert alert-info\">{{ message }}</div>\n            {% endif %}\n            <div>\n                {% if query %}\n                    <form action=\"{% url 'query_detail' query.id %}\" method=\"post\" id=\"editor\">{% csrf_token %}\n                {% else %}\n                    <form action=\"{% url 'query_create' %}\" method=\"post\" id=\"editor\">{% csrf_token %}\n                {% endif %}\n                {% if error %}\n                    <div class=\"alert alert-danger db-error\">{{ error|escape }}</div>\n                {% endif %}\n                {{ form.non_field_errors }}\n                <div class=\"my-3 form-floating\">\n                    <input class=\"form-control\" id=\"id_title\" maxlength=\"255\" name=\"title\" type=\"text\" {% if not can_change %}disabled{% endif %} value=\"{{ form.title.value|default_if_none:\"\" }}\" />\n                    <label for=\"id_title\" class=\"form-label\">{% translate \"Title\" %}</label>\n                    {% if form.title.errors %}{% for error in form.title.errors %}\n                        <div class=\"alert alert-danger\">{{ error|escape }}</div>\n                    {% endfor %}{% endif %}\n                </div>\n                {% if can_change %}\n                    <div class=\"mb-3 form-floating\">\n                        {{ form.database_connection }}\n                        <label for=\"id_database_connection\" class=\"form-label\">{% translate \"Connection\" %}</label>\n                    </div>\n                {% else %}\n                    {# still need to submit the connection, just hide the UI element #}\n                    <div class=\"d-none\">\n                      {{ form.database_connection }}\n                    </div>\n                {% endif %}\n                <div class=\"mb-3 form-floating\">\n                    <textarea\n                        id=\"id_description\" class=\"form-control\" cols=\"40\" name=\"description\"\n                        {% if not can_change %}disabled{% endif %} rows=\"2\"\n                    >{{ form.description.value|default_if_none:\"\" }}</textarea>\n                    <label for=\"id_description\" class=\"form-label\">\n                        {% translate \"Description\"%}\n                    </label>\n                    {% if form.description.errors %}\n                        <div class=\"alert alert-danger\">{{ form.description.errors }}</div>\n                    {% endif %}\n                </div>\n                {% if form.sql.errors %}\n                    {% for error in form.sql.errors %}\n                        <div class=\"alert alert-danger\">{{ error|escape }}</div>\n                    {% endfor %}\n                {% endif %}\n                <div class =\"accordion accordion-flush\" id=\"sql_accordion\">\n                    <div class=\"accordion-item\">\n                        <div class=\"accordion-header\" id=\"sql_accordion_header\">\n                            <button class=\"accordion-button bg-light\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#flush-collapseOne\" aria-expanded=\"false\" aria-controls=\"flush-collapseOne\">\n                                <label for=\"id_sql\">SQL</label>\n                            </button>\n                        </div>\n                    </div>\n                    <div id=\"flush-collapseOne\" class=\"accordion-collapse collapse{% if show_sql_by_default or not form.sql.value %} show{% endif %}\" aria-labelledby=\"sql_accordion_header\" data-bs-parent=\"#sql_accordion\">\n                        <div>\n                            <div class=\"row\" id=\"sql_editor_container\">\n                                <textarea\n                                    class=\"form-control\" {% if not can_change %} disabled {% endif %} cols=\"40\" id=\"id_sql\"\n                                    name=\"sql\" rows=\"20\">{{ form.sql.value|default_if_none:\"\" }}</textarea>\n                                <div id=\"schema_tooltip\" class=\"d-none\"></div>\n                            </div>\n                            {% if params %}\n                                <div class=\"row\">\n                                    {% include 'explorer/params.html' %}\n                                </div>\n                            {% endif %}\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"mt-3 text-center\">\n                    {% if query %}\n                        <div class=\"position-relative float-end\">\n                            <small>\n                                <span class=\"pe-3\">\n                                    {% if query and can_change and assistant_enabled %}{{ form.few_shot }} {% translate \"Assistant Example\" %}{% endif %}\n                                    <i class=\"bi-question-circle\" style=\"cursor: pointer;\" data-bs-toggle=\"tooltip\" data-bs-placement=\"top\" data-bs-title=\"Queries marked as examples will be sent, when relevant, to the AI Assistant as few-shot examples of how certain tables are used.\"></i>\n                                </span>\n                                <a href=\"#\" title=\"Open in playground\" id=\"playground_button\">\n                                    <i class=\"bi-arrow-up-right-square\"></i>\n                                </a>\n                                <a href=\"#\" title=\"Format code (Cmd/ctrl+shift+f)\" id=\"format_button\">\n                                    <i class=\"bi-list-nested\"></i>\n                                </a>\n                            </small>\n                        </div>\n                    {% endif %}\n                    <div class=\"btn-group\" role=\"group\">\n                        {% if can_change %}\n                            <button id=\"save_button\" type=\"submit\" class=\"btn btn-primary\">\n                                {% translate \"Save & Run\" %}\n                            </button>\n                            <button class=\"btn btn-outline-primary\" id=\"save_only_button\">\n                                {% translate \"Save Only\" %}\n                            </button>\n                            {% export_buttons query %}\n                            <button type=\"button\" class=\"btn btn-outline-primary\" id=\"show_schema_button\">\n                                {% translate \"Show Schema\" %}\n                            </button>\n                            <button type=\"button\" class=\"btn btn-outline-primary\" id=\"hide_schema_button\" style=\"display: none;\">\n                                {% translate \"Hide Schema\" %}\n                            </button>\n                        {% else %}\n                            <button id=\"refresh_button\" type=\"button\" class=\"btn btn-outline-primary\">{% translate \"Refresh\" %}</button>\n                            {% export_buttons query %}\n                        {% endif %}\n                    </div>\n                </div>\n                {% if assistant_enabled %}\n                    {% include 'explorer/assistant.html' %}\n                {% endif %}\n                </form>\n            </div>\n        </div>\n        <div id=\"schema\" style=\"display: none;\">\n            <iframe src=\"\" height=\"828px\" frameBorder=\"0\" id=\"schema_frame\"></iframe>\n        </div>\n    </div>\n</div>\n\n{% include 'explorer/preview_pane.html' %}\n\n<div class=\"container mt-1 text-end small\">\n    {% if query.avg_duration %}\n        {% blocktranslate trimmed with avg_duration_display=query.avg_duration_display cuser=query.created_by_user created=form.created_at_time %}\n            Avg. execution: {{ avg_duration_display }}ms. Query created by {{ cuser }} on {{ created }}.\n        {% endblocktranslate %}\n        {% if query %}<a href=\"{% url 'explorer_logs' %}?query_id={{ query.id }}\"> {% translate \"History\" %}</a>{% endif %}\n    {% endif %}\n</div>\n<div class=\"container mt-1 text-end small\">\n    {% if query and can_change and tasks_enabled %}{{ form.snapshot }} {% translate \"Snapshot\" %}{% endif %}\n</div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/query_confirm_delete.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load i18n %}\n\n{% block sql_explorer_content %}\n    <div class=\"container\">\n        <form method=\"post\">{% csrf_token %}\n            <div class=\"alert alert-info\">\n                {% blocktranslate trimmed with title=object.title %}\n                    Are you sure you want to delete \"{{ title }}\"?\n                {% endblocktranslate %}\n            </div>\n            <div>\n                <input type=\"submit\" value=\"Delete Query\" class=\"btn btn-danger\" />\n            </div>\n        </form>\n    </div>\n{%  endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/query_favorite_button.html",
    "content": "{% if is_favorite %}\n    <i class=\"bi-heart-fill text-danger {{ extra_classes }}\" data-id=\"{{ query_id }}\" data-url=\"{% url 'query_favorite' query_id%}\"></i>\n{% else %}\n    <i class=\"bi-heart text-danger {{ extra_classes }}\" data-id=\"{{ query_id }}\" data-url=\"{% url 'query_favorite' query_id%}\"></i>\n{% endif %}\n\n"
  },
  {
    "path": "explorer/templates/explorer/query_favorites.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load i18n %}\n\n{% block sql_explorer_content %}\n    <div class=\"container\">\n        <h3>{% translate \"Favorite Queries\" %}</h3>\n        <table class=\"table table-striped query-list\">\n            <thead>\n            <tr>\n                <th>{% translate \"Query\" %}</th>\n            </tr>\n            </thead>\n\n            <tbody>\n            {% for favorite in favorites %}\n                <tr>\n                    <th>\n                        <a href=\"{% url 'query_detail' favorite.query.id %}\">{{ favorite.query.title }}</a>\n                    </th>\n                </tr>\n\n                {% empty %}\n                <tr>\n                    <th>\n                        {% translate \"No favorite queries added yet.\" %}\n                    </th>\n                </tr>\n            {% endfor %}\n            </tbody>\n        </table>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/query_list.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load explorer_tags i18n static %}\n\n{% block sql_explorer_content %}\n    <div style=\"display: none\">{% csrf_token %}</div>\n    {% if connection_count == 0 %}\n        <div class=\"container py-5\">\n            <div class=\"row justify-content-center\">\n                <div class=\"col-md-8 text-center\">\n                    <h1 class=\"display-4 text-primary mb-4\">Welcome to SQL Explorer!</h1>\n                    <p class=\"lead text-muted\">\n                        First things first, in order to create queries and start exploring, you'll need to:\n                    </p>\n                    <p class=\"mb-4\">\n                        <a href=\"{% url 'explorer_connections' %}\" class=\"btn btn-secondary btn-lg\">Create a Connection</a>\n                    </p>\n                    <p class=\"text-muted\">\n                        Need help? Check out the <a href=\"https://django-sql-explorer.readthedocs.io/en/latest/\" class=\"text-decoration-underline\">documentation</a>{% if hosted %} or <a href=\"mailto:support@sqlexplorer.io\" class=\"text-decoration-underline\">contact support</a>{% endif %}.\n                    </p>\n                </div>\n            </div>\n        </div>\n    {% elif object_list|length == 0 %}\n        <div class=\"container py-5\">\n            <div class=\"row justify-content-center\">\n                <div class=\"col-md-8 text-center\">\n                    <h1 class=\"display-4 text-primary mb-4\">Time to create a query</h1>\n                    <p class=\"lead text-muted\">\n                        You have {{ connection_count }} connection{% if connection_count > 1 %}s{% endif %} created, now get cracking!\n                    </p>\n                    <p class=\"mb-4\">\n                        <a href=\"{% url 'query_create' %}\" class=\"btn btn-secondary btn-lg\">Create a Query</a> or <a href=\"{% url 'explorer_playground' %}\" class=\"btn btn-secondary btn-lg\">Play Around</a>\n                    </p>\n                    <p class=\"text-muted\">\n                        Need help? Check out the <a href=\"https://django-sql-explorer.readthedocs.io/en/latest/\" class=\"text-decoration-underline\">documentation</a>{% if hosted %} or <a href=\"mailto:support@sqlexplorer.io\" class=\"text-decoration-underline\">contact support</a>{% endif %}.\n                    </p>\n                </div>\n            </div>\n        </div>\n    {% else %}\n        {% if recent_queries|length > 0 %}\n            <div class=\"container\">\n                <h3>{% translate \"Recently Run by You\" %}</h3>\n                <table class=\"table table-striped table-borderless\">\n                    <thead>\n                        <tr>\n                            <th>{% translate \"Query\" %}</th>\n                            <th>{% translate \"Last Run\" %}</th>\n                            <th class=\"text-center\">CSV</th>\n                        </tr>\n                    </thead>\n                    <tbody>\n                        {% for object in recent_queries %}\n                            <tr>\n                                <td class=\"name\">\n                                    <a href=\"{% url 'query_detail' object.query_id %}\">{{ object.query.title }}</a>\n                                </td>\n                                <td>{{ object.run_at|date:\"SHORT_DATETIME_FORMAT\" }}</td>\n                                <td class=\"text-center\">\n                                    <a href=\"{% url 'download_query' object.query_id %}\">\n                                        <i class=\"bi-download\"></i>\n                                    </a>\n                                </td>\n                            </tr>\n                        {% endfor %}\n                    </tbody>\n                </table>\n            </div>\n        {% endif %}\n\n        <div id=\"queries\" class=\"container\">\n            <div class=\"row\">\n                <div class=\"col\">\n                    <h3>{% translate \"All Queries\" %}</h3>\n                </div>\n                <div class=\"col text-end\">\n                    <input class=\"search\" placeholder=\"{% translate \"Search\" %}\" style=\"\">\n                </div>\n            </div>\n            <table class=\"table table-striped table-borderless\">\n                <thead>\n                    <tr>\n                        <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"sort-name\"\n                                                             data-dir=\"asc\"></i>{% translate \"Query\" %}</th>\n                        <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"sort-created\"\n                                                             data-dir=\"asc\"></i>{% translate \"Created\" %}</th>\n                        {% if tasks_enabled %}\n                            <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"{{ forloop.counter0 }}\"\n                                                             data-dir=\"asc\"></i>{% translate \"Email\" %}</th>\n                        {% endif %}\n                        <th>{% translate \"CSV\" %}</th>\n                        {% if can_change %}\n                            <th>{% translate \"Play\" %}</th>\n                            <th>{% translate \"Delete\" %}</th>\n                        {% endif %}\n                        <th>{% translate \"Favorite\" %}</th>\n                        <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"sort-last-run\"\n                                                             data-dir=\"asc\"></i>{% translate \"Last Run\" %}</th>\n                        <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"sort-run-count\"\n                                                             data-dir=\"asc\"></i>{% translate \"Run Count\" %}</th>\n                        <th><i class=\"sort bi-chevron-expand pe-1\" data-sort=\"sort-connection\"\n                                                             data-dir=\"asc\"></i>{% translate \"Connection\" %}</th>\n                    </tr>\n                </thead>\n                <tbody class=\"list\">\n                    {% for object in object_list %}\n                        <tr {% if object.is_in_category %}class=\"collapse {{object.collapse_target}}\" data-bs-config='{\"delay\":0}'{% endif %}>\n                            {% if object.is_header %}\n                                <td colspan=\"100\">\n                                    <strong>\n                                        <span data-bs-toggle=\"collapse\" style=\"cursor: pointer;\" data-bs-target=\".{{object.collapse_target}}\">\n                                            <i class=\"bi-plus-circle\"></i> {{ object.title }} ({{ object.count }})\n                                        </span>\n                                    </strong>\n                                </td>\n                            {% else %}\n                                <td class=\"sort-name\">\n                                    <a href=\"{% url 'query_detail' object.id %}\"{% if object.is_in_category %} class=\"ms-3\"{% endif %}>{{ object.title }}</a>\n                                </td>\n                                <td class=\"sort-created\">{{ object.created_at|date:\"m/d/y\" }}\n                                    {% if object.created_by_user %}\n                                        {% blocktranslate trimmed with cuser=object.created_by_user %}\n                                            by {{cuser}}\n                                        {% endblocktranslate %}\n                                    {% endif %}\n                                </td>\n                                {% if tasks_enabled %}\n                                  <td>\n                                      <a class=\"email-csv\" data-query-id=\"{{ object.id }}\">\n                                          <i class=\"bi-send-arrow-down\"></i>\n                                      </a>\n                                  </td>\n                                {% endif %}\n                                <td>\n                                    <a href=\"{% url 'download_query' object.id %}\">\n                                        <i class=\"bi-download\"></i>\n                                    </a>\n                                </td>\n                                {% if can_change %}\n                                    <td>\n                                        <a href=\"{% url 'explorer_playground' %}?query_id={{ object.id }}\">\n                                            <i class=\"bi-arrow-up-right-square\"></i>\n                                        </a>\n                                    </td>\n                                    <td>\n                                        <a href=\"{% url 'query_delete' object.id %}\">\n                                            <i class=\"bi-trash\"></i>\n                                        </a>\n                                    </td>\n                                {% endif %}\n                                <td> {% query_favorite_button object.id object.is_favorite 'query_favorite_toggle' %}</td>\n                                <td class=\"sort-last-run\">{% if object.ran_successfully %}\n                                        <i class=\"bi-check-circle pe-2 text-success\"></i>\n                                    {% elif object.ran_successfully is not None %}\n                                        <i class=\"bi-slash-circle pe-2 text-danger\"></i>\n                                    {% endif %}\n                                    {{ object.last_run_at|date:\"m/d/y\" }}\n                                </td>\n                                <td class=\"sort-run-count\">{{ object.run_count }}</td>\n                                <td class=\"sort-connection\">{{ object.connection_name }}</td>\n                            {% endif %}\n                        </tr>\n                    {% endfor %}\n                </tbody>\n            </table>\n        </div>\n\n        <div class=\"modal fade\" id=\"emailCsvModal\" tabindex=\"-1\" aria-hidden=\"true\">\n            <div class=\"modal-dialog\">\n                <div class=\"modal-content\">\n                    <div class=\"modal-header\">\n                        <h1 class=\"modal-title fs-5\" id=\"exampleModalLabel\">Email Query Results</h1>\n                        <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n                    </div>\n                    <div class=\"modal-body\">\n                        <div class=\"input-group\">\n                            <input type=\"email\" autofocus=\"true\" name=\"email\" id=\"emailCsvInput\" class=\"form-control\" placeholder=\"Email\" />\n                            <label for=\"emailCsvInput\" style=\"display: none\" aria-hidden=\"true\">{% translate \"Email\" %}</label>\n                            <span class=\"input-group-btn\">\n                                <button id=\"btnSubmitCsvEmail\" type=\"button\" class=\"btn btn-primary\">Send</button>\n                            </span>\n                        </div>\n                        <div class=\"mt-3\">\n                            <div class=\"alert alert-success\" style=\"display: none;\" role=\"alert\" id=\"email-success-msg\">\n                                {% translate \"Email will be sent when query completes\" %}\n                            </div>\n                            <div class=\"alert alert-danger\" style=\"display: none;\" role=\"alert\" id=\"email-error-msg\">\n                            </div>\n                        </div>\n                    </div>\n                    <div class=\"modal-footer\">\n                        <button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Close</button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    {% endif %}\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/querylog_list.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load i18n %}\n\n{% block sql_explorer_content %}\n    <div class=\"container\">\n        <h3>{% blocktranslate with pagenum=page_obj.number %}Recent Query Logs - Page {{pagenum}}{% endblocktranslate %}</h3>\n        <table class=\"table table-striped query-list\">\n            <thead>\n                <tr>\n                    <th>{% translate \"Run At\" %}</th>\n                    <th>{% translate \"Run By\" %}</th>\n                    <th>{% translate \"Database Connection\" %}</th>\n                    <th>{% translate \"Duration\" %}</th>\n                    <th class=\"sql\">SQL</th>\n                    <th>{% translate \"Query ID\" %}</th>\n                    <th>{% translate \"Playground\" %}</th>\n                </tr>\n            </thead>\n            <tbody>\n                {% for object in recent_logs %}\n                    <tr>\n                        <td>{{ object.run_at|date:\"SHORT_DATETIME_FORMAT\" }}</td>\n                        <td>{{ object.run_by_user }}</td>\n                        <td>{{ object.database_connection }}</td>\n                        <td>{{ object.duration|floatformat:2 }}ms</td>\n                        <td class=\"log-sql\">{{ object.sql }}</td>\n                        <td>\n                            {% if object.query_id %}\n                                <a href=\"{% url \"query_detail\" object.query_id %}\">\n                                    {% blocktranslate trimmed with query_id=object.query_id %}\n                                        Query {{ query_id }}\n                                    {% endblocktranslate %}\n                                </a>\n                            {% elif object.is_playground %}\n                                {% translate \"Playground\" %}\n                            {% else %}\n                                --\n                            {% endif %}\n                        </td>\n                        <td>\n                            <a href=\"{% url \"explorer_playground\" %}?querylog_id={{ object.id }}\">\n                                {% translate \"Open\" %}\n                            </a>\n                        </td>\n                    </tr>\n                {% endfor %}\n            </tbody>\n        </table>\n        {% if is_paginated %}\n            <div class=\"pagination\">\n                <span>\n                    {% if page_obj.has_previous %}\n                        <a href=\"?page={{ page_obj.previous_page_number }}\"><i class=\"bi-arrow-left-square\"></i></a>\n                    {% endif %}\n                    <span class=\"page-current\">\n                        {% blocktranslate trimmed with pnum=page_obj.number anum=page_obj.paginator.num_pages %}\n                            Page {{ pnum }} of {{ anum }}\n                        {% endblocktranslate %}\n                    </span>\n                    {% if page_obj.has_next %}\n                        <a href=\"?page={{ page_obj.next_page_number }}\"><i class=\"bi-arrow-right-square\"></i></a>\n                    {% endif %}\n                </span>\n            </div>\n        {% endif %}\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/schema.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load i18n %}\n\n\n{% block sql_explorer_content_takeover %}\n<div class=\"schema-wrapper\">\n    <h4>{% translate \"Schema\" %}</h4>\n    <div id=\"schema-contents\">\n        <p><input class=\"search form-control\" placeholder=\"{% translate \"Search Tables\" %}\" /></p>\n        <div class=\"row\">\n            <div class=\"col\">\n                <a class=\"link-primary\" id=\"collapse_all\">\n                    {% translate \"Collapse Tables\" %}\n                </a>\n            </div>\n            <div class=\"col\">\n                <a class=\"link-primary\" id=\"expand_all\">\n                    {% translate \"Expand Tables\" %}\n                </a>\n            </div>\n        </div>\n        <div class=\"mt-3\">\n            <ul class=\"list\">\n                {% for m in schema %}\n                    <li>\n                        <div class=\"app-name schema-header fw-semibold\" style=\"display: inline-block\">{{ m.0 }}</div>\n                        <div class=\"schema-table\">\n                            <table class=\"table table-sm\">\n                                <tbody>\n                                    {% for c in m.1 %}\n                                        <tr>\n                                            <td><code class=\"copyable\">{{ c.0 }}</code></td>\n                                            <td class=\"text-muted small\">{{ c.1 }}</td>\n                                        </tr>\n                                    {% endfor %}\n                                </tbody>\n                            </table>\n                        </div>\n                    </li>\n                {% endfor %}\n            </ul>\n        </div>\n    </div>\n</div>\n{% endblock %}\n{% block sql_explorer_footer %}{% endblock %}\n"
  },
  {
    "path": "explorer/templates/explorer/schema_error.html",
    "content": "{% extends \"explorer/base.html\" %}\n{% load i18n %}\n\n{% block sql_explorer_content_takeover %}\n    <div class=\"schema-wrapper\">\n        <h4 class=\"text-center\">{% translate \"Schema failed to build.\" %}</h4>\n        <div>{% blocktranslate %}\n            The connection '{{ connection }}' is likely misconfigured or unavailable.\n        {% endblocktranslate %}</div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "explorer/templatetags/__init__.py",
    "content": ""
  },
  {
    "path": "explorer/templatetags/explorer_tags.py",
    "content": "from django import template\nfrom django.utils.module_loading import import_string\n\nfrom explorer import app_settings\n\n\nregister = template.Library()\n\n\n@register.inclusion_tag(\"explorer/export_buttons.html\")\ndef export_buttons(query=None):\n    exporters = []\n    for name, classname in app_settings.EXPLORER_DATA_EXPORTERS:\n        exporter_class = import_string(classname)\n        exporters.append((name, exporter_class.name))\n    return {\n        \"exporters\": exporters,\n        \"query\": query,\n    }\n\n\n@register.inclusion_tag(\"explorer/query_favorite_button.html\")\ndef query_favorite_button(query_id, is_favorite, extra_classes):\n    return {\n        \"query_id\": query_id,\n        \"is_favorite\": is_favorite,\n        \"extra_classes\": extra_classes\n    }\n"
  },
  {
    "path": "explorer/templatetags/vite.py",
    "content": "import os\n\nfrom django import template\nfrom django.conf import settings\nfrom django.contrib.staticfiles.storage import staticfiles_storage\nfrom django.utils.safestring import mark_safe\nfrom explorer import app_settings, get_version\n\nregister = template.Library()\n\nVITE_OUTPUT_DIR = staticfiles_storage.url(\"explorer/\")\nVITE_DEV_DIR = \"explorer/src/\"\nVITE_SERVER_HOST = getattr(settings, \"VITE_SERVER_HOST\", \"localhost\")\nVITE_SERVER_PORT = getattr(settings, \"VITE_SERVER_PORT\", \"5173\")\n\n\ndef get_css_link(file: str) -> str:\n    if app_settings.VITE_DEV_MODE is False:\n        file = file.replace(\".scss\", \".css\")\n        base_url = f\"{VITE_OUTPUT_DIR}\"\n    else:\n        base_url = f\"http://{VITE_SERVER_HOST}:{VITE_SERVER_PORT}/{VITE_DEV_DIR}\"\n    return mark_safe(f'<link rel=\"stylesheet\" href=\"{base_url}{file}\">')\n\n\ndef get_script(file: str) -> str:\n    if app_settings.VITE_DEV_MODE is False:\n        file = file.replace(\".js\", f\".{get_version()}.js\")\n        return mark_safe(f'<script type=\"module\" src=\"{VITE_OUTPUT_DIR}{file}\"></script>')\n    else:\n        base_url = f\"http://{VITE_SERVER_HOST}:{VITE_SERVER_PORT}/{VITE_DEV_DIR}\"\n    return mark_safe(f'<script type=\"module\" src=\"{base_url}{file}\"></script>')\n\n\ndef get_asset(file: str) -> str:\n    if app_settings.VITE_DEV_MODE is False:\n        return mark_safe(f\"{VITE_OUTPUT_DIR}{file}\")\n    else:\n        return mark_safe(f\"http://{VITE_SERVER_HOST}:{VITE_SERVER_PORT}/{VITE_DEV_DIR}{file}\")\n\n\n@register.simple_tag\ndef vite_asset(filename: str):\n    if str(filename).endswith(\"scss\"):\n        if app_settings.VITE_DEV_MODE is False:\n            filename = os.path.basename(filename)\n        return get_css_link(filename)\n    if str(filename).endswith(\"js\"):\n        if app_settings.VITE_DEV_MODE is False:\n            filename = os.path.basename(filename)\n        return get_script(filename)\n\n    # Non js/scss assets respect directory structure so don't need to do the filename rewrite\n    return get_asset(filename)\n\n\n@register.simple_tag\ndef vite_hmr_client():\n    if app_settings.VITE_DEV_MODE is False:\n        return \"\"\n    base_url = f\"http://{VITE_SERVER_HOST}:{VITE_SERVER_PORT}/@vite/client\"\n    return mark_safe(f'<script type=\"module\" src=\"{base_url}\"></script>')\n"
  },
  {
    "path": "explorer/tests/__init__.py",
    "content": ""
  },
  {
    "path": "explorer/tests/csvs/all_types.csv",
    "content": "Dates,Integers,Floats,Strings\n2020-01-31,0,42.952198961732414,THNVT\n2020-02-29,1,27.66862453654746,JXPSY\n2020-03-31,2,79.028965687494,FSTNN\n2020-04-30,3,97.00288969016145,BVMNF\n,,,\n\"\",\"\",\"\"\n2020-05-31,4,74.09328128351054,XUMUJ\n"
  },
  {
    "path": "explorer/tests/csvs/dates.csv",
    "content": "Dates,Values\n2024-01-24,0\n2024-01-24T18:45:00Z,1\n01/24/2024,2\n01/24/2024 6:45 PM,3\n24/01/2024,4\n24/01/2024 18:45,5\n24/01/2024 18:45:00,6\n\"January 24, 2024\",7\n\"Jan 24, 2024\",8\n2024-01-24T18:45:00-05:00,9\n\"Thu, 24 Jan 2024 18:45:00 +0000\",10\n\"Thu, 24 Jan 2024 18:45:00 +0000 (GMT)\",11"
  },
  {
    "path": "explorer/tests/csvs/floats.csv",
    "content": "Floats,Values\n61.3410231760637,0\n79.2367071213973,1\n96.93217099083482,2\n69.50523191870069,3\n,4\n\"69.42719764194946\",4\n\"2,000.0128\",5\n\"2.000,0128\",5\n"
  },
  {
    "path": "explorer/tests/csvs/integers.csv",
    "content": "Integers,More_integers\n\"5,000\",0\n\"6000\",1\n\"\",0\n\"7,200,200\",2"
  },
  {
    "path": "explorer/tests/csvs/mixed.csv",
    "content": "Value1,Value2,Value3\n2020-01-32,abc,123\nVariety of other dates,def,123\n,,\nAnother,123,12a"
  },
  {
    "path": "explorer/tests/csvs/rc_sample.csv",
    "content": "name,material_type,seating_type,speed,height,length,num_inversions,manufacturer,park,status\nGoudurix,Steel,Sit Down,75.0,37.0,950.0,7.0,Vekoma,Parc Asterix,status.operating\nDream catcher,Steel,Suspended,45.0,25.0,600.0,0.0,Vekoma,Bobbejaanland,status.operating\nAlucinakis,Steel,Sit Down,30.0,8.0,250.0,0.0,Zamperla,Terra Mítica,status.operating\nAnaconda,Wooden,Sit Down,85.0,35.0,1200.0,0.0,William J. Cobb,Walygator Parc,status.operating\nAzteka,Steel,Sit Down,55.0,17.0,500.0,0.0,Soquet,Le Pal,status.operating\nBat Coaster,Steel,Inverted,70.0,20.0,400.0,2.0,Pinfari,Nigloland,status.relocated\nBatman : Arkham Asylum,Steel,Inverted,80.0,32.0,823.0,5.0,B&M,Parque Warner Madrid,status.operating\nBig Thunder Mountain,Steel,Sit Down,60.0,22.0,1500.0,0.0,Vekoma,Disneyland Park,status.operating\nEqWalizer,Steel,Sit Down,76.0,36.0,285.0,3.0,Vekoma,Walibi Rhône Alpes,status.operating\nCalamity Mine,Steel,Sit Down,48.0,14.0,785.0,0.0,Vekoma,Walibi Belgium,status.operating\n\"Casey Jr, le Petit Train du Cirque\",Steel,Sit Down,30.0,,,0.0,Vekoma,Disneyland Park,status.operating\nCobra,Steel,Sit Down,76.0,36.0,285.0,3.0,Vekoma,Walibi Belgium,status.operating\nCoccinelle,Steel,Sit Down,36.0,8.0,360.0,0.0,Zierer,Walibi Rhône Alpes,status.operating\nColeoz'Arbres,Steel,Sit Down,60.0,,540.0,0.0,Schwarzkopf,Bagatelle,status.closed.definitely\nComet,Steel,Sit Down,64.0,24.0,,3.0,Vekoma,Walygator Parc,status.operating\nCourse de Bobsleigh,Steel,Sit Down,65.0,15.0,450.0,0.0,Schwarzkopf,Nigloland,status.relocated\nCumbres,Steel,Sit Down,,3.0,,0.0,Miler Coaster,Parque de Atracciones de Madrid,status.closed.definitely\nLe Dragon de Bei Hai,Steel,Sit Down,,,,0.0,Cavazza Diego,La Mer de Sable,status.closed.definitely\nEuro Mir,Steel,Spinning,80.0,28.0,980.0,0.0,Mack,Europa Park,status.operating\nEurosat,Steel,Sit Down,60.0,26.0,877.0,0.0,Mack,Europa Park,status.retracked\nExpedition Ge Force,Steel,Sit Down,120.0,53.0,1220.0,0.0,Intamin,Holiday Park,status.operating\nLe Grand canyon,Steel,Sit Down,50.0,12.0,380.0,0.0,Soquet,Fraispertuis City,status.operating\nIndiana Jones et le Temple du Péril,Steel,Sit Down,58.0,18.0,566.0,1.0,Intamin,Disneyland Park,status.operating\nJaguar,Steel,Inverted,83.0,34.0,689.0,5.0,Vekoma,Isla Magica,status.operating\nCop Car Chase (1),Steel,Sit Down,60.0,16.0,620.0,2.0,Intamin,Movie Park Germany,status.closed.definitely\nLoup Garou,Wooden,Sit Down,80.0,28.0,1035.0,0.0,Vekoma,Walibi Belgium,status.operating\nMagnus Colossus,Wooden,Sit Down,92.0,38.0,1150.0,0.0,RCCA,Terra Mítica,status.closed.temporarily\nOki Doki,Steel,Sit Down,58.0,16.0,436.0,0.0,Vekoma,Bobbejaanland,status.operating\nSOS Numerobis,Steel,Sit Down,32.0,6.0,200.0,0.0,Zierer,Parc Asterix,status.operating\nPoseïdon,Steel,Water Coaster,70.0,23.0,836.0,0.0,Mack,Europa Park,status.operating\nRock'n Roller Coaster avec Aerosmith,Steel,Sit Down,92.0,24.0,1037.0,3.0,Vekoma,Walt Disney Studios,status.operating\nLa Ronde des Rondins,Steel,Sit Down,26.0,3.0,60.0,0.0,Zierer,Parc Asterix,status.relocated\nSilverstar,Steel,Sit Down,127.0,73.0,1620.0,0.0,B&M,Europa Park,status.operating\nSuperman la Atraccion de Acero,Steel,Floorless,105.0,50.0,1200.0,7.0,B&M,Parque Warner Madrid,status.operating\nLa Trace du Hourra,Steel,Bobsleigh,60.0,31.0,900.0,0.0,Mack,Parc Asterix,status.operating\nStunt Fall,Steel,Sit Down,106.0,58.0,367.0,3.0,Vekoma,Parque Warner Madrid,status.operating\nLe Tigre de Sibérie,Steel,Sit Down,40.0,13.0,360.0,0.0,Reverchon,Le Pal,status.operating\nTitánide,Steel,Inverted,80.0,33.0,689.0,5.0,Vekoma,Terra Mítica,status.operating\nTom y Jerry,Steel,Sit Down,36.0,8.0,360.0,0.0,Zierer,Parque Warner Madrid,status.operating\nPsyké underground,Steel,Sit Down,85.0,42.0,260.0,1.0,Schwarzkopf,Walibi Belgium,status.operating\nTonnerre de Zeus,Wooden,Sit Down,84.0,30.0,1233.0,0.0,CCI,Parc Asterix,status.operating\nTren Bravo (Left),Steel,Sit Down,45.0,6.0,394.0,0.0,Zamperla,Terra Mítica,status.closed.temporarily\nTyphoon,Steel,Sit Down,80.0,26.0,670.0,4.0,Gerstlauer,Bobbejaanland,status.operating\nSchweizer Bobbahn,Steel,Bobsleigh,50.0,19.0,487.0,0.0,Mack,Europa Park,status.operating\nVampire,Steel,Inverted,80.0,33.0,689.0,5.0,Vekoma,Walibi Belgium,status.operating\nLe Vol d'Icare,Steel,Sit Down,42.0,11.0,410.0,0.0,Zierer,Parc Asterix,status.operating\nWild Train,Steel,Sit Down,70.0,15.0,330.0,0.0,Pax,Parc Saint Paul,status.operating\nBandit,Wooden,Sit Down,80.0,28.0,1099.0,0.0,RCCA,Movie Park Germany,status.operating\nWoodstock Express,Steel,Sit Down,,,220.0,0.0,Zamperla,Walibi Rhône Alpes,status.operating\n"
  },
  {
    "path": "explorer/tests/csvs/test_case1.csv",
    "content": "STORE,CONTENT_TYPE,EMAIL,CUSTOMER_ID,CREATED_DATE,SHIP_MONTH,SHIP_DATE,SHOPIFY_ORDER_NUMBER,SKU,SHOPIFY_PRODUCT_ID,SHOPIFY_VARIANT_ID,PRODUCT_TITLE,VARIANT_TITLE,PRICE,QUANTITY,SUB_TOTAL_PRICE,SHIPPING_FEE,TAX,DISCOUNT,SHOPIFY_ORDER_ID,GIFTCARD_AMOUNT,REFUNDS,REFUND_TAX,REFUND_SALE,REFUND_DATE,SHIPPING_ADDRESS_PROVINCE_CODE,ORDER_TAG,SHIPPING_ADDRESS_FIRST_NAME,SHIPPING_ADDRESS_LAST_NAME,SHIPPING_ADDRESS_ADDRESS_1,SHIPPING_ADDRESS_ADDRESS_2,SHIPPING_ADDRESS_CITY,SHIPPING_ADDRESS_PROVINCE,SHIPPING_ADDRESS_COUNTRY,SHIPPING_ADDRESS_ZIP,ORDER_TAGS,\r\n556516,HBUS,Vitamin,fdf@yahoo.com,3887814443067,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3948556,HBVITAkidsleep75,4907580457019,33723289010235,(Vitamin Bundle) Kid's Sleep,Default Title,0,1,0,0,0,0,4857684885563,0,0,0,0,,FL,,Clark,4075493288,Petunia Terrace,113,De Pere,Wisconsin,United States,32771,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\"\r\n556517,HBUS,Vitamin,dsfs@yahoo.com,3887814443067,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3948556,HBVITAmenim60,4331305107515,31085422018619,(Vitamin Bundle) Organic Men's Multi + Super Immune,Default Title,0,1,0,0,0,0,4857684885563,0,0,0,0,,FL,,Winter,4075493288,20 Union St,113,Warren,Michigan,United States,32771,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\"\r\n556518,HBUS,Vitamin,fdsdf@comcast.net,2444580454459,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946315,HBVITAkidsleep75,4907580457019,33723289010235,(Vitamin Bundle) Kid's Sleep,Default Title,0,3,0,0,0,0,4857557188667,0,0,0,0,,PA,,Berns,2532085200,Eisenhower Dr,,Port Jefferson,New York,United States,15065,\"Subscription, Subscription Recurring Order, FRAUD_APPROVED, sent-to-d365\"\r\n556519,HBUS,Vitamin,fdsdf@yahoo.com,3887814443067,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3948556,HBVITAHSNCol60NV,6830033633339,40285923606587,\"(Vitamin Bundle) Hair, Skin, Nails + Collagen\",Default Title,0,1,0,0,0,0,4857684885563,0,0,0,0,,FL,,Gantt,4075493288,227 Crooked Oak Rd,113,Port Jefferson,New York,United States,32771,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\"\r\n556520,HBUS,Vitamin,asd@comcast.net,2444580454459,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946315,HBVitaBundle7,4331298521147,31085392724027,Vitamin Bundle,7,65.99,1,65.99,4.99,0,13.2,4857557188667,0,0,0,0,,PA,,Johnson,2532085200,227 Eisenhower Dr,,Salt Lake City,Utah,United States,15065,\"Subscription, Subscription Recurring Order, FRAUD_APPROVED, sent-to-d365\"\r\n556521,HBUS,Vitamin,fda@comcast.net,2444580454459,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946315,HBVITAkid60,4331303469115,31085415006267,(Vitamin Bundle) Organic Kid's Multi,60-count,0,2,0,0,0,0,4857557188667,0,0,0,0,,PA,,Moody,2532085200,Eisenhower Dr,,De Pere,Wisconsin,United States,15065,\"Subscription, Subscription Recurring Order, FRAUD_APPROVED, sent-to-d365\"\r\n556522,HBUS,Vitamin,fdsa.family@gmail.com,5976811143227,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3948458,HBVITAimm75,4331307499579,31085430014011,(Vitamin Bundle) Immunity,Default Title,0,1,0,0,0,0,4857645498427,0,0,0,0,,MI,,Clark,6164811698,Petunia Terrace,Nw,De Pere,Wisconsin,United States,49544,\"FRAUD_APPROVED, Subscription, Subscription Recurring Order, sent-to-d365\"\r\n556523,HBUS,Vitamin,asdf.ahlf@gmail.com,5765744361531,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946630,HBVITAwom60,4331304681531,31085420281915,(Vitamin Bundle) Organic Women's Multi,Default Title,0,1,0,0,0,0,4857570000955,0,0,0,0,,IL,,Winter,3863347803,20 Union St,,Warren,Wisconsin,United States,60657,\"FRAUD_APPROVED, Subscription, sent-to-d365, Subscription Recurring Order\"\r\n556524,HBUS,Vitamin,asdf@yahoo.com,3887814443067,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3948556,HBVITAadultstr,4907584782395,33723310178363,(Vitamin Bundle) Adult Stress,Default Title,0,1,0,0,0,0,4857684885563,0,0,0,0,,FL,,Berns,4075493288,Eisenhower Dr,113,Port Jefferson,Michigan,United States,32771,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\"\r\n556525,HBUS,Vitamin,dfgh5@yahoo.com,6074075873339,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945418,HBVitaBundle5,4331298521147,31085392691259,Vitamin Bundle,5,49.99,1,49.99,4.99,1.92,15,4856756699195,0,0,0,0,,WI,,Gantt,9204717027,227 Crooked Oak Rd,,Port Jefferson,New York,United States,54115,\"FRAUD_APPROVED, Subscription First Order, Subscription, sent-to-d365\"\r\n556526,HBUS,Vitamin,gdfg5@gmail.com,5845340389435,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946834,HBVITAHSNC60,7110465355835,41164903678011,\"(Vitamin Bundle) Hair, Skin + Nails\",Default Title,0,1,0,0,0,0,4857578029115,0,0,0,0,,MI,,Johnson,3134422087,227 Eisenhower Dr,,Salt Lake City,New York,United States,48091,\"Subscription Recurring Order, FRAUD_APPROVED, Subscription, sent-to-d365\"\r\n556527,HBUS,Vitamin,ghgh@gf.com,6086223102011,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946172,HBVITAPRENATDHA60,4331306811451,31085428015163,(Vitamin Bundle) Prenatal + DHA,Default Title,0,3,0,0,0,0,4857464619067,0,0,0,0,,NY,,Moody,4153162973,Eisenhower Dr,,De Pere,Utah,United States,11777,\"sent-to-d365, Subscription First Order, Subscription, FRAUD_APPROVED\"\r\n556528,HBUS,Vitamin,sdfg5@fg.com,6086223102011,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946172,HBVitaBundle3,4331298521147,31085392658491,Vitamin Bundle,3,32.99,1,32.99,9.99,0,0,4857464619067,0,0,0,0,,NY,,Clark,4153162973,Petunia Terrace,,De Pere,Wisconsin,United States,11777,\"sent-to-d365, Subscription First Order, Subscription, FRAUD_APPROVED\"\r\n556529,HBUS,Vitamin,sdfgs@gmail.com,5815550279739,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945751,HBVITAwom60,4331304681531,31085420281915,(Vitamin Bundle) Organic Women's Multi,Default Title,0,1,0,0,0,0,4857046335547,0,0,0,0,,UT,,Winter,8016081564,20 Union St,635,Warren,Wisconsin,United States,84108,\"sent-to-d365, FRAUD_APPROVED, Subscription Recurring Order, Subscription\"\r\n556530,HBUS,Vitamin,sdfb@yahoo.com,6074075873339,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945418,HBVITAwom60,4331304681531,31085420281915,(Vitamin Bundle) Organic Women's Multi,Default Title,0,1,0,0,0,0,4856756699195,0,0,0,0,,WI,,Berns,9204717027,Eisenhower Dr,,Port Jefferson,Wisconsin,United States,54115,\"FRAUD_APPROVED, Subscription First Order, Subscription, sent-to-d365\"\r\n556531,HBUS,Vitamin,brookesdfb4colwell@yahoo.com,6074075873339,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945418,HBVITAKIDMOODB60,7012437327931,40867363422267,(Vitamin Bundle) Kid's Mood Boost,Default Title,0,2,0,0,0,0,4856756699195,0,0,0,0,,WI,,Gantt,9204717027,227 Crooked Oak Rd,,Port Jefferson,Michigan,United States,54115,\"FRAUD_APPROVED, Subscription First Order, Subscription, sent-to-d365\"\r\n556532,HBUS,Vitamin,34g@gmail.com,5747147309115,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947011,HBVITAwom60,4331304681531,31085420281915,(Vitamin Bundle) Organic Women's Multi,Default Title,0,2,0,0,0,0,4857585467451,0,0,0,0,,SC,,Johnson,6313126335,227 Eisenhower Dr,,Salt Lake City,New York,United States,29455,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\"\r\n556533,HBUS,Vitamin,dbfd@yahoo.com,6074075873339,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945418,HBVITAkid60,4331303469115,31085415006267,(Vitamin Bundle) Organic Kid's Multi,60-count,0,1,0,0,0,0,4856756699195,0,0,0,0,,WI,,Moody,9204717027,Eisenhower Dr,,De Pere,New York,United States,54115,\"FRAUD_APPROVED, Subscription First Order, Subscription, sent-to-d365\"\r\n556534,HBUS,Vitamin,sdbsdf@gmail.com,5815550279739,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945751,HBVitaBundle3,4331298521147,31085392658491,Vitamin Bundle,3,32.99,1,32.99,9.99,0.99,0,4857046335547,0,0,0,0,,UT,,Clark,8016081564,Petunia Terrace,635,De Pere,Utah,United States,84108,\"sent-to-d365, FRAUD_APPROVED, Subscription Recurring Order, Subscription\"\r\n556535,HBUS,Vitamin,sdfg3@yahoo.com,6074075873339,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945418,HBVITAimm75,4331307499579,31085430014011,(Vitamin Bundle) Immunity,Default Title,0,1,0,0,0,0,4856756699195,0,0,0,0,,WI,,Winter,9204717027,20 Union St,,Warren,Wisconsin,United States,54115,\"FRAUD_APPROVED, Subscription First Order, Subscription, sent-to-d365\"\r\n556536,HBUS,Vitamin,sfg@gmail.com,3058267258939,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3946977,HBVITAsle75,4331307204667,31085429358651,(Vitamin Bundle) Sleep Well,Default Title,0,2,0,0,0,0,4857584156731,0,0,0,0,,PA,,Berns,2012596182,Eisenhower Dr,,Port Jefferson,Wisconsin,United States,18414,\"Subscription, FRAUD_APPROVED, sent-to-d365, Subscription Recurring Order\"\r\n556537,HBUS,Vitamin,sdfg@gmail.com,2971195867195,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945648,HBVITAHSNCol60NV,6830033633339,40285923606587,\"(Vitamin Bundle) Hair, Skin, Nails + Collagen\",Default Title,0,2,0,0,0,0,4856948785211,0,0,0,0,,CA,,Gantt,4044097130,227 Crooked Oak Rd,,Port Jefferson,Wisconsin,United States,91505,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556538,HBUS,Vitamin,vfsw@gmail.com,2971195867195,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945648,HBVITACHL60,6841717850171,40321076789307,(Vitamin Bundle) Organic Chlorophyll,Default Title,0,1,0,0,0,0,4856948785211,0,0,0,0,,CA,,Johnson,4044097130,227 Eisenhower Dr,,Salt Lake City,Michigan,United States,91505,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556539,HBUS,Vitamin,sdfgg@gmail.com,2971195867195,2023-03-30T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3945648,HBVITAimm75,4331307499579,31085430014011,(Vitamin Bundle) Immunity,Default Title,0,2,0,0,0,0,4856948785211,0,0,0,0,,CA,,Moody,4044097130,Eisenhower Dr,,De Pere,New York,United States,91505,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556540,HBUS,Vitamin,sdfgbv@gmail.com,5810735513659,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947165,HBVitaBundle3,4331298521147,31085392658491,Vitamin Bundle,3,32.99,1,32.99,9.99,0,0,4857591169083,0,0,0,0,,IL,,Clark,8159092206,Petunia Terrace,,De Pere,New York,United States,61108,\"Subscription Recurring Order, Subscription, FRAUD_APPROVED, sent-to-d365\"\r\n556541,HBUS,Vitamin,sdvf3@gmail.com,3379719831611,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947396,HBVitaBundle3,4331298521147,31085392658491,Vitamin Bundle,3,32.99,1,32.99,9.99,0,0,4857600639035,0,0,0,0,,NH,,Winter,5174491774,20 Union St,,Warren,Utah,United States,3833,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556542,HBUS,Vitamin,34f@gmail.com,3379719831611,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947396,HBVITAHSNCol60NV,6830033633339,40285923606587,\"(Vitamin Bundle) Hair, Skin, Nails + Collagen\",Default Title,0,1,0,0,0,0,4857600639035,0,0,0,0,,NH,,Berns,5174491774,Eisenhower Dr,,Port Jefferson,Wisconsin,United States,3833,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556543,HBUS,Vitamin,dsfv@gmail.com,3379719831611,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947396,HBVITAmenim60,4331305107515,31085422018619,(Vitamin Bundle) Organic Men's Multi + Super Immune,Default Title,0,1,0,0,0,0,4857600639035,0,0,0,0,,NH,,Gantt,5174491774,227 Crooked Oak Rd,,Port Jefferson,Wisconsin,United States,3833,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556544,HBUS,Vitamin,dsfv35@gmail.com,3379719831611,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947396,HBVITAPRENATDHA60,4331306811451,31085428015163,(Vitamin Bundle) Prenatal + DHA,Default Title,0,1,0,0,0,0,4857600639035,0,0,0,0,,NH,,Johnson,5174491774,227 Eisenhower Dr,,Salt Lake City,Wisconsin,United States,3833,\"FRAUD_APPROVED, Subscription Recurring Order, sent-to-d365, Subscription\"\r\n556545,HBUS,Vitamin,sdfv@gmail.com,5747147309115,2023-03-31T00:00:00.000Z,2023-03-01T00:00:00.000Z,2023-03-31T00:00:00.000Z,3947011,HBVITAACVGW,6785724022843,40072796504123,(Vitamin Bundle) Apple Cider Vinegar,Default Title,0,1,0,0,0,0,4857585467451,0,0,0,0,,SC,,Moody,6313126335,Eisenhower Dr,,De Pere,Michigan,United States,29455,\"Subscription Recurring Order, sent-to-d365, Subscription, FRAUD_APPROVED\""
  },
  {
    "path": "explorer/tests/factories.py",
    "content": "from django.conf import settings\n\nfrom factory import Sequence, SubFactory, LazyFunction\nfrom factory.django import DjangoModelFactory\n\nfrom explorer.models import Query, QueryLog\nfrom explorer.ee.db_connections.utils import default_db_connection_id\n\n\nclass UserFactory(DjangoModelFactory):\n\n    class Meta:\n        model = settings.AUTH_USER_MODEL\n\n    username = Sequence(lambda n: \"User %03d\" % n)\n    is_staff = True\n\n\nclass SimpleQueryFactory(DjangoModelFactory):\n\n    class Meta:\n        model = Query\n\n    title = Sequence(lambda n: f\"My simple query {n}\")\n    sql = \"SELECT 1+1 AS TWO\"  # same result in postgres and sqlite\n    description = \"Doin' math\"\n    created_by_user = SubFactory(UserFactory)\n    database_connection_id = LazyFunction(default_db_connection_id)\n\n\nclass QueryLogFactory(DjangoModelFactory):\n\n    class Meta:\n        model = QueryLog\n\n    sql = \"SELECT 2+2 AS FOUR\"\n    database_connection_id = LazyFunction(default_db_connection_id)\n"
  },
  {
    "path": "explorer/tests/json/github.json",
    "content": "[\n  {\n    \"id\": 6104546,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnk2MTA0NTQ2\",\n    \"name\": \"-REPONAME\",\n    \"full_name\": \"mralexgray/-REPONAME\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/-REPONAME\",\n    \"description\": null,\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/mralexgray/-REPONAME\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/-REPONAME/deployments\",\n    \"created_at\": \"2012-10-06T16:37:39Z\",\n    \"updated_at\": \"2013-01-12T13:39:30Z\",\n    \"pushed_at\": \"2012-10-06T16:37:39Z\",\n    \"git_url\": \"git://github.com/mralexgray/-REPONAME.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/-REPONAME.git\",\n    \"clone_url\": \"https://github.com/mralexgray/-REPONAME.git\",\n    \"svn_url\": \"https://github.com/mralexgray/-REPONAME\",\n    \"homepage\": null,\n    \"size\": 48,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": null,\n    \"has_issues\": true,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 104510411,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMDQ1MTA0MTE=\",\n    \"name\": \"...\",\n    \"full_name\": \"mralexgray/...\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/...\",\n    \"description\": \":computer: Public repo for my personal dotfiles.\",\n    \"fork\": true,\n    \"url\": \"https://api.github.com/repos/mralexgray/...\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/.../forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/.../keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/.../collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/.../teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/.../hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/.../issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/.../events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/.../assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/.../branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/.../tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/.../git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/.../git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/.../git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/.../git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/.../statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/.../languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/.../stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/.../contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/.../subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/.../subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/.../commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/.../git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/.../comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/.../issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/.../contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/.../compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/.../merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/.../{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/.../downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/.../issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/.../pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/.../milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/.../notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/.../labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/.../releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/.../deployments\",\n    \"created_at\": \"2017-09-22T19:19:42Z\",\n    \"updated_at\": \"2017-09-22T19:20:22Z\",\n    \"pushed_at\": \"2017-09-15T08:27:32Z\",\n    \"git_url\": \"git://github.com/mralexgray/....git\",\n    \"ssh_url\": \"git@github.com:mralexgray/....git\",\n    \"clone_url\": \"https://github.com/mralexgray/....git\",\n    \"svn_url\": \"https://github.com/mralexgray/...\",\n    \"homepage\": \"https://driesvints.com/blog/getting-started-with-dotfiles\",\n    \"size\": 113,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Shell\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": false,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"mit\",\n      \"name\": \"MIT License\",\n      \"spdx_id\": \"MIT\",\n      \"url\": \"https://api.github.com/licenses/mit\",\n      \"node_id\": \"MDc6TGljZW5zZTEz\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 58656723,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnk1ODY1NjcyMw==\",\n    \"name\": \"2200087-Serial-Protocol\",\n    \"full_name\": \"mralexgray/2200087-Serial-Protocol\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/2200087-Serial-Protocol\",\n    \"description\": \"A reverse engineered protocol description and accompanying code for Radioshack's 2200087 multimeter\",\n    \"fork\": true,\n    \"url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/2200087-Serial-Protocol/deployments\",\n    \"created_at\": \"2016-05-12T16:05:28Z\",\n    \"updated_at\": \"2016-05-12T16:05:30Z\",\n    \"pushed_at\": \"2016-05-12T16:07:24Z\",\n    \"git_url\": \"git://github.com/mralexgray/2200087-Serial-Protocol.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/2200087-Serial-Protocol.git\",\n    \"clone_url\": \"https://github.com/mralexgray/2200087-Serial-Protocol.git\",\n    \"svn_url\": \"https://github.com/mralexgray/2200087-Serial-Protocol\",\n    \"homepage\": \"http://daviddworken.com\",\n    \"size\": 41,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Python\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 1,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"gpl-2.0\",\n      \"name\": \"GNU General Public License v2.0\",\n      \"spdx_id\": \"GPL-2.0\",\n      \"url\": \"https://api.github.com/licenses/gpl-2.0\",\n      \"node_id\": \"MDc6TGljZW5zZTg=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 1,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 13121042,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMzEyMTA0Mg==\",\n    \"name\": \"ace\",\n    \"full_name\": \"mralexgray/ace\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/ace\",\n    \"description\": \"Ace (Ajax.org Cloud9 Editor)\",\n    \"fork\": true,\n    \"url\": \"https://api.github.com/repos/mralexgray/ace\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/ace/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/ace/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/ace/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/ace/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/ace/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/ace/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/ace/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/ace/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/ace/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/ace/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/ace/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/ace/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/ace/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/ace/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/ace/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/ace/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/ace/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/ace/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/ace/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/ace/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/ace/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/ace/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/ace/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/ace/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/ace/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/ace/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/ace/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/ace/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/ace/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/ace/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/ace/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/ace/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/ace/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/ace/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/ace/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/ace/deployments\",\n    \"created_at\": \"2013-09-26T11:58:10Z\",\n    \"updated_at\": \"2013-10-26T12:34:49Z\",\n    \"pushed_at\": \"2013-10-26T12:34:48Z\",\n    \"git_url\": \"git://github.com/mralexgray/ace.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/ace.git\",\n    \"clone_url\": \"https://github.com/mralexgray/ace.git\",\n    \"svn_url\": \"https://github.com/mralexgray/ace\",\n    \"homepage\": \"http://ace.c9.io\",\n    \"size\": 21080,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"JavaScript\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 1,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"other\",\n      \"name\": \"Other\",\n      \"spdx_id\": \"NOASSERTION\",\n      \"url\": null,\n      \"node_id\": \"MDc6TGljZW5zZTA=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 1,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 10791045,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMDc5MTA0NQ==\",\n    \"name\": \"ACEView\",\n    \"full_name\": \"mralexgray/ACEView\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/ACEView\",\n    \"description\": \"Use the wonderful ACE editor in your Cocoa applications\",\n    \"fork\": true,\n    \"url\": \"https://api.github.com/repos/mralexgray/ACEView\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/ACEView/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/ACEView/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/ACEView/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/ACEView/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/ACEView/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/ACEView/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/ACEView/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/ACEView/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/ACEView/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/ACEView/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/ACEView/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/ACEView/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/ACEView/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/ACEView/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/ACEView/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/ACEView/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/ACEView/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/ACEView/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/ACEView/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/ACEView/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/ACEView/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/ACEView/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/ACEView/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/ACEView/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/ACEView/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/ACEView/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/ACEView/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/ACEView/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/ACEView/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/ACEView/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/ACEView/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/ACEView/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/ACEView/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/ACEView/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/ACEView/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/ACEView/deployments\",\n    \"created_at\": \"2013-06-19T12:15:04Z\",\n    \"updated_at\": \"2015-11-24T01:14:10Z\",\n    \"pushed_at\": \"2014-05-09T01:36:23Z\",\n    \"git_url\": \"git://github.com/mralexgray/ACEView.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/ACEView.git\",\n    \"clone_url\": \"https://github.com/mralexgray/ACEView.git\",\n    \"svn_url\": \"https://github.com/mralexgray/ACEView\",\n    \"homepage\": null,\n    \"size\": 1733,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Objective-C\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 1,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"other\",\n      \"name\": \"Other\",\n      \"spdx_id\": \"NOASSERTION\",\n      \"url\": null,\n      \"node_id\": \"MDc6TGljZW5zZTA=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 1,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 13623648,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnkxMzYyMzY0OA==\",\n    \"name\": \"ActiveLog\",\n    \"full_name\": \"mralexgray/ActiveLog\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/ActiveLog\",\n    \"description\": \"Shut up all logs with active filter.\",\n    \"fork\": true,\n    \"url\": \"https://api.github.com/repos/mralexgray/ActiveLog\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/ActiveLog/deployments\",\n    \"created_at\": \"2013-10-16T15:52:37Z\",\n    \"updated_at\": \"2013-10-16T15:52:37Z\",\n    \"pushed_at\": \"2011-07-03T06:28:59Z\",\n    \"git_url\": \"git://github.com/mralexgray/ActiveLog.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/ActiveLog.git\",\n    \"clone_url\": \"https://github.com/mralexgray/ActiveLog.git\",\n    \"svn_url\": \"https://github.com/mralexgray/ActiveLog\",\n    \"homepage\": \"http://deepitpro.com/en/articles/ActiveLog/info/\",\n    \"size\": 60,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Objective-C\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": true,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 0,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": null,\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 0,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  },\n  {\n    \"id\": 9716210,\n    \"node_id\": \"MDEwOlJlcG9zaXRvcnk5NzE2MjEw\",\n    \"name\": \"adium\",\n    \"full_name\": \"mralexgray/adium\",\n    \"private\": false,\n    \"owner\": {\n      \"login\": \"mralexgray\",\n      \"id\": 262517,\n      \"node_id\": \"MDQ6VXNlcjI2MjUxNw==\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/262517?v=4\",\n      \"gravatar_id\": \"\",\n      \"url\": \"https://api.github.com/users/mralexgray\",\n      \"html_url\": \"https://github.com/mralexgray\",\n      \"followers_url\": \"https://api.github.com/users/mralexgray/followers\",\n      \"following_url\": \"https://api.github.com/users/mralexgray/following{/other_user}\",\n      \"gists_url\": \"https://api.github.com/users/mralexgray/gists{/gist_id}\",\n      \"starred_url\": \"https://api.github.com/users/mralexgray/starred{/owner}{/repo}\",\n      \"subscriptions_url\": \"https://api.github.com/users/mralexgray/subscriptions\",\n      \"organizations_url\": \"https://api.github.com/users/mralexgray/orgs\",\n      \"repos_url\": \"https://api.github.com/users/mralexgray/repos\",\n      \"events_url\": \"https://api.github.com/users/mralexgray/events{/privacy}\",\n      \"received_events_url\": \"https://api.github.com/users/mralexgray/received_events\",\n      \"type\": \"User\",\n      \"site_admin\": false\n    },\n    \"html_url\": \"https://github.com/mralexgray/adium\",\n    \"description\": \"Official mirror of hg.adium.im\",\n    \"fork\": false,\n    \"url\": \"https://api.github.com/repos/mralexgray/adium\",\n    \"forks_url\": \"https://api.github.com/repos/mralexgray/adium/forks\",\n    \"keys_url\": \"https://api.github.com/repos/mralexgray/adium/keys{/key_id}\",\n    \"collaborators_url\": \"https://api.github.com/repos/mralexgray/adium/collaborators{/collaborator}\",\n    \"teams_url\": \"https://api.github.com/repos/mralexgray/adium/teams\",\n    \"hooks_url\": \"https://api.github.com/repos/mralexgray/adium/hooks\",\n    \"issue_events_url\": \"https://api.github.com/repos/mralexgray/adium/issues/events{/number}\",\n    \"events_url\": \"https://api.github.com/repos/mralexgray/adium/events\",\n    \"assignees_url\": \"https://api.github.com/repos/mralexgray/adium/assignees{/user}\",\n    \"branches_url\": \"https://api.github.com/repos/mralexgray/adium/branches{/branch}\",\n    \"tags_url\": \"https://api.github.com/repos/mralexgray/adium/tags\",\n    \"blobs_url\": \"https://api.github.com/repos/mralexgray/adium/git/blobs{/sha}\",\n    \"git_tags_url\": \"https://api.github.com/repos/mralexgray/adium/git/tags{/sha}\",\n    \"git_refs_url\": \"https://api.github.com/repos/mralexgray/adium/git/refs{/sha}\",\n    \"trees_url\": \"https://api.github.com/repos/mralexgray/adium/git/trees{/sha}\",\n    \"statuses_url\": \"https://api.github.com/repos/mralexgray/adium/statuses/{sha}\",\n    \"languages_url\": \"https://api.github.com/repos/mralexgray/adium/languages\",\n    \"stargazers_url\": \"https://api.github.com/repos/mralexgray/adium/stargazers\",\n    \"contributors_url\": \"https://api.github.com/repos/mralexgray/adium/contributors\",\n    \"subscribers_url\": \"https://api.github.com/repos/mralexgray/adium/subscribers\",\n    \"subscription_url\": \"https://api.github.com/repos/mralexgray/adium/subscription\",\n    \"commits_url\": \"https://api.github.com/repos/mralexgray/adium/commits{/sha}\",\n    \"git_commits_url\": \"https://api.github.com/repos/mralexgray/adium/git/commits{/sha}\",\n    \"comments_url\": \"https://api.github.com/repos/mralexgray/adium/comments{/number}\",\n    \"issue_comment_url\": \"https://api.github.com/repos/mralexgray/adium/issues/comments{/number}\",\n    \"contents_url\": \"https://api.github.com/repos/mralexgray/adium/contents/{+path}\",\n    \"compare_url\": \"https://api.github.com/repos/mralexgray/adium/compare/{base}...{head}\",\n    \"merges_url\": \"https://api.github.com/repos/mralexgray/adium/merges\",\n    \"archive_url\": \"https://api.github.com/repos/mralexgray/adium/{archive_format}{/ref}\",\n    \"downloads_url\": \"https://api.github.com/repos/mralexgray/adium/downloads\",\n    \"issues_url\": \"https://api.github.com/repos/mralexgray/adium/issues{/number}\",\n    \"pulls_url\": \"https://api.github.com/repos/mralexgray/adium/pulls{/number}\",\n    \"milestones_url\": \"https://api.github.com/repos/mralexgray/adium/milestones{/number}\",\n    \"notifications_url\": \"https://api.github.com/repos/mralexgray/adium/notifications{?since,all,participating}\",\n    \"labels_url\": \"https://api.github.com/repos/mralexgray/adium/labels{/name}\",\n    \"releases_url\": \"https://api.github.com/repos/mralexgray/adium/releases{/id}\",\n    \"deployments_url\": \"https://api.github.com/repos/mralexgray/adium/deployments\",\n    \"created_at\": \"2013-04-27T14:59:33Z\",\n    \"updated_at\": \"2019-12-11T06:51:45Z\",\n    \"pushed_at\": \"2013-04-26T16:43:53Z\",\n    \"git_url\": \"git://github.com/mralexgray/adium.git\",\n    \"ssh_url\": \"git@github.com:mralexgray/adium.git\",\n    \"clone_url\": \"https://github.com/mralexgray/adium.git\",\n    \"svn_url\": \"https://github.com/mralexgray/adium\",\n    \"homepage\": null,\n    \"size\": 277719,\n    \"stargazers_count\": 0,\n    \"watchers_count\": 0,\n    \"language\": \"Objective-C\",\n    \"has_issues\": false,\n    \"has_projects\": true,\n    \"has_downloads\": true,\n    \"has_wiki\": false,\n    \"has_pages\": false,\n    \"has_discussions\": false,\n    \"forks_count\": 36,\n    \"mirror_url\": null,\n    \"archived\": false,\n    \"disabled\": false,\n    \"open_issues_count\": 0,\n    \"license\": {\n      \"key\": \"other\",\n      \"name\": \"Other\",\n      \"spdx_id\": \"NOASSERTION\",\n      \"url\": null,\n      \"node_id\": \"MDc6TGljZW5zZTA=\"\n    },\n    \"allow_forking\": true,\n    \"is_template\": false,\n    \"web_commit_signoff_required\": false,\n    \"topics\": [\n\n    ],\n    \"visibility\": \"public\",\n    \"forks\": 36,\n    \"open_issues\": 0,\n    \"watchers\": 0,\n    \"default_branch\": \"master\"\n  }\n]\n"
  },
  {
    "path": "explorer/tests/json/kings.json",
    "content": "[\n  {\n    \"Name\": \"Edward the Elder\",\n    \"Country\": \"United Kingdom\",\n    \"House\": \"House of Wessex\",\n    \"Reign\": \"899-925\",\n    \"ID\": 1\n  },\n  {\n    \"Name\": \"Athelstan\",\n    \"Country\": \"United Kingdom\",\n    \"House\": \"House of Wessex\",\n    \"Reign\": \"925-940\",\n    \"ID\": 2\n  },\n  {\n    \"Name\": \"Edmund\",\n    \"Country\": \"United Kingdom\",\n    \"House\": \"House of Wessex\",\n    \"Reign\": \"940-946\",\n    \"ID\": 3\n  },\n  {\n    \"Name\": \"Edred\",\n    \"Country\": \"United Kingdom\",\n    \"House\": \"House of Wessex\",\n    \"Reign\": \"946-955\",\n    \"ID\": 4\n  },\n  {\n    \"Name\": \"Edwy\",\n    \"Country\": \"United Kingdom\",\n    \"House\": \"House of Wessex\",\n    \"Reign\": \"955-959\",\n    \"ID\": 5\n  }\n]\n"
  },
  {
    "path": "explorer/tests/json/list.json",
    "content": "{\"Item\":{\"instanceId\":{\"S\":\"ba4afa8857d1f836701c4ce8e54b95d84a8e3b92ee52292c7e5b2fb6fe6f1a69\"},\"time\":{\"N\":\"1713969118.917831897735595703125\"},\"value\":{\"M\":{\"unique_connection_count\":{\"N\":\"1\"},\"unsafe_rendering\":{\"BOOL\":false},\"total_log_count\":{\"N\":\"24\"},\"total_query_count\":{\"N\":\"1\"},\"debug\":{\"BOOL\":false},\"unique_run_by_user_count\":{\"N\":\"3\"},\"default_database\":{\"S\":\"mysql\"},\"tasks_enabled\":{\"BOOL\":false},\"transform_count\":{\"N\":\"0\"},\"django_install_date\":{\"N\":\"1585856800.150691986083984375\"},\"version\":{\"S\":\"4.1\"},\"assistant_enabled\":{\"BOOL\":false}}},\"name\":{\"S\":\"STARTUP_STATS\"}}}\n{\"Item\":{\"instanceId\":{\"S\":\"ba4afa8857d1f836701c4ce8e54b95d84a8e3b92ee52292c7e5b2fb6fe6f1a69\"},\"time\":{\"N\":\"1713969149.071483612060546875\"},\"value\":{\"M\":{\"unique_connection_count\":{\"N\":\"1\"},\"unsafe_rendering\":{\"BOOL\":false},\"total_log_count\":{\"N\":\"24\"},\"total_query_count\":{\"N\":\"1\"},\"debug\":{\"BOOL\":false},\"unique_run_by_user_count\":{\"N\":\"3\"},\"default_database\":{\"S\":\"mysql\"},\"tasks_enabled\":{\"BOOL\":false},\"transform_count\":{\"N\":\"0\"},\"django_install_date\":{\"N\":\"1585856800.150691986083984375\"},\"version\":{\"S\":\"4.1\"},\"assistant_enabled\":{\"BOOL\":false}}},\"name\":{\"S\":\"STARTUP_STATS\"}}}\n{\"Item\":{\"instanceId\":{\"S\":\"ba4afa8857d1f836701c4ce8e54b95d84a8e3b92ee52292c7e5b2fb6fe6f1a69\"},\"time\":{\"N\":\"1713969303.64731121063232421875\"},\"value\":{\"M\":{\"unique_connection_count\":{\"N\":\"1\"},\"unsafe_rendering\":{\"BOOL\":false},\"total_log_count\":{\"N\":\"24\"},\"total_query_count\":{\"N\":\"1\"},\"debug\":{\"BOOL\":false},\"unique_run_by_user_count\":{\"N\":\"3\"},\"default_database\":{\"S\":\"mysql\"},\"tasks_enabled\":{\"BOOL\":false},\"transform_count\":{\"N\":\"0\"},\"django_install_date\":{\"N\":\"1585856800.150691986083984375\"},\"version\":{\"S\":\"4.1\"},\"assistant_enabled\":{\"BOOL\":false}}},\"name\":{\"S\":\"STARTUP_STATS\"}}}\n{\"Item\":{\"instanceId\":{\"S\":\"67503169-1466-419a-8995-37e28603998c\"},\"time\":{\"N\":\"1717804322.493606090545654296875\"},\"value\":{\"M\":{\"duration\":{\"N\":\"7.7049732208251953125\"},\"sql_len\":{\"N\":\"26\"}}},\"name\":{\"S\":\"QUERY_RUN\"}}}\n{\"Item\":{\"instanceId\":{\"S\":\"67503169-1466-419a-8995-37e28603998c\"},\"time\":{\"N\":\"1717804322.5681588649749755859375\"},\"value\":{\"M\":{\"unique_connection_count\":{\"N\":\"0\"},\"total_query_count\":{\"N\":\"20\"},\"debug\":{\"BOOL\":true},\"unique_run_by_user_count\":{\"N\":\"10\"},\"default_database\":{\"S\":\"postgresql\"},\"tasks_enabled\":{\"BOOL\":false},\"version\":{\"S\":\"4.3\"},\"unsafe_rendering\":{\"BOOL\":false},\"total_log_count\":{\"N\":\"600\"},\"explorer_install_quarter\":{\"S\":\"Q3-2021\"},\"transform_count\":{\"N\":\"0\"},\"charts_enabled\":{\"BOOL\":false},\"assistant_enabled\":{\"BOOL\":false}}},\"name\":{\"S\":\"STARTUP_STATS\"}}}\n"
  },
  {
    "path": "explorer/tests/settings.py",
    "content": "from test_project.settings import *  # noqa\n\nEXPLORER_ENABLE_ANONYMOUS_STATS = False\nEXPLORER_TASKS_ENABLED = True\nEXPLORER_AI_API_KEY = \"foo\"\nCELERY_BROKER_URL = \"redis://localhost:6379/0\"\nCELERY_TASK_ALWAYS_EAGER = True\nTEST_MODE = True\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": \"tst1\",\n        \"TEST\": {\n            \"NAME\": \"tst1\"\n        }\n    },\n    \"alt\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": \"tst2\",\n        \"TEST\": {\n            \"NAME\": \"tst2\"\n        }\n    },\n    \"not_registered\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": \"tst3\",\n        \"TEST\": {\n            \"NAME\": \"tst3\"\n        }\n    }\n}\n\nEXPLORER_CONNECTIONS = {\n    \"SQLite\": \"default\",\n    \"Another\": \"alt\",\n}\n\nclass PrimaryDatabaseRouter:\n    def allow_migrate(self, db, app_label, model_name=None, **hints):\n        if db == \"default\":\n            return None\n        return False\n\nDATABASE_ROUTERS = [\"explorer.tests.settings.PrimaryDatabaseRouter\"]\n"
  },
  {
    "path": "explorer/tests/settings_base.py",
    "content": "from explorer.tests.settings import *  # noqa\n\nEXPLORER_TASKS_ENABLED = False\nEXPLORER_USER_UPLOADS_ENABLED = False\nEXPLORER_CHARTS_ENABLED = False\nEXPLORER_AI_API_KEY = None\n"
  },
  {
    "path": "explorer/tests/test_actions.py",
    "content": "import io\nfrom zipfile import ZipFile\n\nfrom django.test import TestCase\n\nfrom explorer.actions import generate_report_action\nfrom explorer.tests.factories import SimpleQueryFactory\n\n\nclass TestSqlQueryActions(TestCase):\n\n    def test_single_query_is_csv_file(self):\n        expected_csv = \"two\\r\\n2\\r\\n\"\n\n        r = SimpleQueryFactory()\n        fn = generate_report_action()\n        result = fn(None, None, [r, ])\n        self.assertEqual(result.content.lower().decode(\"utf-8-sig\"), expected_csv)\n\n    def test_multiple_queries_are_zip_file(self):\n\n        expected_csv = \"two\\r\\n2\\r\\n\"\n\n        q = SimpleQueryFactory()\n        q2 = SimpleQueryFactory()\n        fn = generate_report_action()\n\n        res = fn(None, None, [q, q2])\n        z = ZipFile(io.BytesIO(res.content))\n        got_csv = z.read(z.namelist()[0])\n\n        self.assertEqual(len(z.namelist()), 2)\n        self.assertEqual(z.namelist()[0], f\"{q.title}.csv\")\n        self.assertEqual(got_csv.lower().decode(\"utf-8-sig\"), expected_csv)\n\n    # if commas are not removed from the filename, then Chrome throws\n    # \"duplicate headers received from server\"\n    def test_packaging_removes_commas_from_file_name(self):\n\n        expected = \"attachment; filename=query for x y.csv\"\n        q = SimpleQueryFactory(title=\"query for x, y\")\n        fn = generate_report_action()\n        res = fn(None, None, [q])\n        self.assertEqual(res[\"Content-Disposition\"], expected)\n"
  },
  {
    "path": "explorer/tests/test_apps.py",
    "content": "from io import StringIO\n\nfrom django.test import TestCase\nfrom django.core.management import call_command\n\n\nclass PendingMigrationsTests(TestCase):\n\n    def test_no_pending_migrations(self):\n        out = StringIO()\n        try:\n            call_command(\n                \"makemigrations\",\n                \"--check\",\n                stdout=out,\n                stderr=StringIO(),\n            )\n        except SystemExit:  # noqa\n            self.fail(\"Pending migrations:\\n\" + out.getvalue())\n"
  },
  {
    "path": "explorer/tests/test_assistant.py",
    "content": "from explorer.tests.factories import SimpleQueryFactory, QueryLogFactory\nfrom unittest.mock import patch, Mock, MagicMock\nimport unittest\nfrom explorer import app_settings\n\nimport json\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom django.urls import reverse\nfrom django.contrib.auth.models import User\nfrom django.db import OperationalError\nfrom explorer.ee.db_connections.utils import default_db_connection\nfrom explorer.ee.db_connections.models import DatabaseConnection\nfrom explorer.assistant.utils import (\n    sample_rows_from_table,\n    ROW_SAMPLE_SIZE,\n    build_prompt,\n    get_relevant_few_shots,\n    get_relevant_annotation,\n    table_schema\n)\n\nfrom explorer.assistant.models import TableDescription\nfrom explorer.models import PromptLog\n\n\ndef conn():\n    return default_db_connection().as_django_connection()\n\n\n@unittest.skipIf(not app_settings.has_assistant(), \"assistant not enabled\")\nclass TestAssistantViews(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.request_data = {\n           \"sql\": \"SELECT * FROM explorer_query\",\n           \"connection_id\": 1,\n           \"assistant_request\": \"Test Request\"\n        }\n\n    @patch(\"explorer.assistant.utils.openai_client\")\n    def test_do_modify_query(self, mocked_openai_client):\n        from explorer.assistant.views import run_assistant\n\n        # create.return_value should match: resp.choices[0].message\n        mocked_openai_client.return_value.chat.completions.create.return_value = Mock(\n            choices=[Mock(message=Mock(content=\"smart computer\"))])\n        resp = run_assistant(self.request_data, None)\n        self.assertEqual(resp, \"smart computer\")\n\n    @patch(\"explorer.assistant.utils.openai_client\")\n    def test_assistant_help(self, mocked_openai_client):\n        mocked_openai_client.return_value.chat.completions.create.return_value = Mock(\n            choices=[Mock(message=Mock(content=\"smart computer\"))])\n        resp = self.client.post(reverse(\"assistant\"),\n                                data=json.dumps(self.request_data),\n                                content_type=\"application/json\")\n        self.assertEqual(json.loads(resp.content)[\"message\"], \"smart computer\")\n\n\n@unittest.skipIf(not app_settings.has_assistant(), \"assistant not enabled\")\nclass TestBuildPrompt(TestCase):\n\n    @patch(\"explorer.models.ExplorerValue.objects.get_item\")\n    def test_build_prompt_with_vendor_only(self, mock_get_item):\n        mock_get_item.return_value.value = \"system prompt\"\n        result = build_prompt(default_db_connection(),\n                              \"Help me with SQL\", [], sql=\"SELECT * FROM table;\")\n        self.assertIn(\"sqlite\", result[\"system\"])\n\n    @patch(\"explorer.assistant.utils.sample_rows_from_table\", return_value=\"sample data\")\n    @patch(\"explorer.assistant.utils.table_schema\", return_value=[])\n    @patch(\"explorer.models.ExplorerValue.objects.get_item\")\n    def test_build_prompt_with_sql_and_annotation(self, mock_get_item, mock_table_schema, mock_sample_rows):\n        mock_get_item.return_value.value = \"system prompt\"\n\n        included_tables = [\"foo\"]\n        td = TableDescription(database_connection=default_db_connection(), table_name=\"foo\", description=\"annotated\")\n        td.save()\n\n        result = build_prompt(default_db_connection(),\n                              \"Help me with SQL\", included_tables, sql=\"SELECT * FROM table;\")\n        self.assertIn(\"Usage Notes:\\nannotated\", result[\"user\"])\n\n    @patch(\"explorer.assistant.utils.sample_rows_from_table\", return_value=\"sample data\")\n    @patch(\"explorer.assistant.utils.table_schema\", return_value=[])\n    @patch(\"explorer.models.ExplorerValue.objects.get_item\")\n    def test_build_prompt_with_few_shot(self, mock_get_item, mock_table_schema, mock_sample_rows):\n        mock_get_item.return_value.value = \"system prompt\"\n\n        included_tables = [\"magic\"]\n        SimpleQueryFactory(title=\"Few shot\", description=\"the quick brown fox\", sql=\"select 'magic value';\",\n                           few_shot=True)\n\n        result = build_prompt(default_db_connection(),\n                              \"Help me with SQL\", included_tables, sql=\"SELECT * FROM table;\")\n        self.assertIn(\"Relevant example queries\", result[\"user\"])\n        self.assertIn(\"magic value\", result[\"user\"])\n\n    @patch(\"explorer.assistant.utils.sample_rows_from_table\", return_value=\"sample data\")\n    @patch(\"explorer.models.ExplorerValue.objects.get_item\")\n    def test_build_prompt_with_sql_and_error(self, mock_get_item, mock_sample_rows):\n        mock_get_item.return_value.value = \"system prompt\"\n\n        included_tables = []\n\n        result = build_prompt(default_db_connection(),\n                              \"Help me with SQL\", included_tables,\n                              \"Syntax error\", \"SELECT * FROM table;\")\n        self.assertIn(\"## Existing User-Written SQL ##\\nSELECT * FROM table;\", result[\"user\"])\n        self.assertIn(\"## Query Error ##\\nSyntax error\\n\", result[\"user\"])\n        self.assertIn(\"## User's Request to Assistant ##\\nHelp me with SQL\", result[\"user\"])\n        self.assertIn(\"system prompt\", result[\"system\"])\n\n    @patch(\"explorer.models.ExplorerValue.objects.get_item\")\n    def test_build_prompt_with_extra_tables_fitting_window(self, mock_get_item):\n        mock_get_item.return_value.value = \"system prompt\"\n\n        included_tables = [\"explorer_query\"]\n        SimpleQueryFactory()\n\n        result = build_prompt(default_db_connection(), \"Help me with SQL\",\n                              included_tables, sql=\"SELECT * FROM table;\")\n        self.assertIn(\"## Information for Table 'explorer_query' ##\", result[\"user\"])\n        self.assertIn(\"Sample rows:\\nid | title\", result[\"user\"])\n\n\n@unittest.skipIf(not app_settings.has_assistant(), \"assistant not enabled\")\nclass TestPromptContext(TestCase):\n\n    def test_retrieves_sample_rows(self):\n        SimpleQueryFactory(title=\"First Query\")\n        SimpleQueryFactory(title=\"Second Query\")\n        SimpleQueryFactory(title=\"Third Query\")\n        SimpleQueryFactory(title=\"Fourth Query\")\n        ret = sample_rows_from_table(conn(), \"explorer_query\")\n        self.assertEqual(len(ret), ROW_SAMPLE_SIZE+1)  # includes header row\n\n    def test_truncates_long_strings(self):\n        c = MagicMock\n        mock_cursor = MagicMock()\n        long_string = \"a\" * 600\n        mock_cursor.description = [(\"col1\",), (\"col2\",)]\n        mock_cursor.fetchall.return_value = [(long_string, \"short string\")]\n        c.cursor = MagicMock()\n        c.cursor.return_value = mock_cursor\n\n        ret = sample_rows_from_table(c, \"some_table\")\n        header, row = ret\n\n        self.assertEqual(header, [\"col1\", \"col2\"])\n        self.assertEqual(row[0], \"a\" * 200 + \"...\")\n        self.assertEqual(row[1], \"short string\")\n\n    def test_binary_data(self):\n        long_binary = b\"a\" * 600\n\n        # Mock database connection and cursor\n        c = MagicMock\n        mock_cursor = MagicMock()\n        mock_cursor.description = [(\"col1\",), (\"col2\",)]\n        mock_cursor.fetchall.return_value = [(long_binary, b\"short binary\")]\n        c.cursor = MagicMock()\n        c.cursor.return_value = mock_cursor\n\n        ret = sample_rows_from_table(c, \"some_table\")\n        header, row = ret\n\n        self.assertEqual(header, [\"col1\", \"col2\"])\n        self.assertEqual(row[0], \"<binary_data>\")\n        self.assertEqual(row[1], \"<binary_data>\")\n\n    def test_handles_various_data_types(self):\n        # Mock database connection and cursor\n        c = MagicMock\n        mock_cursor = MagicMock()\n        mock_cursor.description = [(\"col1\",), (\"col2\",), (\"col3\",)]\n        mock_cursor.fetchall.return_value = [(123, 45.67, \"normal string\")]\n        c.cursor = MagicMock()\n        c.cursor.return_value = mock_cursor\n\n        ret = sample_rows_from_table(c, \"some_table\")\n        header, row = ret\n\n        self.assertEqual(header, [\"col1\", \"col2\", \"col3\"])\n        self.assertEqual(row[0], 123)\n        self.assertEqual(row[1], 45.67)\n        self.assertEqual(row[2], \"normal string\")\n\n    def test_handles_operational_error(self):\n        c = MagicMock\n        mock_cursor = MagicMock()\n        mock_cursor.execute.side_effect = OperationalError(\"Test OperationalError\")\n        c.cursor = MagicMock()\n        c.cursor.return_value = mock_cursor\n\n        ret = sample_rows_from_table(c, \"some_table\")\n\n        self.assertEqual(ret, [[\"Test OperationalError\"]])\n\n    def test_format_rows_from_table(self):\n        from explorer.assistant.utils import format_rows_from_table\n        d = [\n            [\"col1\", \"col2\"],\n            [\"val1\", \"val2\"],\n        ]\n        ret = format_rows_from_table(d)\n        self.assertEqual(ret, \"col1 | col2\\nval1 | val2\")\n\n    def test_schema_info_from_table_names(self):\n        ret = table_schema(default_db_connection(), \"explorer_query\")\n        expected = [\n            (\"id\", \"AutoField\"),\n            (\"title\", \"CharField\"),\n            (\"sql\", \"TextField\"),\n            (\"description\", \"TextField\"),\n            (\"created_at\", \"DateTimeField\"),\n            (\"last_run_date\", \"DateTimeField\"),\n            (\"created_by_user_id\", \"IntegerField\"),\n            (\"snapshot\", \"BooleanField\"),\n            (\"connection\", \"CharField\"),\n            (\"database_connection_id\", \"IntegerField\"),\n            (\"few_shot\", \"BooleanField\")]\n        self.assertEqual(ret, expected)\n\n    def test_schema_info_from_table_names_case_invariant(self):\n        ret = table_schema(default_db_connection(), \"EXPLORER_QUERY\")\n        expected = [\n            (\"id\", \"AutoField\"),\n            (\"title\", \"CharField\"),\n            (\"sql\", \"TextField\"),\n            (\"description\", \"TextField\"),\n            (\"created_at\", \"DateTimeField\"),\n            (\"last_run_date\", \"DateTimeField\"),\n            (\"created_by_user_id\", \"IntegerField\"),\n            (\"snapshot\", \"BooleanField\"),\n            (\"connection\", \"CharField\"),\n            (\"database_connection_id\", \"IntegerField\"),\n            (\"few_shot\", \"BooleanField\")]\n        self.assertEqual(ret, expected)\n\n\n@unittest.skipIf(not app_settings.has_assistant(), \"assistant not enabled\")\nclass TestAssistantUtils(TestCase):\n\n    def test_sample_rows_from_table(self):\n        from explorer.assistant.utils import sample_rows_from_table, format_rows_from_table\n        SimpleQueryFactory(title=\"First Query\")\n        SimpleQueryFactory(title=\"Second Query\")\n        QueryLogFactory()\n        ret = sample_rows_from_table(conn(), \"explorer_query\")\n        self.assertEqual(len(ret), ROW_SAMPLE_SIZE)\n        ret = format_rows_from_table(ret)\n        self.assertTrue(\"First Query\" in ret)\n        self.assertTrue(\"Second Query\" in ret)\n\n    def test_sample_rows_from_tables_no_table_match(self):\n        from explorer.assistant.utils import sample_rows_from_table\n        SimpleQueryFactory(title=\"First Query\")\n        SimpleQueryFactory(title=\"Second Query\")\n        ret = sample_rows_from_table(conn(), \"banana\")\n        self.assertEqual(ret, [[\"no such table: banana\"]])\n\n    def test_relevant_few_shots(self):\n        relevant_q1 = SimpleQueryFactory(sql=\"select * from relevant_table\", few_shot=True)\n        relevant_q2 = SimpleQueryFactory(sql=\"select * from conn.RELEVANT_TABLE limit 10\", few_shot=True)\n        irrelevant_q2 = SimpleQueryFactory(sql=\"select * from conn.RELEVANT_TABLE limit 10\", few_shot=False)\n        relevant_q3 = SimpleQueryFactory(sql=\"select * from conn.another_good_table limit 10\", few_shot=True)\n        irrelevant_q1 = SimpleQueryFactory(sql=\"select * from irrelevant_table\")\n        included_tables = [\"relevant_table\", \"ANOTHER_GOOD_TABLE\"]\n        res = get_relevant_few_shots(relevant_q1.database_connection, included_tables)\n        res_ids = [td.id for td in res]\n        self.assertIn(relevant_q1.id, res_ids)\n        self.assertIn(relevant_q2.id, res_ids)\n        self.assertIn(relevant_q3.id, res_ids)\n        self.assertNotIn(irrelevant_q1.id, res_ids)\n        self.assertNotIn(irrelevant_q2.id, res_ids)\n\n    def test_get_relevant_annotations(self):\n\n        relevant1 = TableDescription(\n            database_connection=default_db_connection(),\n            table_name=\"fruit\"\n        )\n        relevant2 = TableDescription(\n            database_connection=default_db_connection(),\n            table_name=\"Vegetables\"\n        )\n        irrelevant = TableDescription(\n            database_connection=default_db_connection(),\n            table_name=\"animals\"\n        )\n        relevant1.save()\n        relevant2.save()\n        irrelevant.save()\n        res1 = get_relevant_annotation(default_db_connection(), \"Fruit\")\n        self.assertEqual(relevant1.id, res1.id)\n        res2 = get_relevant_annotation(default_db_connection(), \"vegetables\")\n        self.assertEqual(relevant2.id, res2.id)\n\n\nclass TestAssistantHistoryApiView(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_assistant_history_api_view(self):\n        # Create some PromptLogs\n        connection = default_db_connection()\n        PromptLog.objects.create(\n            run_by_user=self.user,\n            database_connection=connection,\n            user_request=\"Test request 1\",\n            response=\"Test response 1\",\n            run_at=timezone.now()\n        )\n        PromptLog.objects.create(\n            run_by_user=self.user,\n            database_connection=connection,\n            user_request=\"Test request 2\",\n            response=\"Test response 2\",\n            run_at=timezone.now()\n        )\n\n        # Make a POST request to the API\n        url = reverse(\"assistant_history\")\n        data = {\n            \"connection_id\": connection.id\n        }\n        response = self.client.post(url, data=json.dumps(data), content_type=\"application/json\")\n\n        # Check the response\n        self.assertEqual(response.status_code, 200)\n        response_data = json.loads(response.content)\n        self.assertIn(\"logs\", response_data)\n        self.assertEqual(len(response_data[\"logs\"]), 2)\n        self.assertEqual(response_data[\"logs\"][0][\"user_request\"], \"Test request 2\")\n        self.assertEqual(response_data[\"logs\"][0][\"response\"], \"Test response 2\")\n        self.assertEqual(response_data[\"logs\"][1][\"user_request\"], \"Test request 1\")\n        self.assertEqual(response_data[\"logs\"][1][\"response\"], \"Test response 1\")\n\n    def test_assistant_history_api_view_invalid_json(self):\n        url = reverse(\"assistant_history\")\n        response = self.client.post(url, data=\"invalid json\", content_type=\"application/json\")\n        self.assertEqual(response.status_code, 400)\n        response_data = json.loads(response.content)\n        self.assertEqual(response_data[\"status\"], \"error\")\n        self.assertEqual(response_data[\"message\"], \"Invalid JSON\")\n\n    def test_assistant_history_api_view_no_logs(self):\n        connection = default_db_connection()\n        url = reverse(\"assistant_history\")\n        data = {\n            \"connection_id\": connection.id\n        }\n        response = self.client.post(url, data=json.dumps(data), content_type=\"application/json\")\n        self.assertEqual(response.status_code, 200)\n        response_data = json.loads(response.content)\n        self.assertIn(\"logs\", response_data)\n        self.assertEqual(len(response_data[\"logs\"]), 0)\n\n    def test_assistant_history_api_view_filtered_results(self):\n        # Create two users\n        user1 = self.user\n        user2 = User.objects.create_superuser(\n            \"admin2\", \"admin2@admin.com\", \"pwd\"\n        )\n\n        # Create two database connections\n        connection1 = default_db_connection()\n        connection2 = DatabaseConnection.objects.create(\n            alias=\"test_connection\",\n            engine=\"django.db.backends.sqlite3\",\n            name=\":memory:\"\n        )\n\n        # Create prompt logs for both users and connections\n        PromptLog.objects.create(\n            run_by_user=user1,\n            database_connection=connection1,\n            user_request=\"User1 Connection1 request\",\n            response=\"User1 Connection1 response\",\n            run_at=timezone.now()\n        )\n        PromptLog.objects.create(\n            run_by_user=user1,\n            database_connection=connection2,\n            user_request=\"User1 Connection2 request\",\n            response=\"User1 Connection2 response\",\n            run_at=timezone.now()\n        )\n        PromptLog.objects.create(\n            run_by_user=user2,\n            database_connection=connection1,\n            user_request=\"User2 Connection1 request\",\n            response=\"User2 Connection1 response\",\n            run_at=timezone.now()\n        )\n\n        # Make a POST request to the API as user1\n        url = reverse(\"assistant_history\")\n        data = {\n            \"connection_id\": connection1.id\n        }\n        response = self.client.post(url, data=json.dumps(data), content_type=\"application/json\")\n\n        # Check the response\n        self.assertEqual(response.status_code, 200)\n        response_data = json.loads(response.content)\n        self.assertIn(\"logs\", response_data)\n        self.assertEqual(len(response_data[\"logs\"]), 1)\n        self.assertEqual(response_data[\"logs\"][0][\"user_request\"], \"User1 Connection1 request\")\n        self.assertEqual(response_data[\"logs\"][0][\"response\"], \"User1 Connection1 response\")\n\n        # Now test with user2\n        self.client.logout()\n        self.client.login(username=\"admin2\", password=\"pwd\")\n\n        response = self.client.post(url, data=json.dumps(data), content_type=\"application/json\")\n\n        # Check the response\n        self.assertEqual(response.status_code, 200)\n        response_data = json.loads(response.content)\n        self.assertIn(\"logs\", response_data)\n        self.assertEqual(len(response_data[\"logs\"]), 1)\n        self.assertEqual(response_data[\"logs\"][0][\"user_request\"], \"User2 Connection1 request\")\n        self.assertEqual(response_data[\"logs\"][0][\"response\"], \"User2 Connection1 response\")\n"
  },
  {
    "path": "explorer/tests/test_create_sqlite.py",
    "content": "from django.test import TestCase\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom unittest import skipIf, mock\nfrom explorer.app_settings import EXPLORER_USER_UPLOADS_ENABLED\nfrom explorer.ee.db_connections.create_sqlite import parse_to_sqlite, get_names\nimport os\nimport sqlite3\n\n\nSQLITE_BYTES = b'SQLite format 3\\x00\\x10\\x00\\x01\\x01\\x00@  \\x00\\x00\\x00\\x02\\x00\\x00\\x00\\x02\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x04\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00.n\\xba\\r\\x00\\x00\\x00\\x01\\x0f\\xb6\\x00\\x0f\\xb6\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00H\\x01\\x06\\x17\\x15\\x15\\x01utabledatadata\\x02CREATE TABLE \"data\" (\\n\"name\" TEXT,\\n  \" title\" TEXT\\n)\\r\\x00\\x00\\x00\\x01\\x0f\\xf3\\x00\\x0f\\xf3\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x0b\\x01\\x03\\x17\\x13chriscto'  # noqa\nPATH = \"./test_parse_to_sqlite.db\"\n\n\ndef write_sqlite_and_get_row(f_bytes, table_name):\n    os.makedirs(os.path.dirname(PATH), exist_ok=True)\n    with open(PATH, \"wb\") as temp_file:\n        temp_file.write(f_bytes.getvalue())\n    conn = sqlite3.connect(PATH)\n    cursor = conn.cursor()\n    cursor.execute(f\"SELECT * FROM {table_name}\")\n    rows = cursor.fetchall()\n    cursor.close()\n    conn.close()\n    os.remove(PATH)\n    return rows\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, reason=\"User uploads disabled\")\nclass TestCreateSqlite(TestCase):\n\n    #def test_parse_to_sqlite_with_sqlite_file\n\n    def test_parse_to_sqlite(self):\n        file = SimpleUploadedFile(\"name.csv\", b\"name, title\\nchris,cto\", content_type=\"text/csv\")\n        sqlite_bytes, name = parse_to_sqlite(file, None, user_id=1)\n        rows = write_sqlite_and_get_row(sqlite_bytes, \"name\")\n\n        self.assertEqual(rows[0], (\"chris\", \"cto\"))\n        self.assertEqual(name, \"name_1.db\")\n\n    def test_parse_to_sqlite_with_no_parser(self):\n        file = SimpleUploadedFile(\"name.db\", SQLITE_BYTES, content_type=\"application/x-sqlite3\")\n        sqlite_bytes, name = parse_to_sqlite(file, None, user_id=1)\n        rows = write_sqlite_and_get_row(sqlite_bytes, \"data\")\n\n        self.assertEqual(rows[0], (\"chris\", \"cto\"))\n        self.assertEqual(name, \"name_1.db\")\n\n\nclass TestGetNames(TestCase):\n    def setUp(self):\n        # Mock file object\n        self.mock_file = mock.MagicMock()\n        self.mock_file.name = \"test file name.txt\"\n\n        # Mock append_conn object\n        self.mock_append_conn = mock.MagicMock()\n        self.mock_append_conn.name = \"/path/to/existing_db.sqlite\"\n\n    def test_no_append_conn(self):\n        table_name, f_name = get_names(self.mock_file, append_conn=None, user_id=123)\n        self.assertEqual(table_name, \"test_file_name\")\n        self.assertEqual(f_name, \"test_file_name_123.db\")\n\n    def test_with_append_conn(self):\n        table_name, f_name = get_names(self.mock_file, append_conn=self.mock_append_conn, user_id=123)\n        self.assertEqual(table_name, \"test_file_name\")\n        self.assertEqual(f_name, \"existing_db.sqlite\")\n\n    def test_secure_filename(self):\n        self.mock_file.name = \"测试文件.txt\"\n        table_name, f_name = get_names(self.mock_file, append_conn=None, user_id=123)\n        self.assertEqual(table_name, \"_\")\n        self.assertEqual(f_name, \"__123.db\")\n\n    def test_empty_filename(self):\n        self.mock_file.name = \".txt\"\n        with self.assertRaises(ValueError):\n            get_names(self.mock_file, append_conn=None, user_id=123)\n\n    def test_invalid_extension(self):\n        self.mock_file.name = \"filename.exe\"\n        with self.assertRaises(ValueError):\n            get_names(self.mock_file, append_conn=None, user_id=123)\n"
  },
  {
    "path": "explorer/tests/test_csrf_cookie_name.py",
    "content": "from django.test import TestCase, override_settings\n\n\ntry:\n    from django.urls import reverse\nexcept ImportError:\n    from django.core.urlresolvers import reverse\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\n\n\nclass TestCsrfCookieName(TestCase):\n    def test_csrf_cookie_name_in_context(self):\n        self.user = User.objects.create_superuser(\"admin\", \"admin@admin-fake.com\", \"pwd\")\n        self.client.login(username=\"admin\", password=\"pwd\")\n        resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertTrue(\"csrf_cookie_name\" in resp.context)\n        self.assertEqual(resp.context[\"csrf_cookie_name\"], settings.CSRF_COOKIE_NAME)\n\n    @override_settings(CSRF_COOKIE_NAME=\"TEST_CSRF_COOKIE_NAME\")\n    def test_custom_csrf_cookie_name(self):\n        self.user = User.objects.create_superuser(\"admin\", \"admin@admin-fake.com\", \"pwd\")\n        self.client.login(username=\"admin\", password=\"pwd\")\n        resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertTrue(\"csrf_cookie_name\" in resp.context)\n        self.assertEqual(resp.context[\"csrf_cookie_name\"], \"TEST_CSRF_COOKIE_NAME\")\n"
  },
  {
    "path": "explorer/tests/test_db_connection_utils.py",
    "content": "from django.test import TestCase\nfrom unittest import skipIf\nfrom explorer.app_settings import EXPLORER_USER_UPLOADS_ENABLED\nif EXPLORER_USER_UPLOADS_ENABLED:\n    import pandas as pd\nimport os\nimport sqlite3\nfrom django.db import DatabaseError\nfrom explorer.models import DatabaseConnection\nfrom unittest.mock import patch, MagicMock\nfrom explorer.ee.db_connections.utils import (\n    pandas_to_sqlite\n)\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, \"User uploads not enabled\")\nclass TestSQLiteConnection(TestCase):\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    def test_get_sqlite_for_connection_downloads_file_if_not_exists(self, mock_get_s3_bucket):\n        mock_s3 = MagicMock()\n        mock_get_s3_bucket.return_value = mock_s3\n\n        conn = DatabaseConnection(\n            name=\"test_db.db\",\n            host=\"s3_bucket/test_db.db\",\n            engine=DatabaseConnection.SQLITE\n        )\n        conn.delete_local_sqlite()\n\n        local_name = conn.local_name\n\n        conn.as_django_connection()\n\n        mock_s3.download_file.assert_called_once_with(\"s3_bucket/test_db.db\", local_name)\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    def test_get_sqlite_for_connection_skips_download_if_exists(self, mock_get_s3_bucket):\n        mock_s3 = MagicMock()\n        mock_get_s3_bucket.return_value = mock_s3\n\n        conn = DatabaseConnection(\n            name=\"test_db.db\",\n            host=\"s3_bucket/test_db.db\",\n            engine=DatabaseConnection.SQLITE\n        )\n        conn.delete_local_sqlite()\n\n        local_name = conn.local_name\n\n        with open(local_name, \"wb\") as file:\n            file.write(b\"\\x00\" * 10)\n\n        conn.update_fingerprint()\n\n        conn.as_django_connection()\n\n        mock_s3.download_file.assert_not_called()\n\n        os.remove(local_name)\n\n\nclass TestDjangoStyleConnection(TestCase):\n\n    @patch(\"explorer.ee.db_connections.models.load_backend\")\n    def test_create_django_style_connection_with_extras(self, mock_load_backend):\n        conn = DatabaseConnection(\n            name=\"test_db\",\n            alias=\"test_db\",\n            engine=\"django.db.backends.postgresql\",\n            extras='{\"sslmode\": \"require\", \"connect_timeout\": 10}'\n        )\n\n        mock_backend = MagicMock()\n        mock_load_backend.return_value = mock_backend\n\n        conn.as_django_connection()\n\n        mock_load_backend.assert_called_once_with(\"django.db.backends.postgresql\")\n        mock_backend.DatabaseWrapper.assert_called_once()\n        args, kwargs = mock_backend.DatabaseWrapper.call_args\n        self.assertEqual(args[0][\"sslmode\"], \"require\")\n        self.assertEqual(args[0][\"connect_timeout\"], 10)\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, \"User uploads not enabled\")\nclass TestPandasToSQLite(TestCase):\n\n    def test_pandas_to_sqlite(self):\n        # Create a sample DataFrame\n        data = {\n            \"column1\": [1, 2, 3],\n            \"column2\": [\"A\", \"B\", \"C\"]\n        }\n        df = pd.DataFrame(data)\n\n        # Convert the DataFrame to SQLite and get the BytesIO buffer\n        db_buffer = pandas_to_sqlite(df, \"data\", \"test_pandas_to_sqlite.db\")\n\n        # Write the buffer to a temporary file to simulate reading it back\n        temp_db_path = \"temp_test_database.db\"\n        with open(temp_db_path, \"wb\") as f:\n            f.write(db_buffer.getbuffer())\n\n        # Connect to the SQLite database and verify its content\n        con = sqlite3.connect(temp_db_path)\n        try:\n            cursor = con.cursor()\n            cursor.execute(\"SELECT * FROM data\")  # noqa\n            rows = cursor.fetchall()\n\n            # Verify the content of the SQLite database\n            self.assertEqual(len(rows), 3)\n            self.assertEqual(rows[0], (1, \"A\"))\n            self.assertEqual(rows[1], (2, \"B\"))\n            self.assertEqual(rows[2], (3, \"C\"))\n        finally:\n            con.close()\n            os.remove(temp_db_path)\n\n    def test_cant_create_connection_for_unregistered_django_alias(self):\n        conn = DatabaseConnection(alias=\"not_registered\", engine=DatabaseConnection.DJANGO)\n        conn.save()\n        self.assertRaises(DatabaseError, conn.as_django_connection)\n"
  },
  {
    "path": "explorer/tests/test_exporters.py",
    "content": "import json\nimport unittest\nfrom datetime import date, datetime\n\nfrom django.core.serializers.json import DjangoJSONEncoder\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom explorer.exporters import CSVExporter, ExcelExporter, JSONExporter\nfrom explorer.models import QueryResult\nfrom explorer.tests.factories import SimpleQueryFactory\nfrom explorer.utils import is_xls_writer_available\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\nclass TestCsv(TestCase):\n\n    def test_writing_unicode(self):\n        res = QueryResult(\n            SimpleQueryFactory(sql='select 1 as \"a\", 2 as \"\"').sql,\n            default_db_connection().as_django_connection()\n        )\n        res.execute_query()\n        res.process()\n        res._data = [[1, None], [\"Jenét\", \"1\"]]\n\n        res = CSVExporter(query=None)._get_output(res).getvalue()\n        self.assertEqual(\n            res.encode(\"utf-8\").decode(\"utf-8-sig\"),\n            \"a,\\r\\n1,\\r\\nJenét,1\\r\\n\"\n        )\n\n    def test_custom_delimiter(self):\n        q = SimpleQueryFactory(sql=\"select 1, 2\")\n        exporter = CSVExporter(query=q)\n        res = exporter.get_output(delim=\"|\")\n        self.assertEqual(\n            res.encode(\"utf-8\").decode(\"utf-8-sig\"),\n            \"1|2\\r\\n1|2\\r\\n\"\n        )\n\n    def test_writing_bom(self):\n        q = SimpleQueryFactory(sql=\"select 1, 2\")\n        exporter = CSVExporter(query=q)\n        res = exporter.get_output()\n        self.assertEqual(res, \"\\ufeff1,2\\r\\n1,2\\r\\n\")\n\n\nclass TestJson(TestCase):\n\n    def test_writing_json(self):\n        res = QueryResult(\n            SimpleQueryFactory(sql='select 1 as \"a\", 2 as \"\"').sql,\n            default_db_connection().as_django_connection()\n        )\n        res.execute_query()\n        res.process()\n        res._data = [[1, None], [\"Jenét\", \"1\"]]\n\n        res = JSONExporter(query=None)._get_output(res).getvalue()\n        expected = [{\"a\": 1, \"\": None}, {\"a\": \"Jenét\", \"\": \"1\"}]\n        self.assertEqual(res, json.dumps(expected))\n\n    def test_writing_datetimes(self):\n        res = QueryResult(\n            SimpleQueryFactory(sql='select 1 as \"a\", 2 as \"b\"').sql,\n            default_db_connection().as_django_connection()\n        )\n        res.execute_query()\n        res.process()\n        res._data = [[1, date.today()]]\n\n        res = JSONExporter(query=None)._get_output(res).getvalue()\n        expected = [{\"a\": 1, \"b\": date.today()}]\n        self.assertEqual(res, json.dumps(expected, cls=DjangoJSONEncoder))\n\n\nclass TestExcel(TestCase):\n\n    @unittest.skipIf(not is_xls_writer_available(), \"excel exporter not available\")\n    def test_writing_excel(self):\n        \"\"\"\n        This is a pretty crap test. It at least exercises the code.\n        If anyone wants to go through the brain damage of actually building\n        an 'expected' xlsx output and comparing it\n        (https://github.com/jmcnamara/XlsxWriter/blob/master/xlsxwriter/\n        test/helperfunctions.py)\n        by all means submit a pull request!\n        \"\"\"\n        res = QueryResult(\n            SimpleQueryFactory(\n                sql='select 1 as \"a\", 2 as \"\"',\n                title=\"\\\\/*[]:?this title is longer than 32 characters\"\n            ).sql,\n            default_db_connection().as_django_connection()\n        )\n\n        res.execute_query()\n        res.process()\n\n        d = datetime.now()\n        d = timezone.make_aware(d, timezone.get_current_timezone())\n\n        res._data = [[1, None], [\"Jenét\", d]]\n\n        res = ExcelExporter(\n            query=SimpleQueryFactory()\n        )._get_output(res).getvalue()\n\n        expected = b\"PK\"\n\n        self.assertEqual(res[:2], expected)\n\n    @unittest.skipIf(not is_xls_writer_available(), \"excel exporter not available\")\n    def test_writing_dict_fields(self):\n        res = QueryResult(\n            SimpleQueryFactory(\n                sql='select 1 as \"a\", 2 as \"\"',\n                title=\"\\\\/*[]:?this title is longer than 32 characters\"\n            ).sql,\n            default_db_connection().as_django_connection()\n        )\n\n        res.execute_query()\n        res.process()\n\n        res._data = [[1, [\"foo\", \"bar\"]], [2, {\"foo\": \"bar\"}]]\n\n        res = ExcelExporter(\n            query=SimpleQueryFactory()\n        )._get_output(res).getvalue()\n\n        expected = b\"PK\"\n\n        self.assertEqual(res[:2], expected)\n"
  },
  {
    "path": "explorer/tests/test_forms.py",
    "content": "from django.db.utils import IntegrityError\nfrom django.forms.models import model_to_dict\nfrom django.test import TestCase\nfrom unittest.mock import patch, MagicMock\n\nfrom explorer.forms import QueryForm\nfrom explorer.tests.factories import SimpleQueryFactory\nfrom explorer.ee.db_connections.utils import default_db_connection_id\n\n\nclass TestFormValidation(TestCase):\n\n    def test_form_is_valid_with_valid_sql(self):\n        q = SimpleQueryFactory(sql=\"select 1;\", created_by_user_id=None)\n        form = QueryForm(model_to_dict(q))\n        self.assertTrue(form.is_valid())\n\n    def test_form_fails_null(self):\n        with self.assertRaises(IntegrityError):\n            SimpleQueryFactory(sql=None, created_by_user_id=None)\n\n    def test_form_fails_blank(self):\n        q = SimpleQueryFactory(sql=\"\", created_by_user_id=None)\n        q.params = {}\n        form = QueryForm(model_to_dict(q))\n        self.assertFalse(form.is_valid())\n\n    def test_form_fails_blacklist(self):\n        q = SimpleQueryFactory(sql=\"delete $$a$$;\", created_by_user_id=None)\n        q.params = {}\n        form = QueryForm(model_to_dict(q))\n        self.assertFalse(form.is_valid())\n\n\nclass QueryFormTestCase(TestCase):\n\n    def test_valid_form_submission(self):\n        form_data = {\n            \"title\": \"Test Query\",\n            \"sql\": \"SELECT * FROM table\",\n            \"description\": \"A test query description\",\n            \"snapshot\": False,\n            \"database_connection\": str(default_db_connection_id()),\n        }\n\n        form = QueryForm(data=form_data)\n        self.assertTrue(form.is_valid(), msg=form.errors)\n        query = form.save()\n\n        # Verify that the Query instance was created and is correctly linked to the DatabaseConnection\n        self.assertEqual(query.database_connection_id, default_db_connection_id())\n        self.assertEqual(query.title, form_data[\"title\"])\n        self.assertEqual(query.sql, form_data[\"sql\"])\n\n    @patch(\"explorer.forms.default_db_connection\")\n    def test_default_connection_first(self, mocked_default_db_connection):\n        dbc = MagicMock()\n        dbc.id = default_db_connection_id()\n        mocked_default_db_connection.return_value = dbc\n        self.assertEqual(default_db_connection_id(), QueryForm().connections[0][0])\n\n        dbc = MagicMock()\n        dbc.id = 2\n        mocked_default_db_connection.return_value = dbc\n        self.assertEqual(2, QueryForm().connections[0][0])\n"
  },
  {
    "path": "explorer/tests/test_mime.py",
    "content": "from django.test import TestCase\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom explorer.ee.db_connections.mime import is_sqlite, is_json, is_json_list, is_csv\nimport io\nimport sqlite3\nimport os\n\n\nclass TestIsCsvFunction(TestCase):\n\n    def test_is_csv_with_csv_file(self):\n        # Create a SimpleUploadedFile with content_type set to \"text/csv\"\n        csv_file = SimpleUploadedFile(\"test.csv\", b\"column1,column2\\n1,A\\n2,B\", content_type=\"text/csv\")\n        self.assertTrue(is_csv(csv_file))\n\n    def test_is_csv_with_non_csv_file(self):\n        # Create a SimpleUploadedFile with content_type set to \"text/plain\"\n        txt_file = SimpleUploadedFile(\"test.txt\", b\"Just some text\", content_type=\"text/plain\")\n        self.assertFalse(is_csv(txt_file))\n\n    def test_is_csv_with_empty_content_type(self):\n        # Create a SimpleUploadedFile with an empty content_type\n        empty_file = SimpleUploadedFile(\"test.csv\", b\"column1,column2\\n1,A\\n2,B\", content_type=\"\")\n        self.assertFalse(is_csv(empty_file))\n\n\nclass TestIsJsonFunction(TestCase):\n\n    def test_is_json_with_valid_json(self):\n        long_json = '{\"key1\": \"value1\", \"key2\": {\"subkey1\": \"subvalue1\", \"subkey2\": \"subvalue2\"}, \"key3\": [1, 2, 3, 4]}'  #  noqa\n        json_file = SimpleUploadedFile(\"test.json\", long_json.encode(\"utf-8\"), content_type=\"application/json\")\n        self.assertTrue(is_json(json_file))\n\n    def test_is_json_with_non_json_file(self):\n        txt_file = SimpleUploadedFile(\"test.txt\", b\"Just some text\", content_type=\"text/plain\")\n        self.assertFalse(is_json(txt_file))\n\n    def test_is_json_with_wrong_extension(self):\n        long_json = '{\"key1\": \"value1\", \"key2\": {\"subkey1\": \"subvalue1\", \"subkey2\": \"subvalue2\"}, \"key3\": [1, 2, 3, 4]}'  #  noqa\n        json_file = SimpleUploadedFile(\"test.txt\", long_json.encode(\"utf-8\"), content_type=\"application/json\")\n        self.assertFalse(is_json(json_file))\n\n    def test_is_json_with_empty_content_type(self):\n        long_json = '{\"key1\": \"value1\", \"key2\": {\"subkey1\": \"subvalue1\", \"subkey2\": \"subvalue2\"}, \"key3\": [1, 2, 3, 4]}'  #  noqa\n        json_file = SimpleUploadedFile(\"test.json\", long_json.encode(\"utf-8\"), content_type=\"\")\n        self.assertFalse(is_json(json_file))\n\n\nclass TestIsJsonListFunction(TestCase):\n\n    def test_is_json_list_with_valid_json_lines(self):\n        json_lines = b'{\"key1\": \"value1\"}\\n{\"key2\": \"value2\"}\\n{\"key3\": {\"subkey1\": \"subvalue1\"}}\\n'  #  noqa\n        json_file = SimpleUploadedFile(\"test.json\", json_lines, content_type=\"application/json\")\n        self.assertTrue(is_json_list(json_file))\n\n    def test_is_json_list_with_multiline_json(self):\n        json_lines = b'{\"key1\":\\n\"value1\"}\\n{\"key2\": \"value2\"}\\n{\"key3\": {\"subkey1\": \"subvalue1\"}}\\n'  #  noqa\n        json_file = SimpleUploadedFile(\"test.json\", json_lines, content_type=\"application/json\")\n        self.assertFalse(is_json_list(json_file))\n\n    def test_is_json_list_with_non_json_file(self):\n        txt_file = SimpleUploadedFile(\"test.txt\", b\"Just some text\", content_type=\"text/plain\")\n        self.assertFalse(is_json_list(txt_file))\n\n    def test_is_json_list_with_invalid_json_lines(self):\n        # This is actually going to *pass* the check, because it's a shallow file-type check, not a comprehensive\n        # one. That's ok! This type of error will get caught later, when pandas tries to parse it\n        invalid_json_lines = b'{\"key1\": \"value1\"}\\nNot a JSON content\\n{\"key3\": {\"subkey1\": \"subvalue1\"}}\\n'  #  noqa\n        json_file = SimpleUploadedFile(\"test.json\", invalid_json_lines, content_type=\"application/json\")\n        self.assertTrue(is_json_list(json_file))\n\n    def test_is_json_list_with_wrong_extension(self):\n        json_lines = b'{\"key1\": \"value1\"}\\n{\"key2\": \"value2\"}\\n{\"key3\": {\"subkey1\": \"subvalue1\"}}\\n'  #  noqa\n        json_file = SimpleUploadedFile(\"test.txt\", json_lines, content_type=\"application/json\")\n        self.assertFalse(is_json_list(json_file))\n\n    def test_is_json_list_with_empty_file(self):\n        json_file = SimpleUploadedFile(\"test.json\", b\"\", content_type=\"application/json\")\n        self.assertFalse(is_json_list(json_file))\n\n\nclass IsSqliteTestCase(TestCase):\n    def setUp(self):\n        # Create a SQLite database in a local file and read it into a BytesIO object\n        # It would be nice to do this in memory, but that is not possible.\n        local_path = \"local_database.db\"\n        try:\n            os.remove(local_path)\n        except Exception as e:  # noqa\n            pass\n        conn = sqlite3.connect(local_path)\n        conn.execute(\"CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)\")\n        for i in range(5):\n            conn.execute(\"INSERT INTO test (name) VALUES (?)\", (f\"name_{i}\",))\n        conn.commit()\n        conn.close()\n\n        # Read the local SQLite database file into a BytesIO buffer\n        self.sqlite_db = io.BytesIO()\n        with open(local_path, \"rb\") as f:\n            self.sqlite_db.write(f.read())\n        self.sqlite_db.seek(0)\n\n        # Clean up the local file\n        os.remove(local_path)\n\n    def test_is_sqlite_with_valid_sqlite_file(self):\n        valid_sqlite_file = SimpleUploadedFile(\"test.sqlite\", self.sqlite_db.read(),\n                                               content_type=\"application/x-sqlite3\")\n        self.assertTrue(is_sqlite(valid_sqlite_file))\n\n    def test_is_sqlite_with_invalid_sqlite_file_content_type(self):\n        self.sqlite_db.seek(0)\n        invalid_content_type_file = SimpleUploadedFile(\"test.sqlite\", self.sqlite_db.read(), content_type=\"text/plain\")\n        self.assertFalse(is_sqlite(invalid_content_type_file))\n\n    def test_is_sqlite_with_invalid_sqlite_file_header(self):\n        invalid_sqlite_header = b\"Invalid header\" + b\"\\x00\" * 100\n        invalid_sqlite_file = SimpleUploadedFile(\"test.sqlite\", invalid_sqlite_header,\n                                                 content_type=\"application/x-sqlite3\")\n        self.assertFalse(is_sqlite(invalid_sqlite_file))\n\n    def test_is_sqlite_with_exception_handling(self):\n        class FaultyFile:\n            content_type = \"application/x-sqlite3\"\n\n            def seek(self, offset):\n                pass\n\n            def read(self, num_bytes):\n                raise OSError(\"Unable to read file\")\n\n        faulty_file = FaultyFile()\n        self.assertFalse(is_sqlite(faulty_file))\n"
  },
  {
    "path": "explorer/tests/test_models.py",
    "content": "import unittest\nimport os\nfrom unittest.mock import Mock, patch, MagicMock\n\nfrom django.core.exceptions import ValidationError\nfrom django.db import IntegrityError\nfrom django.test import TestCase\n\nfrom explorer import app_settings\nfrom explorer.models import ColumnHeader, ColumnSummary, Query, QueryLog, QueryResult, DatabaseConnection\nfrom explorer.tests.factories import SimpleQueryFactory\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\nclass TestQueryModel(TestCase):\n\n    def test_params_get_merged(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo$$';\")\n        q.params = {\"foo\": \"bar\", \"mux\": \"qux\"}\n        self.assertEqual(q.available_params(), {\"foo\": \"bar\"})\n\n    def test_default_params_used(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo:bar$$';\")\n        self.assertEqual(q.available_params(), {\"foo\": \"bar\"})\n\n    def test_default_params_used_even_with_labels(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo|label:bar$$';\")\n        self.assertEqual(q.available_params(), {\"foo\": \"bar\"})\n\n    def test_default_params_and_labels(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo|Label:bar$$';\")\n        self.assertEqual(q.available_params_w_labels(), {\"foo\": {\"label\": \"Label\", \"val\": \"bar\"}})\n\n    def test_query_log(self):\n        self.assertEqual(0, QueryLog.objects.count())\n        q = SimpleQueryFactory()\n        q.log(None)\n        self.assertEqual(1, QueryLog.objects.count())\n        log = QueryLog.objects.first()\n        self.assertEqual(log.run_by_user, None)\n        self.assertEqual(log.query, q)\n        self.assertFalse(log.is_playground)\n        self.assertEqual(log.database_connection, q.database_connection)\n\n    def test_query_logs_final_sql(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo$$';\")\n        q.params = {\"foo\": \"bar\"}\n        q.log(None)\n        self.assertEqual(1, QueryLog.objects.count())\n        log = QueryLog.objects.first()\n        self.assertEqual(log.sql, \"select 'bar';\")\n\n    def test_playground_query_log(self):\n        query = Query(sql=\"select 1;\", title=\"Playground\")\n        query.log(None)\n        log = QueryLog.objects.first()\n        self.assertTrue(log.is_playground)\n\n    def test_shared(self):\n        q = SimpleQueryFactory()\n        q2 = SimpleQueryFactory()\n        with self.settings(EXPLORER_USER_QUERY_VIEWS={\"foo\": [q.id]}):\n            self.assertTrue(q.shared)\n            self.assertFalse(q2.shared)\n\n    def test_get_run_count(self):\n        q = SimpleQueryFactory()\n        self.assertEqual(q.get_run_count(), 0)\n        expected = 4\n        for _ in range(0, expected):\n            q.log()\n        self.assertEqual(q.get_run_count(), expected)\n\n    def test_avg_duration(self):\n        q = SimpleQueryFactory()\n        self.assertIsNone(q.avg_duration())\n        expected = 2.5\n        ql = q.log()\n        ql.duration = 2\n        ql.save()\n        ql = q.log()\n        ql.duration = 3\n        ql.save()\n        self.assertEqual(q.avg_duration(), expected)\n\n    def test_log_saves_duration(self):\n        q = SimpleQueryFactory()\n        res, ql = q.execute_with_logging(None)\n        log = QueryLog.objects.first()\n        self.assertEqual(log.duration, res.duration)\n        self.assertTrue(log.success)\n        self.assertIsNone(log.error)\n\n    def test_log_saves_errors(self):\n        q = SimpleQueryFactory()\n        q.sql = \"select wildly invalid query\"\n        q.save()\n        try:\n            q.execute_with_logging(None)\n        except Exception:\n            pass\n        log = QueryLog.objects.first()\n        self.assertFalse(log.success)\n        self.assertIsNotNone(log.error)\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    @patch(\"explorer.models.s3_url\")\n    @patch(\"explorer.models.get_s3_bucket\")\n    def test_get_snapshots_sorts_snaps(self, mocked_get_s3_bucket, mocked_s3_url):\n        bucket = Mock()\n        bucket.objects.filter = Mock()\n        k1 = Mock()\n        k1.key = \"foo\"\n        k1.last_modified = \"b\"\n        k2 = Mock()\n        k2.key = \"bar\"\n        k2.last_modified = \"a\"\n        bucket.objects.filter.return_value = [k1, k2]\n        mocked_get_s3_bucket.return_value = bucket\n        mocked_s3_url.return_value = \"http://s3.com/presigned_url\"\n        q = SimpleQueryFactory()\n        snaps = q.snapshots\n        self.assertEqual(bucket.objects.filter.call_count, 1)\n        self.assertEqual(snaps[0].url, \"http://s3.com/presigned_url\")\n        bucket.objects.filter.assert_called_once_with(Prefix=f\"query-{q.id}/snap-\")\n\n    def test_final_sql_uses_merged_params(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo:bar$$', '$$qux$$';\")\n        q.params = {\"qux\": \"mux\"}\n        expected = \"select 'bar', 'mux';\"\n        self.assertEqual(q.final_sql(), expected)\n\n    def test_final_sql_fails_blacklist_with_bad_param(self):\n        q = SimpleQueryFactory(sql=\"$$command$$ from bar;\")\n        q.params = {\"command\": \"delete\"}\n        expected = \"delete from bar;\"\n        self.assertEqual(q.final_sql(), expected)\n        with self.assertRaises(ValidationError):\n            q.execute_query_only()\n\n    def test_query_will_execute_with_null_database_connection(self):\n        q = SimpleQueryFactory(sql=\"select 1;\")\n        q.database_connection_id = None\n        q.save()\n        q.refresh_from_db()\n        qr = q.execute_query_only()\n        self.assertEqual(qr.data[0], [1])\n\n\nclass TestQueryResults(TestCase):\n\n    def setUp(self):\n        conn = default_db_connection().as_django_connection()\n        self.qr = QueryResult('select 1 as \"foo\", \"qux\" as \"mux\";', conn)\n\n    def test_column_access(self):\n        self.qr._data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]\n        self.assertEqual(self.qr.column(1), [2, 5, 8])\n\n    def test_headers(self):\n        self.assertEqual(str(self.qr.headers[0]), \"foo\")\n        self.assertEqual(str(self.qr.headers[1]), \"mux\")\n\n    def test_data(self):\n        self.assertEqual(self.qr.data, [[1, \"qux\"]])\n\n    def test_unicode_with_nulls(self):\n        self.qr._headers = [ColumnHeader(\"num\"), ColumnHeader(\"char\")]\n        self.qr._description = [(\"num\",), (\"char\",)]\n        self.qr._data = [[2, \"a\"], [3, None]]\n        self.qr.process()\n        self.assertEqual(self.qr.data, [[2, \"a\"], [3, None]])\n\n    def test_summary_gets_built(self):\n        self.qr.process()\n        self.assertEqual(len([h for h in self.qr.headers if h.summary]), 1)\n        self.assertEqual(str(self.qr.headers[0].summary), \"foo\")\n        self.assertEqual(self.qr.headers[0].summary.stats[\"Sum\"], 1.0)\n\n    def test_summary_gets_built_for_multiple_cols(self):\n        self.qr._headers = [ColumnHeader(\"a\"), ColumnHeader(\"b\")]\n        self.qr._description = [(\"a\",), (\"b\",)]\n        self.qr._data = [[1, 10], [2, 20]]\n        self.qr.process()\n        self.assertEqual(len([h for h in self.qr.headers if h.summary]), 2)\n        self.assertEqual(self.qr.headers[0].summary.stats[\"Sum\"], 3.0)\n        self.assertEqual(self.qr.headers[1].summary.stats[\"Sum\"], 30.0)\n\n    def test_numeric_detection(self):\n        self.assertEqual(self.qr._get_numerics(), [0])\n\n    def test_transforms_are_identified(self):\n        self.qr._headers = [ColumnHeader(\"foo\")]\n        got = self.qr._get_transforms()\n        self.assertEqual([(0, '<a href=\"{0}\">{0}</a>')], got)\n\n    def test_transform_alters_row(self):\n        self.qr._headers = [ColumnHeader(\"foo\"), ColumnHeader(\"qux\")]\n        self.qr._data = [[1, 2]]\n        self.qr.process()\n        self.assertEqual(['<a href=\"1\">1</a>', 2], self.qr._data[0])\n\n    def test_multiple_transforms(self):\n        self.qr._headers = [ColumnHeader(\"foo\"), ColumnHeader(\"bar\")]\n        self.qr._data = [[1, 2]]\n        self.qr.process()\n        self.assertEqual(['<a href=\"1\">1</a>', \"x: 2\"], self.qr._data[0])\n\n    def test_get_headers_no_results(self):\n        self.qr._description = None\n        self.assertEqual([ColumnHeader(\"--\")][0].title, self.qr._get_headers()[0].title)\n\n\nclass TestColumnSummary(TestCase):\n\n    def test_executes(self):\n        res = ColumnSummary(\"foo\", [1, 2, 3])\n        self.assertEqual(res.stats, {\"Min\": 1, \"Max\": 3, \"Avg\": 2, \"Sum\": 6, \"NUL\": 0})\n\n    def test_handles_null_as_zero(self):\n        res = ColumnSummary(\"foo\", [1, None, 5])\n        self.assertEqual(res.stats, {\"Min\": 0, \"Max\": 5, \"Avg\": 2, \"Sum\": 6,  \"NUL\": 1})\n\n    def test_empty_data(self):\n        res = ColumnSummary(\"foo\", [])\n        self.assertEqual(res.stats, {\"Min\": 0, \"Max\": 0, \"Avg\": 0, \"Sum\": 0,  \"NUL\": 0})\n\n\nclass TestDatabaseConnection(TestCase):\n\n    def test_cant_create_a_connection_with_conflicting_name(self):\n        thrown = False\n        try:\n            conn = DatabaseConnection(alias=\"default\")\n            conn.save()\n        except IntegrityError:\n            thrown = True\n        self.assertTrue(thrown)\n\n    @patch(\"os.makedirs\")\n    @patch(\"os.path.exists\", return_value=False)\n    @patch(\"os.getcwd\", return_value=\"/mocked/path\")\n    def test_local_name_calls_user_dbs_local_dir(self, mock_getcwd, mock_exists, mock_makedirs):\n        connection = DatabaseConnection(\n            alias=\"test\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test_db.sqlite3\",\n            host=\"some-s3-bucket\",\n        )\n\n        local_name = connection.local_name\n        expected_path = \"/mocked/path/user_dbs/test_db.sqlite3\"\n\n        # Check if the local_name property returns the correct path\n        self.assertEqual(local_name, expected_path)\n\n        # Ensure os.makedirs was called once since the directory does not exist\n        mock_makedirs.assert_called_once_with(\"/mocked/path/user_dbs\")\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    @patch(\"explorer.ee.db_connections.models.cache\")\n    def test_single_download_triggered(self, mock_cache, mock_get_s3_bucket):\n        # Setup mocks\n        mock_cache.add.return_value = True  # Simulate acquiring the lock\n        mock_s3 = MagicMock()\n        mock_get_s3_bucket.return_value = mock_s3\n\n        # Call the method\n        instance = DatabaseConnection(\n            alias=\"test\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test_db.sqlite3\",\n            host=\"some-s3-bucket\",\n            id=123\n        )\n        instance.download_sqlite_if_needed()\n\n        # Assertions\n        mock_s3.download_file.assert_called_once()\n        mock_cache.add.assert_called_once()\n        mock_cache.delete.assert_called_once()\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    @patch(\"explorer.ee.db_connections.models.cache\")\n    def test_skip_download_when_locked(self, mock_cache, mock_get_s3_bucket):\n        # Setup mocks\n        mock_cache.add.return_value = False  # Simulate that another process has the lock\n        mock_s3 = MagicMock()\n        mock_get_s3_bucket.return_value = mock_s3\n\n        # Call the method\n        instance = DatabaseConnection(\n            alias=\"test\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test_db.sqlite3\",\n            host=\"some-s3-bucket\",\n            id=123\n        )\n        instance.download_sqlite_if_needed()\n\n        # Assertions\n        mock_s3.download_file.assert_not_called()\n        mock_cache.add.assert_called_once()\n        mock_cache.delete.assert_not_called()\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    def test_not_downloaded_if_file_exists_and_model_is_unsaved(self, mock_get_s3_bucket):\n\n        # Note this is NOT being saved to disk, e.g. how a DatabaseValidate would work.\n        connection = DatabaseConnection(\n            alias=\"test\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test_db.sqlite3\",\n            host=\"some-s3-bucket\",\n        )\n\n        def mock_download_file(path, filename): pass\n        mock_s3 = mock_get_s3_bucket.return_value\n        mock_s3.download_file = MagicMock(side_effect=mock_download_file)\n\n        # write the file\n        with open(connection.local_name, \"w\") as f:\n            f.write(\"Initial content\")\n\n        # See if it downloads\n        connection.download_sqlite_if_needed()\n\n        # And it shouldn't....\n        mock_s3.download_file.assert_not_called()\n\n        # ...even though the fingerprints don't match\n        self.assertIsNone(connection.upload_fingerprint)\n\n    @patch(\"explorer.utils.get_s3_bucket\")\n    def test_fingerprint_is_updated_after_download_and_download_is_not_called_again(self, mock_get_s3_bucket):\n        # Setup\n        mock_s3 = mock_get_s3_bucket.return_value\n\n        connection = DatabaseConnection.objects.create(\n            alias=\"test\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test_db.sqlite3\",\n            host=\"some-s3-bucket\",\n        )\n\n        # Define a function to mock S3 download\n        def mock_download_file(path, filename):\n            with open(filename, \"w\") as f:\n                f.write(\"Initial content\")\n\n        mock_s3.download_file = MagicMock(side_effect=mock_download_file)\n\n        # First download\n        connection.download_sqlite_if_needed()\n\n        # Check that the file was \"downloaded\" (in this case, created)\n        self.assertTrue(os.path.exists(connection.local_name))\n\n        # Check that the fingerprint was updated\n        self.assertIsNotNone(connection.upload_fingerprint)\n        initial_fingerprint = connection.upload_fingerprint\n\n        # Mock S3 download to track calls\n        mock_s3.download_file.reset_mock()\n\n        # Second attempt to download\n        connection.download_sqlite_if_needed()\n\n        # Check that download was not called again\n        mock_s3.download_file.assert_not_called()\n\n        # Check that the fingerprint hasn't changed\n        connection.refresh_from_db()\n        self.assertEqual(connection.upload_fingerprint, initial_fingerprint)\n\n        # Modify the file to simulate changes\n        with open(connection.local_name, \"w\") as f:\n            f.write(\"Modified content\")\n\n        # Third attempt to download\n        connection.download_sqlite_if_needed()\n\n        # Check that download was called again\n        mock_s3.download_file.assert_called_once()\n\n        # Check that the fingerprint has been updated back to the original\n        connection.refresh_from_db()\n        self.assertEqual(connection.upload_fingerprint, initial_fingerprint)\n\n    def test_default_is_set(self):\n        orig_default = default_db_connection()\n        new_default = DatabaseConnection(alias=\"new1\", engine=DatabaseConnection.SQLITE, name=\"test_db.sqlite3\",\n                                         default=True)\n        new_default.save()\n        orig_default.refresh_from_db()\n        self.assertFalse(orig_default.default)\n        self.assertEqual(new_default.id, default_db_connection().id)\n        self.assertEqual(DatabaseConnection.objects.filter(default=True).count(), 1)\n"
  },
  {
    "path": "explorer/tests/test_schema.py",
    "content": "from unittest.mock import patch\n\nfrom django.core.cache import cache\nfrom django.db import connection\nfrom django.test import TestCase\n\nfrom explorer import schema\nfrom explorer.ee.db_connections.utils import default_db_connection\n\n\ndef conn():\n    return default_db_connection()\n\n\nclass TestSchemaInfo(TestCase):\n\n    def setUp(self):\n        cache.clear()\n\n    @patch(\"explorer.schema._get_includes\")\n    @patch(\"explorer.schema._get_excludes\")\n    def test_schema_info_returns_valid_data(self, mocked_excludes,\n                                            mocked_includes):\n        mocked_includes.return_value = None\n        mocked_excludes.return_value = []\n        res = schema.schema_info(conn())\n        assert mocked_includes.called  # sanity check: ensure patch worked\n        tables = [x[0] for x in res]\n        self.assertIn(\"explorer_query\", tables)\n\n        json_res = schema.schema_json_info(conn())\n        self.assertListEqual(list(json_res.keys()), tables)\n\n    @patch(\"explorer.schema._get_includes\")\n    @patch(\"explorer.schema._get_excludes\")\n    def test_table_exclusion_list(self, mocked_excludes, mocked_includes):\n        mocked_includes.return_value = None\n        mocked_excludes.return_value = (\"explorer_\",)\n        res = schema.schema_info(conn())\n        tables = [x[0] for x in res]\n        self.assertNotIn(\"explorer_query\", tables)\n\n    @patch(\"explorer.schema._get_includes\")\n    @patch(\"explorer.schema._get_excludes\")\n    def test_app_inclusion_list(self, mocked_excludes, mocked_includes):\n        mocked_includes.return_value = (\"auth_\",)\n        mocked_excludes.return_value = []\n        res = schema.schema_info(conn())\n        tables = [x[0] for x in res]\n        self.assertNotIn(\"explorer_query\", tables)\n        self.assertIn(\"auth_user\", tables)\n\n    @patch(\"explorer.schema._get_includes\")\n    @patch(\"explorer.schema._get_excludes\")\n    def test_app_inclusion_list_excluded(self, mocked_excludes,\n                                         mocked_includes):\n        # Inclusion list \"wins\"\n        mocked_includes.return_value = (\"explorer_\",)\n        mocked_excludes.return_value = (\"explorer_\",)\n        res = schema.schema_info(conn())\n        tables = [x[0] for x in res]\n        self.assertIn(\"explorer_query\", tables)\n\n    @patch(\"explorer.schema._include_views\")\n    def test_app_include_views(self, mocked_include_views):\n        database_view = setup_sample_database_view()\n        mocked_include_views.return_value = True\n        res = schema.schema_info(conn())\n        tables = [x[0] for x in res]\n        self.assertIn(database_view, tables)\n\n    @patch(\"explorer.schema._include_views\")\n    def test_app_exclude_views(self, mocked_include_views):\n        database_view = setup_sample_database_view()\n        mocked_include_views.return_value = False\n        res = schema.schema_info(conn())\n        tables = [x[0] for x in res]\n        self.assertNotIn(database_view, tables)\n\n    def test_transform_to_json(self):\n        schema_info = [\n            (\"table1\", [(\"col1\", \"type1\"), (\"col2\", \"type2\")]),\n            (\"table2\", [(\"col1\", \"type1\"), (\"col2\", \"type2\")]),\n        ]\n        json_schema = schema.transform_to_json_schema(schema_info)\n        self.assertEqual(json_schema, {\n            \"table1\": [\"col1\", \"col2\"],\n            \"table2\": [\"col1\", \"col2\"],\n        })\n\n\ndef setup_sample_database_view():\n    with connection.cursor() as cursor:\n        cursor.execute(\n            \"CREATE VIEW IF NOT EXISTS v_explorer_query AS SELECT title, \"\n            \"sql from explorer_query\"\n        )\n    return \"v_explorer_query\"\n"
  },
  {
    "path": "explorer/tests/test_tasks.py",
    "content": "import unittest\nfrom datetime import datetime, timedelta\nfrom io import StringIO\nfrom unittest.mock import patch\nimport os\n\nfrom django.core import mail\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom explorer import app_settings\nfrom explorer.models import QueryLog\nfrom explorer.ee.db_connections.models import DatabaseConnection\nfrom explorer.tasks import execute_query, snapshot_queries, truncate_querylogs, \\\n    remove_unused_sqlite_dbs\nfrom explorer.tests.factories import SimpleQueryFactory\n\n\nclass TestTasks(TestCase):\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    @patch(\"explorer.tasks.s3_csv_upload\")\n    def test_async_results(self, mocked_upload):\n        mocked_upload.return_value = \"http://s3.com/your-file.csv\"\n\n        q = SimpleQueryFactory(\n            sql='select 1 \"a\", 2 \"b\", 3 \"c\";', title=\"testquery\"\n        )\n        execute_query(q.id, \"cc@epantry.com\")\n\n        output = StringIO()\n        output.write(\"a,b,c\\r\\n1,2,3\\r\\n\")\n\n        self.assertEqual(len(mail.outbox), 2)\n        self.assertIn(\n            \"[SQL Explorer] Your query is running\", mail.outbox[0].subject\n        )\n        self.assertIn(\"[SQL Explorer] Report \", mail.outbox[1].subject)\n        self.assertEqual(\n            mocked_upload\n            .call_args[0][1].getvalue()\n            .decode(\"utf-8-sig\"),\n            output.getvalue()\n        )\n        self.assertEqual(mocked_upload.call_count, 1)\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    @patch(\"explorer.tasks.s3_csv_upload\")\n    def test_async_results_fails_with_message(self, mocked_upload):\n        mocked_upload.return_value = \"http://s3.com/your-file.csv\"\n\n        q = SimpleQueryFactory(sql=\"select x from foo;\", title=\"testquery\")\n        execute_query(q.id, \"cc@epantry.com\")\n\n        output = StringIO()\n        output.write(\"a,b,c\\r\\n1,2,3\\r\\n\")\n\n        self.assertEqual(len(mail.outbox), 2)\n        self.assertIn(\"[SQL Explorer] Error \", mail.outbox[1].subject)\n        self.assertEqual(mocked_upload.call_count, 0)\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    @patch(\"explorer.tasks.s3_csv_upload\")\n    def test_snapshots(self, mocked_upload):\n        mocked_upload.return_value = \"http://s3.com/your-file.csv\"\n\n        SimpleQueryFactory(snapshot=True)\n        SimpleQueryFactory(snapshot=True)\n        SimpleQueryFactory(snapshot=True)\n        SimpleQueryFactory(snapshot=False)\n\n        snapshot_queries()\n        self.assertEqual(mocked_upload.call_count, 3)\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    def test_truncating_querylogs(self):\n        QueryLog(sql=\"foo\").save()\n        delete_time = timezone.make_aware(datetime.now() - timedelta(days=31), timezone.get_default_timezone())\n        QueryLog.objects.filter(sql=\"foo\").update(\n            run_at=delete_time\n        )\n\n        QueryLog(sql=\"bar\").save()\n        ok_time = timezone.make_aware(datetime.now() - timedelta(days=29), timezone.get_default_timezone())\n        QueryLog.objects.filter(sql=\"bar\").update(\n            run_at=ok_time\n        )\n        truncate_querylogs(30)\n        self.assertEqual(QueryLog.objects.count(), 1)\n\n\nclass RemoveUnusedSQLiteDBsTestCase(TestCase):\n\n    def set_up_the_things(self, offset):\n        dbc = DatabaseConnection(\n            alias=\"localconn\",\n            name=\"localconn\",\n            engine=DatabaseConnection.SQLITE,\n            host=\"foo\"\n        )\n        dbc.save()\n        days = app_settings.EXPLORER_PRUNE_LOCAL_UPLOAD_COPY_DAYS_INACTIVITY\n\n        with open(dbc.local_name, \"w\") as temp_db:\n            temp_db.write(\"\")\n\n        recent_time = timezone.make_aware(datetime.now() - timedelta(days=days + offset),\n                                          timezone.get_default_timezone())\n        ql = QueryLog(sql=\"foo\", database_connection=dbc)\n        ql.save()\n        QueryLog.objects.filter(id=ql.id).update(run_at=recent_time)  # Have to sidestep the auto_add_now\n\n        return dbc, ql\n\n    def test_remove_unused_sqlite_dbs(self):\n        dbc, ql = self.set_up_the_things(1)\n        remove_unused_sqlite_dbs()\n        self.assertFalse(os.path.exists(dbc.local_name))\n        dbc.delete()\n        ql.delete()\n\n    def test_do_not_remove_recently_used_db(self):\n        dbc, ql = self.set_up_the_things(-1)\n        remove_unused_sqlite_dbs()\n        self.assertTrue(os.path.exists(dbc.local_name))\n        os.remove(dbc.local_name)\n        dbc.delete()\n        ql.delete()\n"
  },
  {
    "path": "explorer/tests/test_telemetry.py",
    "content": "from django.test import TestCase\nfrom explorer.telemetry import instance_identifier, _gather_summary_stats, Stat, StatNames, _get_install_quarter\nfrom unittest.mock import patch, MagicMock\nfrom django.core.cache import cache\nfrom datetime import datetime\n\n\nclass TestTelemetry(TestCase):\n\n    def setUp(self):\n        cache.delete(\"last_stat_sent_time\")\n\n    def test_instance_identifier(self):\n        v = instance_identifier()\n        self.assertEqual(len(v), 36)\n\n        # Doesn't change after calling it again\n        v = instance_identifier()\n        self.assertEqual(len(v), 36)\n\n    def test_gather_summary_stats(self):\n        res = _gather_summary_stats()\n        self.assertEqual(res[\"total_query_count\"], 0)\n        self.assertEqual(res[\"default_database\"], \"sqlite\")\n\n    @patch(\"explorer.telemetry.threading.Thread\")\n    @patch(\"explorer.app_settings\")\n    def test_stats_not_sent_too_frequently(self, mocked_app_settings, mocked_thread):\n        mocked_app_settings.EXPLORER_ENABLE_ANONYMOUS_STATS = True\n        mocked_app_settings.UNSAFE_RENDERING = True\n        mocked_app_settings.EXPLORER_CHARTS_ENABLED = True\n        mocked_app_settings.has_assistant = MagicMock(return_value=True)\n        mocked_app_settings.db_connections_enabled = MagicMock(return_value=True)\n        mocked_app_settings.ENABLE_TASKS = True\n        s1 = Stat(StatNames.QUERY_RUN, {\"foo\": \"bar\"})\n        s2 = Stat(StatNames.QUERY_RUN, {\"mux\": \"qux\"})\n        s3 = Stat(StatNames.QUERY_RUN, {\"bar\": \"baz\"})\n\n        # once for s1 and once for summary stats\n        s1.track()\n        self.assertEqual(mocked_thread.call_count, 2)\n\n        # both the s2 track call is suppressed, and the summary stat call\n        s2.track()\n        self.assertEqual(mocked_thread.call_count, 2)\n\n        # clear the cache, which should cause track() for the stat to work, but not send summary stats\n        cache.clear()\n        s3.track()\n        self.assertEqual(mocked_thread.call_count, 3)\n\n    @patch(\"explorer.telemetry.threading.Thread\")\n    @patch(\"explorer.app_settings\")\n    def test_stats_not_sent_if_disabled(self, mocked_app_settings, mocked_thread):\n        mocked_app_settings.EXPLORER_ENABLE_ANONYMOUS_STATS = False\n        s1 = Stat(StatNames.QUERY_RUN, {\"foo\": \"bar\"})\n        s1.track()\n        self.assertEqual(mocked_thread.call_count, 0)\n\n    @patch(\"explorer.telemetry.MigrationRecorder.Migration.objects.filter\")\n    def test_get_install_quarter_with_no_migrations(self, mock_filter):\n        mock_filter.return_value.order_by.return_value.first.return_value = None\n        result = _get_install_quarter()\n        self.assertIsNone(result)\n\n    @patch(\"explorer.telemetry.MigrationRecorder.Migration.objects.filter\")\n    def test_get_install_quarter_edge_cases(self, mock_filter):\n        # Test edge cases like end of year and start of year\n        dates = [datetime(2022, 12, 31), datetime(2023, 1, 1), datetime(2023, 3, 31), datetime(2023, 4, 1)]\n        results = [\"Q4-2022\", \"Q1-2023\", \"Q1-2023\", \"Q2-2023\"]\n\n        for date, expected in zip(dates, results):\n            with self.subTest(date=date):\n                mock_migration = MagicMock()\n                mock_migration.applied = date\n                mock_filter.return_value.order_by.return_value.first.return_value = mock_migration\n\n                result = _get_install_quarter()\n                self.assertEqual(result, expected)\n"
  },
  {
    "path": "explorer/tests/test_type_infer.py",
    "content": "from django.test import TestCase\nfrom unittest import skipIf\nfrom explorer.app_settings import EXPLORER_USER_UPLOADS_ENABLED\nif EXPLORER_USER_UPLOADS_ENABLED:\n    import pandas as pd\nimport os\nfrom explorer.ee.db_connections.type_infer import csv_to_typed_df, json_to_typed_df, json_list_to_typed_df\n\n\ndef _get_csv(csv_name):\n    current_script_dir = os.path.dirname(os.path.abspath(__file__))\n    file_path = os.path.join(current_script_dir, \"csvs\", csv_name)\n\n    # Open the file in binary mode and read its contents\n    with open(file_path, \"rb\") as file:\n        csv_bytes = file.read()\n\n    return csv_bytes\n\n\ndef _get_json(json_name):\n    current_script_dir = os.path.dirname(os.path.abspath(__file__))\n    file_path = os.path.join(current_script_dir, \"json\", json_name)\n\n    # Open the file in binary mode and read its contents\n    with open(file_path, \"rb\") as file:\n        json_bytes = file.read()\n\n    return json_bytes\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, \"User uploads not enabled\")\nclass TestCsvToTypedDf(TestCase):\n\n    def test_mixed_types(self):\n        df = csv_to_typed_df(_get_csv(\"mixed.csv\"))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Value1\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Value2\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Value3\"]))\n\n    def test_all_types(self):\n        df = csv_to_typed_df(_get_csv(\"all_types.csv\"))\n        self.assertTrue(pd.api.types.is_datetime64_ns_dtype(df[\"Dates\"]))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"Integers\"]))\n        self.assertTrue(pd.api.types.is_float_dtype(df[\"Floats\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Strings\"]))\n\n    def test_integer_parsing(self):\n        df = csv_to_typed_df(_get_csv(\"integers.csv\"))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"Integers\"]))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"More_integers\"]))\n\n    def test_float_parsing(self):\n        df = csv_to_typed_df(_get_csv(\"floats.csv\"))\n        self.assertTrue(pd.api.types.is_float_dtype(df[\"Floats\"]))\n\n    def test_date_parsing(self):\n\n        # Will not handle these formats:\n        # Unix Timestamp: 1706232300 (Seconds since Unix Epoch - 1970-01-01 00:00:00 UTC)\n        # ISO 8601 Week Number: 2024-W04-3 (Year-WWeekNumber-Weekday)\n        # Day of Year: 2024-024 (Year-DayOfYear)\n\n        df = csv_to_typed_df(_get_csv(\"dates.csv\"))\n        self.assertTrue(pd.api.types.is_datetime64_ns_dtype(df[\"Dates\"]))\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, \"User uploads not enabled\")\nclass TestJsonToTypedDf(TestCase):\n\n    def test_basic_json(self):\n        df = json_to_typed_df(_get_json(\"kings.json\"))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Name\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Country\"]))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"ID\"]))\n\n    def test_nested_json(self):\n        df = json_to_typed_df(_get_json(\"github.json\"))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"subscription_url\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"topics\"]))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"size\"]))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"owner.id\"]))\n\n    def test_json_list(self):\n        df = json_list_to_typed_df(_get_json(\"list.json\"))\n        self.assertTrue(pd.api.types.is_integer_dtype(df[\"Item.value.M.unique_connection_count.N\"]))\n        self.assertTrue(pd.api.types.is_object_dtype(df[\"Item.instanceId.S\"]))\n        self.assertEqual(len(df), 5)\n"
  },
  {
    "path": "explorer/tests/test_utils.py",
    "content": "from unittest.mock import Mock\n\nfrom django.test import TestCase\n\nfrom explorer import app_settings\nfrom explorer.tests.factories import SimpleQueryFactory\nfrom explorer.utils import (\n    EXPLORER_PARAM_TOKEN, extract_params, get_params_for_url, get_params_from_request, param, passes_blacklist,\n    shared_dict_update, swap_params, secure_filename\n)\n\n\nclass TestSqlBlacklist(TestCase):\n\n    def setUp(self):\n        self.orig = app_settings.EXPLORER_SQL_BLACKLIST\n\n    def tearDown(self):\n        app_settings.EXPLORER_SQL_BLACKLIST = self.orig\n\n    def test_overriding_blacklist(self):\n        app_settings.EXPLORER_SQL_BLACKLIST = []\n        sql = \"DELETE FROM some_table;\"\n        passes, words = passes_blacklist(sql)\n        self.assertTrue(passes)\n\n    def test_not_overriding_blacklist(self):\n        sql = \"DELETE FROM some_table;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    # Various flavors of select - all should be ok\n    def test_select_keywords_as_literals(self):\n        sql = \"SELECT * from eventtype where eventtype.value = 'Grant Date';\"\n        passes, words = passes_blacklist(sql)\n        self.assertTrue(passes)\n\n    def test_select_containing_drop_in_word(self):\n        sql = \"SELECT * FROM student droptable WHERE name LIKE 'Robert%'\"\n        self.assertTrue(passes_blacklist(sql)[0])\n\n    def test_select_with_case(self):\n        sql = \"\"\"SELECT   ProductNumber, Name, \"Price Range\" =\n          CASE\n             WHEN ListPrice =  0 THEN 'Mfg item - not for resale'\n             WHEN ListPrice < 50 THEN 'Under $50'\n             WHEN ListPrice >= 50 and ListPrice < 250 THEN 'Under $250'\n             WHEN ListPrice >= 250 and ListPrice < 1000 THEN 'Under $1000'\n             ELSE 'Over $1000'\n          END\n        FROM Production.Product\n        ORDER BY ProductNumber ;\n        \"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertTrue(passes)\n\n    def test_select_with_subselect(self):\n        sql = \"\"\"SELECT a.studentid, a.name, b.total_marks\n            FROM student a, marks b\n            WHERE a.studentid = b.studentid AND b.total_marks >\n            (SELECT total_marks\n            FROM marks\n            WHERE studentid =  'V002');\n            \"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertTrue(passes)\n\n    def test_select_with_replace_function(self):\n        sql = \"SELECT replace('test string', 'st', '**');\"\n        passes, words = passes_blacklist(sql)\n        self.assertTrue(passes)\n\n    def test_dml_commit(self):\n        sql = \"COMMIT TRANSACTION;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_delete(self):\n        sql = \"'distraction'; deLeTe from table; \" \\\n              \"SELECT 1+1 AS TWO; drop view foo;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n        self.assertEqual(len(words), 2)\n\n    def test_dml_insert(self):\n        sql = \"INSERT INTO products (product_no, name, price) VALUES (1, 'Cheese', 9.99);\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_merge(self):\n        sql = \"\"\"MERGE INTO wines w\n            USING (VALUES('Chateau Lafite 2003', '24')) v\n            ON v.column1 = w.winename\n            WHEN NOT MATCHED\n              INSERT VALUES(v.column1, v.column2)\n            WHEN MATCHED\n              UPDATE SET stock = stock + v.column2;\"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_replace(self):\n        sql = \"REPLACE INTO test VALUES (1, 'Old', '2014-08-20 18:47:00');\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_rollback(self):\n        sql = \"ROLLBACK TO SAVEPOINT my_savepoint;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_set(self):\n        sql = \"SET PASSWORD FOR 'user-name-here' = PASSWORD('new-password');\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_start(self):\n        sql = \"START TRANSACTION;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_update(self):\n        sql = \"\"\"UPDATE accounts SET (contact_first_name, contact_last_name) =\n        (SELECT first_name, last_name FROM employees\n         WHERE employees.id = accounts.sales_person);\"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dml_upsert(self):\n        sql = \"UPSERT INTO Users VALUES (10, 'John', 'Smith', 27, 60000);\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_ddl_alter(self):\n        sql = \"\"\"ALTER TABLE foo\n        ALTER COLUMN foo_timestamp DROP DEFAULT,\n        ALTER COLUMN foo_timestamp TYPE timestamp with time zone\n        USING\n            timestamp with time zone 'epoch' + foo_timestamp * interval '1 second',\n        ALTER COLUMN foo_timestamp SET DEFAULT now();\"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_ddl_create(self):\n        sql = \"\"\"CREATE TABLE Persons (\n            PersonID int,\n            LastName varchar(255),\n            FirstName varchar(255),\n            Address varchar(255),\n            City varchar(255)\n        );\n        \"\"\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_ddl_drop(self):\n        sql = \"DROP TABLE films, distributors;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_ddl_rename(self):\n        sql = \"RENAME TABLE old_table_name TO new_table_name;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_ddl_truncate(self):\n        sql = \"TRUNCATE bigtable, othertable RESTART IDENTITY;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dcl_grant(self):\n        sql = \"GRANT ALL PRIVILEGES ON kinds TO manuel;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dcl_revoke(self):\n        sql = \"REVOKE ALL PRIVILEGES ON kinds FROM manuel;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n    def test_dcl_revoke_bad_syntax(self):\n        sql = \"REVOKE ON kinds; FROM manuel;\"\n        passes, words = passes_blacklist(sql)\n        self.assertFalse(passes)\n\n\nclass TestParams(TestCase):\n\n    def test_swappable_params_are_built_correctly(self):\n        expected = EXPLORER_PARAM_TOKEN + \"foo\" + EXPLORER_PARAM_TOKEN\n        self.assertEqual(expected, param(\"foo\"))\n\n    def test_params_get_swapped(self):\n        sql = \"please Swap $$this$$ and $$THat$$\"\n        expected = \"please Swap here and there\"\n        params = {\"this\": \"here\", \"that\": \"there\"}\n        got = swap_params(sql, params)\n        self.assertEqual(got, expected)\n\n    def test_empty_params_does_nothing(self):\n        sql = \"please swap $$this$$ and $$that$$\"\n        params = None\n        got = swap_params(sql, params)\n        self.assertEqual(got, sql)\n\n    def test_non_string_param_gets_swapper(self):\n        sql = \"please swap $$this$$\"\n        expected = \"please swap 1\"\n        params = {\"this\": 1}\n        got = swap_params(sql, params)\n        self.assertEqual(got, expected)\n\n    def _assertSwap(self, tuple):\n        self.assertEqual(extract_params(tuple[0]), tuple[1])\n\n    def test_extracting_params(self):\n        tests = [\n            (\"please swap $$this0$$\", {\"this0\": {\"default\": \"\", \"label\": \"\"}}),\n            (\"please swap $$THis0$$\", {\"this0\": {\"default\": \"\", \"label\": \"\"}}),\n            (\"please swap $$this6$$ $$this6:that$$\", {\"this6\": {\"default\": \"that\", \"label\": \"\"}}),\n            (\"please swap $$this_7:foo, bar$$\", {\"this_7\": {\"default\": \"foo, bar\", \"label\": \"\"}}),\n            (\"please swap $$this8:$$\", {}),\n            (\"do nothing with $$this1 $$\", {}),\n            (\"do nothing with $$this2 :$$\", {}),\n            (\"do something with $$this3: $$\", {\"this3\": {\"default\": \" \", \"label\": \"\"}}),\n            (\"do nothing with $$this4: \", {}),\n            (\"do nothing with $$this5$that$$\", {}),\n            (\"check label $$this|label:val$$\", {\"this\": {\"default\": \"val\", \"label\": \"label\"}}),\n            (\"check case $$this|label Case:Va l$$\", {\"this\": {\"default\": \"Va l\", \"label\": \"label Case\"}}),\n            (\"check label case and unicode $$this|label Case ελληνικά:val Τέστ$$\", {\n                \"this\": {\"default\": \"val Τέστ\", \"label\": \"label Case ελληνικά\"}\n            }),\n        ]\n        for s in tests:\n            self._assertSwap(s)\n\n    def test_shared_dict_update(self):\n        source = {\"foo\": 1, \"bar\": 2}\n        target = {\"bar\": None}  # ha ha!\n        self.assertEqual({\"bar\": 2}, shared_dict_update(target, source))\n\n    def test_get_params_from_url(self):\n        r = Mock()\n        r.GET = {\"params\": \"foo:bar|qux:mux\"}\n        res = get_params_from_request(r)\n        self.assertEqual(res[\"foo\"], \"bar\")\n        self.assertEqual(res[\"qux\"], \"mux\")\n\n    def test_get_params_for_request(self):\n        q = SimpleQueryFactory(params={\"a\": 1, \"b\": 2})\n        # For some reason the order of the params is non-deterministic,\n        # causing the following to periodically fail:\n        #     self.assertEqual(get_params_for_url(q), 'a:1|b:2')\n        # So instead we go for the following, convoluted, asserts:\n        res = get_params_for_url(q)\n        res = res.split(\"|\")\n        expected = [\"a:1\", \"b:2\"]\n        for e in expected:\n            self.assertIn(e, res)\n\n    def test_get_params_for_request_empty(self):\n        q = SimpleQueryFactory()\n        self.assertEqual(get_params_for_url(q), None)\n\n\nclass TestSecureFilename(TestCase):\n    def test_basic_ascii(self):\n        self.assertEqual(secure_filename(\"simple_file.txt\"), \"simple_file.txt\")\n\n    def test_special_characters(self):\n        self.assertEqual(secure_filename(\"file@name!.txt\"), \"file_name.txt\")\n\n    def test_leading_trailing_underscores(self):\n        self.assertEqual(secure_filename(\"_leading.txt\"), \"leading.txt\")\n        self.assertEqual(secure_filename(\"trailing_.txt\"), \"trailing.txt\")\n        self.assertEqual(secure_filename(\".__filename__.txt\"), \"filename.txt\")\n\n    def test_unicode_characters(self):\n        self.assertEqual(secure_filename(\"fïléñâmé.txt\"), \"filename.txt\")\n        self.assertEqual(secure_filename(\"测试文件.txt\"), \"_.txt\")\n\n    def test_empty_filename(self):\n        with self.assertRaises(ValueError):\n            secure_filename(\"\")\n\n    def test_bad_extension(self):\n        with self.assertRaises(ValueError):\n            secure_filename(\"foo.xyz\")\n\n    def test_empty_extension(self):\n        with self.assertRaises(ValueError):\n            secure_filename(\"foo.\")\n\n    def test_spaces(self):\n        self.assertEqual(secure_filename(\"file name.txt\"), \"file_name.txt\")\n"
  },
  {
    "path": "explorer/tests/test_views.py",
    "content": "import importlib\nimport json\nimport time\nimport unittest\nimport os\nfrom unittest.mock import Mock, patch, MagicMock\nfrom unittest import skipIf\n\nfrom django.contrib.auth.models import User\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.core.cache import cache\nfrom django.db import DatabaseError\nfrom django.forms.models import model_to_dict\nfrom django.shortcuts import redirect\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom explorer import app_settings\nfrom explorer.forms import QueryForm\nfrom explorer.app_settings import EXPLORER_TOKEN, EXPLORER_USER_UPLOADS_ENABLED\nfrom explorer.models import MSG_FAILED_BLACKLIST, Query, QueryFavorite, QueryLog, DatabaseConnection\nfrom explorer.tests.factories import QueryLogFactory, SimpleQueryFactory\nfrom explorer.utils import user_can_see_query\nfrom explorer.ee.db_connections.utils import default_db_connection\nfrom explorer.schema import connection_schema_cache_key, connection_schema_json_cache_key\nfrom explorer.assistant.models import TableDescription\n\n\ndef reload_app_settings():\n    \"\"\"\n    Reload app settings, otherwise changes from testing context manager won't take effect\n    app_settings are loaded at time of import\n    \"\"\"\n    importlib.reload(app_settings)\n\n\nclass TestQueryListView(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_headers(self):\n        SimpleQueryFactory(title=\"foo - bar1\")\n        SimpleQueryFactory(title=\"foo - bar2\")\n        SimpleQueryFactory(title=\"foo - bar3\")\n        SimpleQueryFactory(title=\"qux - mux\")\n        resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertContains(resp, \"foo (3)\")\n        self.assertContains(resp, \"foo - bar2\")\n        self.assertContains(resp, \"qux - mux\")\n\n    def test_permissions_show_only_allowed_queries(self):\n        self.client.logout()\n        q1 = SimpleQueryFactory(title=\"canseethisone\")\n        q2 = SimpleQueryFactory(title=\"nope\")\n        user = User.objects.create_user(\"user\", \"user@user.com\", \"pwd\")\n        self.client.login(username=\"user\", password=\"pwd\")\n        with self.settings(EXPLORER_USER_QUERY_VIEWS={user.id: [q1.id]}):\n            resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertTemplateUsed(resp, \"explorer/query_list.html\")\n        self.assertContains(resp, q1.title)\n        self.assertNotContains(resp, q2.title)\n\n    def test_run_count(self):\n        q = SimpleQueryFactory(title=\"foo - bar1\")\n        for _ in range(0, 4):\n            q.log()\n        resp = self.client.get(reverse(\"explorer_index\"))\n        self.assertContains(resp, \"4</td>\")\n\n\nclass TestQueryCreateView(TestCase):\n\n    def setUp(self):\n        self.admin = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.user = User.objects.create_user(\n            \"user\", \"user@user.com\", \"pwd\"\n        )\n\n    def test_change_permission_required(self):\n        self.client.login(username=\"user\", password=\"pwd\")\n        resp = self.client.get(reverse(\"query_create\"))\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_renders_with_title(self):\n        self.client.login(username=\"admin\", password=\"pwd\")\n        resp = self.client.get(reverse(\"query_create\"))\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"New Query\")\n\n\ndef custom_view(request):\n    return redirect(\"/custom/login\")\n\n\nclass TestQueryDetailView(TestCase):\n    databases = [\"default\", \"alt\"]\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_query_with_bad_sql_renders_error(self):\n        query = SimpleQueryFactory(sql=\"error\")\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n        )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"syntax error\")\n\n    def test_query_with_bad_sql_renders_error_on_save(self):\n        query = SimpleQueryFactory(sql=\"select 1;\")\n        resp = self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            data={\"sql\": \"error\"}\n        )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"syntax error\")\n\n    def test_posting_query_saves_correctly(self):\n        expected = \"select 2;\"\n        query = SimpleQueryFactory(sql=\"select 1;\")\n        data = model_to_dict(query)\n        data[\"sql\"] = expected\n        self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            data\n        )\n        self.assertEqual(Query.objects.get(pk=query.id).sql, expected)\n\n    def test_change_permission_required_to_save_query(self):\n        query = SimpleQueryFactory()\n        expected = query.sql\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n        )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n\n        self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            {\"sql\": \"select 1;\"}\n        )\n        self.assertEqual(Query.objects.get(pk=query.id).sql, expected)\n\n    def test_modified_date_gets_updated_after_viewing_query(self):\n        query = SimpleQueryFactory()\n        old = query.last_run_date\n        time.sleep(0.1)\n        self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n        )\n        self.assertNotEqual(old, Query.objects.get(pk=query.id).last_run_date)\n\n    def test_doesnt_render_results_if_show_is_none(self):\n        query = SimpleQueryFactory(sql=\"select 6870+1;\")\n        resp = self.client.get(\n            reverse(\n                \"query_detail\", kwargs={\"query_id\": query.id}\n            ) + \"?show=0\"\n        )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertNotContains(resp, \"6871\")\n\n    def test_doesnt_render_results_if_show_is_none_on_post(self):\n        query = SimpleQueryFactory(sql=\"select 6870+1;\")\n        resp = self.client.post(\n            reverse(\n                \"query_detail\", kwargs={\"query_id\": query.id}\n            ) + \"?show=0\",\n            {\"sql\": \"select 6870+2;\"}\n        )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertNotContains(resp, \"6872\")\n\n    def test_doesnt_render_results_if_params_and_no_autorun(self):\n        with self.settings(EXPLORER_AUTORUN_QUERY_WITH_PARAMS=False):\n            reload_app_settings()\n            query = SimpleQueryFactory(sql=\"select 6870+3 where 1=$$myparam:1$$;\")\n            resp = self.client.get(\n                reverse(\n                    \"query_detail\", kwargs={\"query_id\": query.id}\n                )\n            )\n            self.assertTemplateUsed(resp, \"explorer/query.html\")\n            self.assertNotContains(resp, \"6873\")\n\n    def test_does_render_results_if_params_and_autorun(self):\n        with self.settings(EXPLORER_AUTORUN_QUERY_WITH_PARAMS=True):\n            reload_app_settings()\n            query = SimpleQueryFactory(sql=\"select 6870+4 where 1=$$myparam:1$$;\")\n            resp = self.client.get(\n                reverse(\n                    \"query_detail\", kwargs={\"query_id\": query.id}\n                )\n            )\n            self.assertTemplateUsed(resp, \"explorer/query.html\")\n            self.assertContains(resp, \"6874\")\n\n    def test_does_render_label_if_params_and_autorun(self):\n        with self.settings(EXPLORER_AUTORUN_QUERY_WITH_PARAMS=True):\n            reload_app_settings()\n            query = SimpleQueryFactory(sql=\"select 6870+4 where 1=$$myparam|test my param label:1$$;\")\n            resp = self.client.get(\n                reverse(\n                    \"query_detail\", kwargs={\"query_id\": query.id}\n                )\n            )\n            self.assertTemplateUsed(resp, \"explorer/query.html\")\n            self.assertContains(resp, \"test my param label\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        query = SimpleQueryFactory()\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n        )\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_admin_required_with_explorer_no_permission_setting(self):\n        self.client.logout()\n        query = SimpleQueryFactory()\n        with self.settings(EXPLORER_NO_PERMISSION_VIEW=\"explorer.tests.test_views.custom_view\"):\n            resp = self.client.get(\n                reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n            )\n            self.assertRedirects(\n                resp, \"/custom/login\",\n                target_status_code=404\n            )\n\n    def test_individual_view_permission(self):\n        self.client.logout()\n        user = User.objects.create_user(\"user1\", \"user@user.com\", \"pwd\")\n        self.client.login(username=\"user1\", password=\"pwd\")\n\n        query = SimpleQueryFactory(sql=\"select 123+1\")\n\n        with self.settings(EXPLORER_USER_QUERY_VIEWS={user.id: [query.id]}):\n            resp = self.client.get(\n                reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n            )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"124\")\n\n    def test_header_token_auth(self):\n        self.client.logout()\n\n        query = SimpleQueryFactory(sql=\"select 123+1\")\n\n        with self.settings(EXPLORER_TOKEN_AUTH_ENABLED=True):\n            resp = self.client.get(\n                reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n                **{\"HTTP_X_API_TOKEN\": EXPLORER_TOKEN}\n            )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"124\")\n\n    def test_url_token_auth(self):\n        self.client.logout()\n\n        query = SimpleQueryFactory(sql=\"select 123+1\")\n\n        with self.settings(EXPLORER_TOKEN_AUTH_ENABLED=True):\n            resp = self.client.get(\n                reverse(\n                    \"query_detail\", kwargs={\"query_id\": query.id}\n                ) + f\"?token={EXPLORER_TOKEN}\"\n            )\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, \"124\")\n\n    def test_user_query_views(self):\n        request = Mock()\n\n        request.user.is_anonymous = True\n        kwargs = {}\n        self.assertFalse(user_can_see_query(request, **kwargs))\n\n        request.user.is_anonymous = True\n        self.assertFalse(user_can_see_query(request, **kwargs))\n\n        kwargs = {\"query_id\": 123}\n        request.user.is_anonymous = False\n        self.assertFalse(user_can_see_query(request, **kwargs))\n\n        request.user.id = 99\n        with self.settings(EXPLORER_USER_QUERY_VIEWS={99: [111, 123]}):\n            self.assertTrue(user_can_see_query(request, **kwargs))\n\n    @unittest.skipIf(not app_settings.ENABLE_TASKS, \"tasks not enabled\")\n    @patch(\"explorer.models.get_s3_bucket\")\n    def test_query_snapshot_renders(self, mocked_conn):\n        conn = Mock()\n        conn.objects.filter = Mock()\n        k1 = Mock()\n        k1.generate_url.return_value = \"http://s3.com/foo\"\n        k1.last_modified = \"2015-01-01\"\n        k2 = Mock()\n        k2.generate_url.return_value = \"http://s3.com/bar\"\n        k2.last_modified = \"2015-01-02\"\n        conn.objects.filter.return_value = [k1, k2]\n        mocked_conn.return_value = conn\n\n        query = SimpleQueryFactory(sql=\"select 1;\", snapshot=True)\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id})\n        )\n        self.assertContains(resp, \"2015-01-01\")\n        self.assertContains(resp, \"2015-01-02\")\n\n    def test_failing_blacklist_means_query_doesnt_execute(self):\n        conn = default_db_connection().as_django_connection()\n        start = len(conn.queries)\n        query = SimpleQueryFactory(sql=\"select 1;\")\n        resp = self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            data={\"sql\": \"delete from auth_user;\"}\n        )\n        end = len(conn.queries)\n\n        self.assertTemplateUsed(resp, \"explorer/query.html\")\n        self.assertContains(resp, MSG_FAILED_BLACKLIST % \"\")\n\n        self.assertEqual(start, end)\n\n    def test_fullscreen(self):\n        query = SimpleQueryFactory(sql=\"select 1;\")\n        resp = self.client.get(\n            reverse(\n                \"query_detail\", kwargs={\"query_id\": query.id}\n            ) + \"?fullscreen=1\"\n        )\n        self.assertTemplateUsed(resp, \"explorer/fullscreen.html\")\n\n    def test_multiple_connections_integration(self):\n        from explorer.app_settings import EXPLORER_CONNECTIONS\n\n        c1_alias = EXPLORER_CONNECTIONS[\"SQLite\"]\n        conn = DatabaseConnection.objects.get(alias=c1_alias).as_django_connection()\n        c = conn.cursor()\n        c.execute(\"CREATE TABLE IF NOT EXISTS animals (name text NOT NULL);\")\n        c.execute(\"INSERT INTO animals ( name ) VALUES ('peacock')\")\n\n        c2_alias = EXPLORER_CONNECTIONS[\"Another\"]\n        conn = DatabaseConnection.objects.get(alias=c2_alias).as_django_connection()\n        c = conn.cursor()\n        c.execute(\"CREATE TABLE IF NOT EXISTS animals (name text NOT NULL);\")\n        c.execute(\"INSERT INTO animals ( name ) VALUES ('superchicken')\")\n\n        query1 = SimpleQueryFactory(\n            sql=\"select name from animals;\", database_connection_id=DatabaseConnection.objects.get(alias=c1_alias).id\n        )\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query1.id})\n        )\n        self.assertContains(resp, \"peacock\")\n\n        query2 = SimpleQueryFactory(\n            sql=\"select name from animals;\", database_connection_id=DatabaseConnection.objects.get(alias=c2_alias).id\n        )\n        resp = self.client.get(\n            reverse(\"query_detail\", kwargs={\"query_id\": query2.id})\n        )\n        self.assertContains(resp, \"superchicken\")\n\n\nclass TestDownloadView(TestCase):\n    def setUp(self):\n        self.query = SimpleQueryFactory(sql=\"select 1;\")\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.get(\n            reverse(\"download_query\", kwargs={\"query_id\": self.query.id})\n        )\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_params_in_download(self):\n        q = SimpleQueryFactory(sql=\"select '$$foo$$';\")\n        url = \"{}?params={}\".format(\n            reverse(\"download_query\", kwargs={\"query_id\": q.id}),\n            \"foo:123\"\n        )\n        resp = self.client.get(url)\n        self.assertContains(resp, \"'123'\")\n\n    def test_download_defaults_to_csv(self):\n        query = SimpleQueryFactory()\n        url = reverse(\"download_query\", args=[query.pk])\n\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n\n    def test_download_csv(self):\n        query = SimpleQueryFactory()\n        url = reverse(\"download_query\", args=[query.pk]) + \"?format=csv\"\n\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n\n    def test_bad_query_gives_500(self):\n        query = SimpleQueryFactory(sql=\"bad\")\n        url = reverse(\"download_query\", args=[query.pk]) + \"?format=csv\"\n\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, 500)\n\n    def test_download_json(self):\n        query = SimpleQueryFactory()\n        url = reverse(\"download_query\", args=[query.pk]) + \"?format=json\"\n\n        response = self.client.get(url)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"application/json\")\n\n        json_data = json.loads(response.content.decode(\"utf-8\"))\n        self.assertIsInstance(json_data, list)\n        self.assertEqual(len(json_data), 1)\n        self.assertEqual(json_data, [{\"TWO\": 2}])\n\n\nclass TestQueryPlayground(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_empty_playground_renders(self):\n        resp = self.client.get(reverse(\"explorer_playground\"))\n        self.assertEqual(resp.status_code, 200)\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n\n    def test_playground_renders_with_query_sql(self):\n        query = SimpleQueryFactory(sql=\"select 1;\")\n        resp = self.client.get(\n            \"{}?query_id={}\".format(reverse(\"explorer_playground\"), query.id)\n        )\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n        self.assertContains(resp, \"select 1;\")\n\n    def test_playground_renders_with_posted_sql(self):\n        resp = self.client.post(\n            reverse(\"explorer_playground\"),\n            {\"sql\": \"select 1+3400;\"}\n        )\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n        self.assertContains(resp, \"3401\")\n\n    def test_playground_doesnt_render_with_posted_sql_if_show_is_none(self):\n        resp = self.client.post(\n            reverse(\"explorer_playground\") + \"?show=0\",\n            {\"sql\": \"select 1+3400;\"}\n        )\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n        self.assertNotContains(resp, \"3401\")\n\n    def test_playground_renders_with_empty_posted_sql(self):\n        resp = self.client.post(reverse(\"explorer_playground\"), {\"sql\": \"\"})\n        self.assertEqual(resp.status_code, 200)\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n\n    def test_query_with_no_resultset_doesnt_throw_error(self):\n        query = SimpleQueryFactory(sql=\"\")\n        resp = self.client.get(\n            \"{}?query_id={}\".format(reverse(\"explorer_playground\"), query.id)\n        )\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.get(reverse(\"explorer_playground\"))\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_admin_required_with_no_permission_view_setting(self):\n        self.client.logout()\n        with self.settings(EXPLORER_NO_PERMISSION_VIEW=\"explorer.tests.test_views.custom_view\"):\n            resp = self.client.get(reverse(\"explorer_playground\"))\n            self.assertRedirects(\n                resp,\n                \"/custom/login\",\n                target_status_code=404\n            )\n\n    def test_loads_query_from_log(self):\n        querylog = QueryLogFactory()\n        resp = self.client.get(\n            \"{}?querylog_id={}\".format(\n                reverse(\"explorer_playground\"), querylog.id\n            )\n        )\n        self.assertContains(resp, \"FOUR\")\n\n    def test_fails_blacklist(self):\n        resp = self.client.post(\n            reverse(\"explorer_playground\"),\n            {\"sql\": \"delete from auth_user;\"}\n        )\n        self.assertTemplateUsed(resp, \"explorer/play.html\")\n        self.assertContains(resp, MSG_FAILED_BLACKLIST % \"\")\n\n    def test_fullscreen(self):\n        query = SimpleQueryFactory(sql=\"\")\n        resp = self.client.get(\n            \"{}?query_id={}&fullscreen=1\".format(\n                reverse(\"explorer_playground\"),\n                query.id\n            )\n        )\n        self.assertTemplateUsed(resp, \"explorer/fullscreen.html\")\n\n\nclass TestCSVFromSQL(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.post(reverse(\"download_sql\"), {})\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_downloading_from_playground(self):\n        sql = \"select 1;\"\n        resp = self.client.post(reverse(\"download_sql\"), {\"sql\": sql})\n        self.assertIn(\"attachment\", resp[\"Content-Disposition\"])\n        self.assertEqual(\"text/csv\", resp[\"content-type\"])\n        ql = QueryLog.objects.first()\n        self.assertIn(\n            f'filename=\"Playground-{ql.id}.csv\"',\n            resp[\"Content-Disposition\"]\n        )\n\n    def test_stream_csv_from_query(self):\n        q = SimpleQueryFactory()\n        resp = self.client.get(\n            reverse(\"stream_query\", kwargs={\"query_id\": q.id})\n        )\n        self.assertEqual(\"text/csv\", resp[\"content-type\"])\n\n\nclass TestSQLDownloadViews(TestCase):\n    databases = [\"default\", \"alt\"]\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_sql_download_csv(self):\n        url = reverse(\"download_sql\") + \"?format=csv\"\n\n        response = self.client.post(url, {\"sql\": \"select 1;\"})\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n\n    def test_sql_download_respects_connection(self):\n        from explorer.app_settings import EXPLORER_CONNECTIONS\n\n        c1_alias = EXPLORER_CONNECTIONS[\"SQLite\"]\n        conn = DatabaseConnection.objects.get(alias=c1_alias).as_django_connection()\n        c = conn.cursor()\n        c.execute(\"CREATE TABLE IF NOT EXISTS animals (name text NOT NULL);\")\n        c.execute(\"INSERT INTO animals ( name ) VALUES ('peacock')\")\n\n        c2_alias = EXPLORER_CONNECTIONS[\"Another\"]\n        conn = DatabaseConnection.objects.get(alias=c2_alias).as_django_connection()\n        c = conn.cursor()\n        c.execute(\"CREATE TABLE IF NOT EXISTS animals (name text NOT NULL);\")\n        c.execute(\"INSERT INTO animals ( name ) VALUES ('superchicken')\")\n\n        url = reverse(\"download_sql\") + \"?format=csv\"\n\n        form_data = {\"sql\": \"select * from animals;\",\n                     \"title\": \"foo\",\n                     \"database_connection\": DatabaseConnection.objects.get(alias=c2_alias).id}\n        form = QueryForm(data=form_data)\n        self.assertTrue(form.is_valid())\n        response = self.client.post(url, form.data)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"superchicken\")\n\n    def test_sql_download_csv_with_custom_delim(self):\n        url = reverse(\"download_sql\") + \"?format=csv&delim=|\"\n        form_data = {\"sql\": \"select 1,2;\", \"title\": \"foo\", \"database_connection\": default_db_connection().id}\n        form = QueryForm(data=form_data)\n        self.assertTrue(form.is_valid())\n        response = self.client.post(url, form.data)\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n        self.assertEqual(response.content.decode(\"utf-8-sig\"), \"1|2\\r\\n1|2\\r\\n\")\n\n    def test_sql_download_csv_with_tab_delim(self):\n        url = reverse(\"download_sql\") + \"?format=csv&delim=tab\"\n\n        response = self.client.post(url, {\"sql\": \"select 1,2;\"})\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n        self.assertEqual(response.content.decode(\"utf-8-sig\"), \"1\\t2\\r\\n1\\t2\\r\\n\")\n\n    def test_sql_download_csv_with_bad_delim(self):\n        url = reverse(\"download_sql\") + \"?format=csv&delim=foo\"\n\n        response = self.client.post(url, {\"sql\": \"select 1,2;\"})\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"text/csv\")\n        self.assertEqual(response.content.decode(\"utf-8-sig\"), \"1,2\\r\\n1,2\\r\\n\")\n\n    def test_sql_download_json(self):\n        url = reverse(\"download_sql\") + \"?format=json\"\n\n        response = self.client.post(url, {\"sql\": \"select 1;\"})\n\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"content-type\"], \"application/json\")\n\n\nclass TestSchemaView(TestCase):\n\n    def setUp(self):\n        cache.clear()\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_returns_schema_contents(self):\n        resp = self.client.get(\n            reverse(\"explorer_schema\", kwargs={\"connection\": default_db_connection().id})\n        )\n        self.assertContains(resp, \"explorer_query\")\n        self.assertTemplateUsed(resp, \"explorer/schema.html\")\n\n    def test_returns_schema_contents_json(self):\n        resp = self.client.get(\n            reverse(\"explorer_schema_json\", kwargs={\"connection\": default_db_connection().id})\n        )\n        self.assertContains(resp, \"explorer_query\")\n        self.assertEqual(resp.headers[\"Content-Type\"], \"application/json\")\n\n    def test_returns_404_if_conn_doesnt_exist(self):\n        resp = self.client.get(\n            reverse(\"explorer_schema\", kwargs={\"connection\": \"bananas\"})\n        )\n        self.assertEqual(resp.status_code, 404)\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.get(\n            reverse(\"explorer_schema\", kwargs={\"connection\": default_db_connection().id})\n        )\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n\nclass TestFormat(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_returns_formatted_sql(self):\n        resp = self.client.post(\n            reverse(\"format_sql\"),\n            data={\"sql\": \"select * from explorer_query\"}\n        )\n        resp = json.loads(resp.content.decode(\"utf-8\"))\n        self.assertIn(\"\\n\", resp[\"formatted\"])\n        self.assertIn(\"explorer_query\", resp[\"formatted\"])\n\n\nclass TestParamsInViews(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.query = SimpleQueryFactory(sql=\"select $$swap$$;\")\n\n    def test_retrieving_query_works_with_params(self):\n        resp = self.client.get(\n            reverse(\n                \"query_detail\", kwargs={\"query_id\": self.query.id}\n            ) + \"?params=swap:123}\"\n        )\n        self.assertContains(resp, \"123\")\n\n    def test_saving_non_executing_query_with__wrong_url_params_works(self):\n        q = SimpleQueryFactory(sql=\"select $$swap$$;\")\n        data = model_to_dict(q)\n        url = \"{}?params={}\".format(\n            reverse(\"query_detail\", kwargs={\"query_id\": q.id}),\n            \"foo:123\"\n        )\n        resp = self.client.post(url, data)\n        self.assertContains(resp, \"saved\")\n\n    def test_users_without_change_permissions_can_use_params(self):\n        resp = self.client.get(\n            reverse(\n                \"query_detail\", kwargs={\"query_id\": self.query.id}\n            ) + \"?params=swap:123}\"\n        )\n        self.assertContains(resp, \"123\")\n\n\nclass TestCreatedBy(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.user2 = User.objects.create_superuser(\n            \"admin2\", \"admin2@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.query = SimpleQueryFactory.build(created_by_user=self.user)\n        self.data = model_to_dict(self.query)\n        del self.data[\"id\"]\n        self.data[\"created_by_user_id\"] = self.user2.id\n\n    def test_query_update_doesnt_change_created_user(self):\n        self.query.save()\n        self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": self.query.id}),\n            self.data\n        )\n        q = Query.objects.get(id=self.query.id)\n        self.assertEqual(q.created_by_user_id, self.user.id)\n\n    def test_new_query_gets_created_by_logged_in_user(self):\n        self.client.post(reverse(\"query_create\"), self.data)\n        q = Query.objects.first()\n        self.assertEqual(q.created_by_user_id, self.user.id)\n\n\nclass TestQueryLog(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_playground_saves_query_to_log(self):\n        self.client.post(reverse(\"explorer_playground\"), {\"sql\": \"select 1;\"})\n        log = QueryLog.objects.first()\n        self.assertTrue(log.is_playground)\n        self.assertEqual(log.sql, \"select 1;\")\n\n    # Since it will be saved on the initial query creation, no need to log it\n    def test_creating_query_does_not_save_to_log(self):\n        query = SimpleQueryFactory()\n        self.client.post(reverse(\"query_create\"), model_to_dict(query))\n        self.assertEqual(0, QueryLog.objects.count())\n\n    def test_query_saves_to_log(self):\n        query = SimpleQueryFactory()\n        data = model_to_dict(query)\n        data[\"sql\"] = \"select 12345;\"\n        self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            data\n        )\n        self.assertEqual(1, QueryLog.objects.count())\n\n    def test_query_gets_logged_and_appears_on_log_page(self):\n        query = SimpleQueryFactory()\n        data = model_to_dict(query)\n        data[\"sql\"] = \"select 12345;\"\n        self.client.post(\n            reverse(\"query_detail\", kwargs={\"query_id\": query.id}),\n            data\n        )\n        resp = self.client.get(reverse(\"explorer_logs\"))\n        self.assertContains(resp, \"select 12345;\")\n\n    def test_admin_required(self):\n        self.client.logout()\n        resp = self.client.get(reverse(\"explorer_logs\"))\n        self.assertTemplateUsed(resp, \"admin/login.html\")\n\n    def test_is_playground(self):\n        self.assertTrue(QueryLog(sql=\"foo\").is_playground)\n\n        q = SimpleQueryFactory()\n        self.assertFalse(QueryLog(sql=\"foo\", query_id=q.id).is_playground)\n\n\nclass TestEmailQuery(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    @patch(\"explorer.views.email.execute_query\")\n    def test_email_calls_task(self, mocked_execute):\n        query = SimpleQueryFactory()\n        url = reverse(\"email_csv_query\", kwargs={\"query_id\": query.id})\n        self.client.post(\n            url,\n            data={\"email\": \"foo@bar.com\"},\n        )\n        self.assertEqual(mocked_execute.delay.call_count, 1)\n\n    def test_no_email(self):\n        query = SimpleQueryFactory()\n        url = reverse(\"email_csv_query\", kwargs={\"query_id\": query.id})\n        response = self.client.post(\n            url,\n            data={},\n        )\n        self.assertEqual(response.status_code, 400)\n\n\nclass TestQueryFavorites(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.q = SimpleQueryFactory(title=\"query for x, y\")\n        QueryFavorite.objects.create(user=self.user, query=self.q)\n\n    def test_returns_favorite_list(self):\n        resp = self.client.get(\n            reverse(\"query_favorites\")\n        )\n        self.assertContains(resp, \"query for x, y\")\n\n\nclass TestQueryFavorite(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.q = SimpleQueryFactory(title=\"query for x, y\")\n\n    def test_toggle(self):\n        resp = self.client.post(\n            reverse(\"query_favorite\",  args=(self.q.id,))\n        )\n        resp = json.loads(resp.content.decode(\"utf-8\"))\n        self.assertTrue(resp[\"is_favorite\"])\n        resp = self.client.post(\n            reverse(\"query_favorite\",  args=(self.q.id,))\n        )\n        resp = json.loads(resp.content.decode(\"utf-8\"))\n        self.assertFalse(resp[\"is_favorite\"])\n\n\n@skipIf(not EXPLORER_USER_UPLOADS_ENABLED, \"User uploads not enabled\")\nclass UploadDbViewTest(TestCase):\n\n    def setUp(self):\n        DatabaseConnection.objects.uploads().delete()\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n\n    def test_post_csv_file(self):\n        file_content = \"col1,col2\\nval1,val2\\nval3,val4\"\n        uploaded_file = SimpleUploadedFile(\"test.csv\", file_content.encode(), content_type=\"text/csv\")\n\n        self.assertFalse(DatabaseConnection.objects.filter(alias=f\"test_{self.user.id}.db\").exists())\n\n        with patch(\"explorer.ee.db_connections.type_infer.csv_to_typed_df\") as mock_csv_to_typed_df, \\\n            patch(\"explorer.ee.db_connections.views.upload_sqlite\") as mock_upload_sqlite:\n            mock_csv_to_typed_df.return_value = MagicMock()\n\n            response = self.client.post(reverse(\"explorer_upload\"), {\"file\": uploaded_file})\n\n            self.assertEqual(response.status_code, 200)\n            self.assertJSONEqual(response.content, {\"success\": True})\n            self.assertTrue(DatabaseConnection.objects.filter(alias=f\"test_{self.user.id}.db\").exists())\n            mock_upload_sqlite.assert_called_once()\n            mock_csv_to_typed_df.assert_called_once()\n\n    # An end-to-end test that uploads a json file, verifies a connection was created, then issues a query\n    # using that connection and verifies the right data is returned.\n    @patch(\"explorer.ee.db_connections.views.upload_sqlite\")\n    def test_upload_file(self, mock_upload_sqlite):\n        self.assertFalse(DatabaseConnection.objects.filter(alias__contains=\"kings\").exists())\n\n        # Upload some JSON\n        file_path = os.path.join(os.getcwd(), \"explorer/tests/json/kings.json\")\n        with open(file_path, \"rb\") as f:\n            response = self.client.post(reverse(\"explorer_upload\"), {\"file\": f})\n\n        # Verify that the mock was called and the connection created\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(mock_upload_sqlite.call_count, 1)\n\n        # Query it and make sure that the reign of this particular king is indeed in the results.\n        conn = DatabaseConnection.objects.filter(alias__contains=\"kings\").first()\n        resp = self.client.post(\n            reverse(\"explorer_playground\"),\n            {\"sql\": \"select * from kings where Name = 'Athelstan';\", \"database_connection\": conn.id}\n        )\n        self.assertIn(\"925-940\", resp.content.decode(\"utf-8\"))\n\n        # Append a new table to the existing connection\n        file_path = os.path.join(os.getcwd(), \"explorer/tests/csvs/rc_sample.csv\")\n        with open(file_path, \"rb\") as f:\n            response = self.client.post(reverse(\"explorer_upload\"), {\"file\": f, \"append\": conn.id})\n\n        # Make sure it got re-uploaded\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(mock_upload_sqlite.call_count, 2)\n\n        # Query it and make sure a valid result is in the response. Note this is the *same* connection.\n        resp = self.client.post(\n            reverse(\"explorer_playground\"),\n            {\"sql\": \"select * from rc_sample where material_type = 'Steel';\", \"database_connection\": conn.id}\n        )\n        self.assertIn(\"Goudurix\", resp.content.decode(\"utf-8\"))\n\n        # Clean up filesystem\n        os.remove(conn.local_name)\n\n    def test_post_no_file(self):\n        response = self.client.post(reverse(\"explorer_upload\"))\n\n        self.assertEqual(response.status_code, 400)\n        self.assertJSONEqual(response.content, {\"error\": \"No file provided\"})\n\n    def test_delete_existing_connection(self):\n        dbc = DatabaseConnection.objects.create(\n            alias=\"test.db\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test.db\",\n            host=\"s3_path/test.db\"\n        )\n\n        with patch(\"explorer.ee.db_connections.views.delete_from_s3\") as mock_delete_from_s3:\n            response = self.client.delete(reverse(\"explorer_connection_delete\", kwargs={\"pk\": dbc.id}))\n\n            self.assertEqual(response.status_code, 302)\n            self.assertFalse(DatabaseConnection.objects.filter(alias=\"test.db\").exists())\n            mock_delete_from_s3.assert_called_once_with(\"s3_path/test.db\")\n\n    def test_delete_non_existing_connection(self):\n        response = self.client.delete(\"/upload/?alias=nonexistent.db\")\n\n        self.assertEqual(response.status_code, 404)\n\n    @patch(\"explorer.ee.db_connections.views.EXPLORER_MAX_UPLOAD_SIZE\", 1024*1024)\n    def test_post_file_too_large(self):\n        file_content = \"a\" * (1024 * 1024 + 1)  # Slightly larger 1 MB\n        uploaded_file = SimpleUploadedFile(\"large_file.csv\", file_content.encode(), content_type=\"text/csv\")\n\n        response = self.client.post(reverse(\"explorer_upload\"), {\"file\": uploaded_file})\n\n        self.assertEqual(response.status_code, 400)\n        self.assertJSONEqual(response.content, {\"error\": \"File size exceeds the limit of 1.0 MB\"})\n\n    @patch(\"explorer.ee.db_connections.views.parse_to_sqlite\")\n    def test_bad_parse_type(self, patched_parse):\n        patched_parse.side_effect = TypeError(\"didnt work\")\n        uploaded_file = SimpleUploadedFile(\"large_file.csv\", (\"a\"*10).encode(), content_type=\"text/foo\")\n        response = self.client.post(reverse(\"explorer_upload\"), {\"file\": uploaded_file})\n        self.assertEqual(json.loads(response.content.decode(\"utf-8\"))[\"error\"],\n                         \"Error parsing file.\")\n\n    def test_bad_parse_mime(self):\n        uploaded_file = SimpleUploadedFile(\"large_file.foo\", (\"a\" * 10).encode(), content_type=\"text/foo\")\n        response = self.client.post(reverse(\"explorer_upload\"), {\"file\": uploaded_file})\n        self.assertEqual(json.loads(response.content.decode(\"utf-8\"))[\"error\"],\n                         \"File was not csv, json, or sqlite.\")\n\n    @patch(\"explorer.ee.db_connections.views.is_sqlite\")\n    def test_cant_append_sqlite_to_file(self, patched_is_sqlite):\n        patched_is_sqlite.return_value = True\n        f = SimpleUploadedFile(\"large_file.foo\", (\"a\" * 10).encode(), content_type=\"text/foo\")\n        dbc = DatabaseConnection.objects.create(\n            alias=\"test.db\",\n            engine=DatabaseConnection.SQLITE,\n            name=\"test.db\",\n            host=\"s3_path/test.db\"\n        )\n        resp = self.client.post(reverse(\"explorer_upload\"), {\"file\": f, \"append\": dbc.id})\n        self.assertEqual(json.loads(resp.content.decode(\"utf-8\"))[\"error\"],\n                         \"Can't append a SQLite file to a SQLite file. Only CSV and JSON.\")\n\n\nclass DatabaseConnectionValidateViewTestCase(TestCase):\n\n    def setUp(self):\n\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.url = reverse(\"explorer_connection_validate\")\n        self.valid_data = {\n            \"alias\": \"test_alias\",\n            \"engine\": \"django.db.backends.sqlite3\",\n            \"name\": \":memory:\",\n            \"user\": \"\",\n            \"password\": \"\",\n            \"host\": \"\",\n            \"port\": \"\",\n        }\n        self.invalid_data = {\n            \"alias\": \"\",\n            \"engine\": \"\",\n            \"name\": \"\",\n            \"user\": \"\",\n            \"password\": \"\",\n            \"host\": \"\",\n            \"port\": \"\",\n        }\n\n    def test_validate_connection_success(self):\n        response = self.client.post(self.url, data=self.valid_data)\n        self.assertEqual(response.status_code, 200)\n        self.assertJSONEqual(response.content, {\"success\": True})\n\n    def test_validate_connection_invalid_form(self):\n        response = self.client.post(self.url, data=self.invalid_data)\n        self.assertEqual(response.status_code, 200)\n        self.assertJSONEqual(response.content, {\"success\": False, \"error\": \"Invalid form data\"})\n\n    def test_update_existing_connection(self):\n        DatabaseConnection.objects.create(alias=\"test_alias\", engine=\"django.db.backends.sqlite3\", name=\":memory:\")\n        response = self.client.post(self.url, data=self.valid_data)\n        self.assertEqual(response.status_code, 200)\n        self.assertJSONEqual(response.content, {\"success\": True})\n\n    @patch(\"explorer.ee.db_connections.models.load_backend\")\n    def test_database_connection_error(self, mock_load):\n        mock_load.side_effect = DatabaseError(\"Connection error\")\n        response = self.client.post(self.url, data=self.valid_data)\n        self.assertEqual(response.status_code, 200)\n        self.assertJSONEqual(response.content, {\"success\": False,\n                                                \"error\": \"Failed to create explorer connection: Connection error\"})\n\n\nclass TestDatabaseConnectionRefreshView(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.dbc = DatabaseConnection.objects.create(\n            alias=\"test_alias\",\n            engine=\"django.db.backends.sqlite3\",\n            name=\"test.db\",\n            host=\"foo\"\n        )\n        self.k = connection_schema_cache_key(self.dbc.id)\n        self.kj = connection_schema_json_cache_key(self.dbc.id)\n        cache.set(self.k, \"foo\")\n        cache.set(self.kj, \"foo\")\n\n    def test_refresh_connection(self):\n        # Create a file on disk\n        with open(self.dbc.local_name, \"w\") as f:\n            f.write(\"test data\")\n\n        # Ensure the file exists\n        self.assertTrue(os.path.exists(self.dbc.local_name))\n\n        # Make a GET call to refresh the connection\n        url = reverse(\"explorer_connection_refresh\", args=[self.dbc.id])\n        response = self.client.get(url)\n\n        # Assert the response is successful\n        self.assertEqual(response.status_code, 200)\n\n        # Assert that the file has been deleted\n        self.assertFalse(os.path.exists(self.dbc.local_name))\n\n        # Assert that the cache keys are clear\n        self.assertIsNone(cache.get(self.k))\n        self.assertIsNone(cache.get(self.kj))\n\n    def tearDown(self):\n        # Clean up any files that might have been created\n        if os.path.exists(self.dbc.local_name):\n            os.remove(self.dbc.local_name)\n\n\n# The idea is to render all of these views, to ensure that errors haven't been introduced in the templates\nclass SimpleViewTests(TestCase):\n\n    def setUp(self):\n        self.user = User.objects.create_superuser(\n            \"admin\", \"admin@admin.com\", \"pwd\"\n        )\n        self.client.login(username=\"admin\", password=\"pwd\")\n        self.connection = DatabaseConnection.objects.create(\n            alias=\"test_alias\",\n            engine=\"django.db.backends.sqlite3\",\n            name=\":memory:\",\n            user=\"\",\n            password=\"\",\n            host=\"\",\n            port=\"\"\n        )\n\n    def test_database_connection_detail_view(self):\n        response = self.client.get(reverse(\"explorer_connection_detail\", args=[self.connection.pk]))\n        self.assertEqual(response.status_code, 200)\n\n    def test_database_connection_create_view(self):\n        response = self.client.get(reverse(\"explorer_connection_create\"))\n        self.assertEqual(response.status_code, 200)\n\n    def test_database_connection_update_view(self):\n        response = self.client.get(reverse(\"explorer_connection_update\", args=[self.connection.pk]))\n        self.assertEqual(response.status_code, 200)\n\n    def test_database_connections_list_view(self):\n        response = self.client.get(reverse(\"explorer_connections\"))\n        self.assertEqual(response.status_code, 200)\n\n    def test_database_connection_delete_view(self):\n        response = self.client.get(reverse(\"explorer_connection_delete\", args=[self.connection.pk]))\n        self.assertEqual(response.status_code, 200)\n\n    def test_database_connection_upload_view(self):\n        response = self.client.get(reverse(\"explorer_upload_create\"))\n        self.assertEqual(response.status_code, 200)\n\n    def test_table_description_list_view(self):\n        td = TableDescription(database_connection=default_db_connection(), table_name=\"foo\", description=\"annotated\")\n        td.save()\n        response = self.client.get(reverse(\"table_description_list\"))\n        self.assertEqual(response.status_code, 200)\n\n        response = self.client.get(reverse(\"table_description_update\", args=[td.pk]))\n        self.assertEqual(response.status_code, 200)\n\n        response = self.client.get(reverse(\"table_description_create\"))\n        self.assertEqual(response.status_code, 200)\n"
  },
  {
    "path": "explorer/urls.py",
    "content": "from django.urls import path\n\nfrom explorer.ee.urls import ee_urls\nfrom explorer.views import (\n    CreateQueryView, DeleteQueryView, DownloadFromSqlView, DownloadQueryView, EmailCsvQueryView, ListQueryLogView,\n    ListQueryView, PlayQueryView, QueryFavoritesView, QueryFavoriteView, QueryView, SchemaJsonView, SchemaView,\n    StreamQueryView, format_sql\n)\nfrom explorer.assistant.urls import assistant_urls\n\nurlpatterns = [\n    path(\n        \"<int:query_id>/\", QueryView.as_view(), name=\"query_detail\"\n    ),\n    path(\n        \"<int:query_id>/download\", DownloadQueryView.as_view(),\n        name=\"download_query\"\n    ),\n    path(\n        \"<int:query_id>/stream\", StreamQueryView.as_view(),\n        name=\"stream_query\"\n    ),\n    path(\"download\", DownloadFromSqlView.as_view(), name=\"download_sql\"),\n    path(\n        \"<int:query_id>/email_csv\", EmailCsvQueryView.as_view(),\n        name=\"email_csv_query\"\n    ),\n    path(\n        \"<int:pk>/delete\", DeleteQueryView.as_view(), name=\"query_delete\"\n    ),\n    path(\"new/\", CreateQueryView.as_view(), name=\"query_create\"),\n    path(\"play/\", PlayQueryView.as_view(), name=\"explorer_playground\"),\n    path(\n        \"schema/<path:connection>\", SchemaView.as_view(),\n        name=\"explorer_schema\"\n    ),\n    path(\n        \"schema.json/<path:connection>\", SchemaJsonView.as_view(),\n        name=\"explorer_schema_json\"\n    ),\n    path(\"logs/\", ListQueryLogView.as_view(), name=\"explorer_logs\"),\n    path(\"format/\", format_sql, name=\"format_sql\"),\n    path(\"favorites/\", QueryFavoritesView.as_view(), name=\"query_favorites\"),\n    path(\"favorite/<int:query_id>\", QueryFavoriteView.as_view(), name=\"query_favorite\"),\n    path(\"\", ListQueryView.as_view(), name=\"explorer_index\"),\n]\n\nurlpatterns += assistant_urls\nurlpatterns += ee_urls\n"
  },
  {
    "path": "explorer/utils.py",
    "content": "import re\nimport os\nimport unicodedata\nfrom collections import deque\nfrom typing import Iterable, Tuple\n\nfrom django.contrib.auth import REDIRECT_FIELD_NAME\nfrom django.contrib.auth.forms import AuthenticationForm\nfrom django.contrib.auth.views import LoginView\n\nimport sqlparse\nfrom sqlparse import format as sql_format\nfrom sqlparse.sql import Token, TokenList\nfrom sqlparse.tokens import Keyword\n\nfrom explorer import app_settings\n\n\nEXPLORER_PARAM_TOKEN = \"$$\"\n\n\ndef passes_blacklist(sql: str) -> Tuple[bool, Iterable[str]]:\n    sql_strings = sqlparse.split(sql)\n    keyword_tokens = set()\n    for sql_string in sql_strings:\n        statements = sqlparse.parse(sql_string)\n        for statement in statements:\n            for token in walk_tokens(statement):\n                if not token.is_whitespace and not isinstance(token, TokenList):\n                    if token.ttype in Keyword:\n                        keyword_tokens.add(str(token.value).upper())\n\n    fails = [\n        bl_word\n        for bl_word in app_settings.EXPLORER_SQL_BLACKLIST\n        if bl_word.upper() in keyword_tokens\n    ]\n\n    return not bool(fails), fails\n\n\ndef walk_tokens(token: TokenList) -> Iterable[Token]:\n    \"\"\"\n    Generator to walk all tokens in a Statement\n    https://stackoverflow.com/questions/54982118/parse-case-when-statements-with-sqlparse\n    :param token: TokenList\n    \"\"\"\n    queue = deque([token])\n    while queue:\n        token = queue.popleft()\n        if isinstance(token, TokenList):\n            queue.extend(token)\n        yield token\n\n\ndef _format_field(field):\n    return field.get_attname_column()[1], field.get_internal_type()\n\n\ndef param(name):\n    return f\"{EXPLORER_PARAM_TOKEN}{name}{EXPLORER_PARAM_TOKEN}\"\n\n\ndef swap_params(sql, params):\n    p = params.items() if params else {}\n    for k, v in p:\n        fmt_k = re.escape(str(k).lower())\n        regex = re.compile(rf\"\\$\\${fmt_k}(?:\\|([^\\$\\:]+))?(?:\\:([^\\$]+))?\\$\\$\", re.I)\n        sql = regex.sub(str(v), sql)\n    return sql\n\n\ndef extract_params(text):\n    regex = re.compile(r\"\\$\\$([a-z0-9_]+)(?:\\|([^\\$\\:]+))?(?:\\:([^\\$]+))?\\$\\$\", re.IGNORECASE)\n    params = re.findall(regex, text)\n    # Matching will result to ('name', 'label', 'default')\n    return {\n        p[0].lower(): {\n            \"label\": p[1],\n            \"default\": p[2]\n        } for p in params if len(p) > 1\n    }\n\n\ndef safe_login_prompt(request):\n    defaults = {\n        \"template_name\": \"admin/login.html\",\n        \"authentication_form\": AuthenticationForm,\n        \"extra_context\": {\n            \"title\": \"Log in\",\n            \"app_path\": request.get_full_path(),\n            REDIRECT_FIELD_NAME: request.get_full_path(),\n        },\n    }\n    return LoginView.as_view(**defaults)(request)\n\n\ndef shared_dict_update(target, source):\n    for k_d1 in target:\n        if k_d1 in source:\n            target[k_d1] = source[k_d1]\n    return target\n\n\ndef safe_cast(val, to_type, default=None):\n    try:\n        return to_type(val)\n    except ValueError:\n        return default\n\n\ndef get_int_from_request(request, name, default):\n    val = request.GET.get(name, default)\n    return safe_cast(val, int, default) if val else None\n\n\ndef get_params_from_request(request):\n    val = request.GET.get(\"params\", None)\n    try:\n        d = {}\n        tuples = val.split(\"|\")\n        for t in tuples:\n            res = t.split(\":\")\n            d[res[0]] = res[1]\n        return d\n    except Exception:\n        return None\n\n\ndef get_params_for_url(query):\n    if query.params:\n        return \"|\".join([f\"{p}:{v}\" for p, v in query.params.items()])\n\n\ndef url_get_rows(request):\n    return get_int_from_request(\n        request, \"rows\", app_settings.EXPLORER_DEFAULT_ROWS\n    )\n\n\ndef url_get_query_id(request):\n    return get_int_from_request(request, \"query_id\", None)\n\n\ndef url_get_log_id(request):\n    return get_int_from_request(request, \"querylog_id\", None)\n\n\ndef url_get_show(request):\n    return bool(get_int_from_request(request, \"show\", 1))\n\n\ndef url_get_fullscreen(request):\n    return bool(get_int_from_request(request, \"fullscreen\", 0))\n\n\ndef url_get_params(request):\n    return get_params_from_request(request)\n\n\ndef allowed_query_pks(user_id):\n    return app_settings.EXPLORER_GET_USER_QUERY_VIEWS().get(user_id, [])\n\n\ndef user_can_see_query(request, **kwargs):\n    if not request.user.is_anonymous and \"query_id\" in kwargs:\n        return int(kwargs[\"query_id\"]) in allowed_query_pks(request.user.id)\n    return False\n\n\ndef fmt_sql(sql):\n    return sql_format(sql, reindent=True, keyword_case=\"upper\")\n\n\ndef noop_decorator(f):\n    return f\n\n\nclass InvalidExplorerConnectionException(Exception):\n    pass\n\n\ndef delete_from_s3(s3_path):\n    s3_bucket = get_s3_bucket()\n    s3_bucket.delete_objects(\n        Delete={\n            \"Objects\": [\n                {\"Key\": s3_path}\n            ]\n        }\n    )\n\n\ndef get_s3_bucket():\n    import boto3\n    from botocore.client import Config\n\n    config = Config(\n        signature_version=app_settings.S3_SIGNATURE_VERSION,\n        region_name=app_settings.S3_REGION\n    )\n\n    kwargs = {\"config\": config}\n\n    # If these are set, use them. Otherwise, boto will use its built-in mechanisms\n    # to provide authentication.\n    if app_settings.S3_ACCESS_KEY and app_settings.S3_SECRET_KEY:\n        kwargs[\"aws_access_key_id\"] = app_settings.S3_ACCESS_KEY\n        kwargs[\"aws_secret_access_key\"] = app_settings.S3_SECRET_KEY\n\n    if app_settings.S3_ENDPOINT_URL:\n        kwargs[\"endpoint_url\"] = app_settings.S3_ENDPOINT_URL\n\n    s3 = boto3.resource(\"s3\", **kwargs)\n\n    return s3.Bucket(name=app_settings.S3_BUCKET)\n\n\ndef s3_csv_upload(key, data):\n    if app_settings.S3_DESTINATION:\n        key = \"/\".join([app_settings.S3_DESTINATION, key])\n    bucket = get_s3_bucket()\n    bucket.upload_fileobj(data, key, ExtraArgs={\"ContentType\": \"text/csv\"})\n    return s3_url(bucket, key)\n\n\ndef s3_url(bucket, key):\n    url = bucket.meta.client.generate_presigned_url(\n        ClientMethod=\"get_object\",\n        Params={\"Bucket\": app_settings.S3_BUCKET, \"Key\": key},\n        ExpiresIn=app_settings.S3_LINK_EXPIRATION)\n    return url\n\n\ndef is_xls_writer_available():\n    try:\n        import xlsxwriter  # noqa\n        return True\n    except ImportError:\n        return False\n\n\ndef secure_filename(filename):\n    filename, ext = os.path.splitext(filename)\n    if not filename and not ext:\n        raise ValueError(\"Filename or extension cannot be blank\")\n    if ext.lower() not in [\".db\", \".sqlite\", \".sqlite3\", \".csv\", \".json\", \".txt\"]:\n        raise ValueError(f\"Invalid extension: {ext}\")\n\n    filename = unicodedata.normalize(\"NFKD\", filename).encode(\"ascii\", \"ignore\").decode(\"ascii\")\n    filename = re.sub(r\"[^a-zA-Z0-9_.-]\", \"_\", filename)\n    filename = filename.strip(\"._\")\n    if not filename:  # If filename becomes empty, replace it with an underscore\n        filename = \"_\"\n    return f\"{filename}{ext}\"\n"
  },
  {
    "path": "explorer/views/__init__.py",
    "content": "from .auth import PermissionRequiredMixin, SafeLoginView\nfrom .create import CreateQueryView\nfrom .delete import DeleteQueryView\nfrom .download import DownloadFromSqlView, DownloadQueryView\nfrom .email import EmailCsvQueryView\nfrom .format_sql import format_sql\nfrom .list import ListQueryLogView, ListQueryView\nfrom .query import PlayQueryView, QueryView\nfrom .query_favorite import QueryFavoritesView, QueryFavoriteView\nfrom .schema import SchemaJsonView, SchemaView\nfrom .stream import StreamQueryView\n\n__all__ = [\n    \"CreateQueryView\",\n    \"DeleteQueryView\",\n    \"DownloadQueryView\",\n    \"DownloadFromSqlView\",\n    \"EmailCsvQueryView\",\n    \"ListQueryView\",\n    \"ListQueryLogView\",\n    \"PermissionRequiredMixin\",\n    \"PlayQueryView\",\n    \"QueryView\",\n    \"SafeLoginView\",\n    \"StreamQueryView\",\n    \"SchemaJsonView\",\n    \"SchemaView\",\n    \"format_sql\",\n    \"QueryFavoritesView\",\n    \"QueryFavoriteView\",\n]\n"
  },
  {
    "path": "explorer/views/auth.py",
    "content": "from django.contrib.auth import REDIRECT_FIELD_NAME\nfrom django.contrib.auth.views import LoginView\nfrom django.core.exceptions import ImproperlyConfigured\n\nfrom explorer import app_settings, permissions\n\n\nclass PermissionRequiredMixin:\n\n    permission_required = None\n\n    @staticmethod\n    def handle_no_permission(request):\n        return app_settings.EXPLORER_NO_PERMISSION_VIEW()(request)\n\n    def get_permission_required(self):\n        if self.permission_required is None:\n            raise ImproperlyConfigured(\n                f\"{self.__class__.__name__} is missing the permission_required attribute. \"\n                f\"Define {self.__class__.__name__}.permission_required, or override \"\n                f\"{self.__class__.__name__}.get_permission_required().\"\n            )\n        return self.permission_required\n\n    def has_permission(self, request, *args, **kwargs):\n        perms = self.get_permission_required()\n\n        # TODO: fix the case when the perms is not defined in\n        #  permissions module.\n        handler = getattr(permissions, perms)\n        return handler(request, *args, **kwargs)\n\n    def dispatch(self, request, *args, **kwargs):\n        if not self.has_permission(request, *args, **kwargs):\n            return self.handle_no_permission(request)\n        return super().dispatch(request, *args, **kwargs)\n\n\nclass SafeLoginView(LoginView):\n    template_name = \"admin/login.html\"\n\n\ndef safe_login_view_wrapper(request):\n    return SafeLoginView.as_view(\n        extra_context={\n            \"title\": \"Log in\",\n            REDIRECT_FIELD_NAME: request.get_full_path()\n        }\n    )(request)\n"
  },
  {
    "path": "explorer/views/create.py",
    "content": "from django.views.generic import CreateView\n\nfrom explorer.forms import QueryForm\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\n\n\nclass CreateQueryView(PermissionRequiredMixin, ExplorerContextMixin,\n                      CreateView):\n\n    permission_required = \"change_permission\"\n    form_class = QueryForm\n    template_name = \"explorer/query.html\"\n\n    def form_valid(self, form):\n        form.instance.created_by_user = self.request.user\n        return super().form_valid(form)\n"
  },
  {
    "path": "explorer/views/delete.py",
    "content": "from django.urls import reverse_lazy\nfrom django.views.generic import DeleteView\n\nfrom explorer.models import Query\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\n\n\nclass DeleteQueryView(PermissionRequiredMixin, ExplorerContextMixin,\n                      DeleteView):\n\n    permission_required = \"change_permission\"\n    model = Query\n    success_url = reverse_lazy(\"explorer_index\")\n"
  },
  {
    "path": "explorer/views/download.py",
    "content": "from django.shortcuts import get_object_or_404\nfrom django.views.generic.base import View\n\nfrom explorer.models import Query\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.export import _export\nfrom explorer.ee.db_connections.utils import default_db_connection_id\n\n\nclass DownloadQueryView(PermissionRequiredMixin, View):\n\n    permission_required = \"view_permission\"\n\n    def get(self, request, query_id, *args, **kwargs):\n        query = get_object_or_404(Query, pk=query_id)\n        return _export(request, query)\n\n\nclass DownloadFromSqlView(PermissionRequiredMixin, View):\n\n    permission_required = \"view_permission\"\n\n    def post(self, request, *args, **kwargs):\n        sql = request.POST.get(\"sql\", \"\")\n        connection = request.POST.get(\"database_connection\", default_db_connection_id())\n        query = Query(sql=sql, database_connection_id=connection, title=\"\")\n        ql = query.log(request.user)\n        query.title = f\"Playground-{ql.id}\"\n        return _export(request, query)\n"
  },
  {
    "path": "explorer/views/email.py",
    "content": "from django.http import JsonResponse\nfrom django.views import View\n\nfrom explorer.tasks import execute_query\nfrom explorer.views.auth import PermissionRequiredMixin\n\n\nclass EmailCsvQueryView(PermissionRequiredMixin, View):\n\n    permission_required = \"view_permission\"\n\n    def post(self, request, query_id, *args, **kwargs):\n        email = request.POST.get(\"email\", None)\n        if not email:\n            return JsonResponse(\n                {\"error\": \"email is required\"},\n                status=400,\n            )\n\n        execute_query.delay(query_id, email)\n\n        return JsonResponse({\"message\": \"message was sent successfully\"})\n"
  },
  {
    "path": "explorer/views/export.py",
    "content": "from django.db import DatabaseError\nfrom django.http import HttpResponse\n\nfrom explorer.exporters import get_exporter_class\nfrom explorer.utils import url_get_params\n\n\ndef _export(request, query, download=True):\n    _fmt = request.GET.get(\"format\", \"csv\")\n    exporter_class = get_exporter_class(_fmt)\n    query.params = url_get_params(request)\n    delim = request.GET.get(\"delim\")\n    exporter = exporter_class(query)\n    try:\n        output = exporter.get_output(delim=delim)\n    except DatabaseError as e:\n        msg = f\"Error executing query {query.title}: {e}\"\n        return HttpResponse(\n            msg, status=500\n        )\n\n    response = HttpResponse(\n        output,\n        content_type=exporter.content_type\n    )\n    if download:\n        response[\"Content-Disposition\"] = \\\n            f'attachment; filename=\"{exporter.get_filename()}\"'\n    return response\n"
  },
  {
    "path": "explorer/views/format_sql.py",
    "content": "from django.http import JsonResponse\nfrom django.views.decorators.http import require_POST\n\nfrom explorer.utils import fmt_sql\n\n\n@require_POST\ndef format_sql(request):\n    sql = request.POST.get(\"sql\", \"\")\n    formatted = fmt_sql(sql)\n    return JsonResponse({\"formatted\": formatted})\n"
  },
  {
    "path": "explorer/views/list.py",
    "content": "import re\nfrom collections import Counter\n\nfrom django.forms.models import model_to_dict\nfrom django.views.generic import ListView\n\nfrom explorer import app_settings\nfrom explorer.models import Query, QueryFavorite, QueryLog\nfrom explorer.utils import allowed_query_pks, url_get_query_id\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\nfrom explorer.ee.db_connections.models import DatabaseConnection\n\n\nclass ListQueryView(PermissionRequiredMixin, ExplorerContextMixin, ListView):\n    permission_required = \"view_permission_list\"\n    model = Query\n\n    def recently_viewed(self):\n        qll = QueryLog.objects.filter(\n            run_by_user=self.request.user, query_id__isnull=False\n        ).order_by(\n            \"-run_at\"\n        ).select_related(\"query\")\n\n        ret = []\n        tracker = []\n        for ql in qll:\n            if len(ret) == app_settings.EXPLORER_RECENT_QUERY_COUNT:\n                break\n\n            if ql.query_id not in tracker:\n                ret.append(ql)\n                tracker.append(ql.query_id)\n        return ret\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\"object_list\"] = self._build_queries_and_headers()\n        context[\"connection_count\"] = DatabaseConnection.objects.count()\n        context[\"recent_queries\"] = self.recently_viewed()\n        context[\"tasks_enabled\"] = app_settings.ENABLE_TASKS\n        context[\"vite_dev_mode\"] = app_settings.VITE_DEV_MODE\n        return context\n\n    def get_queryset(self):\n        if app_settings.EXPLORER_PERMISSION_VIEW(self.request):\n            qs = (\n                Query.objects.prefetch_related(\n                    \"created_by_user\", \"querylog_set\"\n                ).all()\n            )\n        else:\n            qs = (\n                Query.objects.prefetch_related(\n                    \"created_by_user\", \"querylog_set\"\n                ).filter(pk__in=allowed_query_pks(self.request.user.id))\n            )\n        return qs\n\n    def _build_queries_and_headers(self):\n        \"\"\"\n        Build a list of query information and headers (pseudo-folders) for\n        consumption by the template.\n\n        Strategy: Look for queries with titles of the form \"something - else\"\n                  (eg. with a ' - ' in the middle) and split on the ' - ',\n                  treating the left side as a \"header\" (or folder).\n                  Interleave the headers into the ListView's object_list as\n                  appropriate. Ignore headers that only have one child.\n                  The front end uses bootstrap's JS Collapse plugin, which\n                  necessitates generating CSS classes to map the header onto\n                  the child rows, hence the collapse_target variable.\n\n                  To make the return object homogeneous, convert the\n                  object_list models into dictionaries for interleaving with\n                  the header \"objects\". This necessitates special handling of\n                  'created_at' and 'created_by_user' because model_to_dict\n                  doesn't include non-editable fields (created_at) and will\n                  give the int representation of the user instead of the\n                  string representation.\n\n        :return: A list of model dictionaries representing all the query\n                 objects, interleaved with header dictionaries.\n        :rtype: list\n        \"\"\"\n\n        dict_list = []\n        rendered_headers = []\n        pattern = re.compile(r\"[\\W_]+\")\n\n        headers = Counter([q.title.split(\" - \")[0] for q in self.object_list])\n        query_favorites_for_user = QueryFavorite.objects.filter(user_id=self.request.user.pk).values_list(\"query_id\",\n                                                                                                          flat=True)\n\n        for q in self.object_list:\n            model_dict = model_to_dict(q)\n            header = q.title.split(\" - \")[0]\n            collapse_target = pattern.sub(\"\", header)\n\n            if headers[header] > 1 and header not in rendered_headers:\n                dict_list.append({\n                    \"title\": header,\n                    \"is_header\": True,\n                    \"is_in_category\": False,\n                    \"collapse_target\": collapse_target,\n                    \"count\": headers[header]\n                })\n                rendered_headers.append(header)\n\n            lrl = q.last_run_log()\n            model_dict.update({\n                \"is_in_category\": headers[header] > 1,\n                \"collapse_target\": collapse_target,\n                \"created_at\": q.created_at,\n                \"is_header\": False,\n                \"run_count\": q.querylog_set.count(),\n                \"connection_name\": str(q.database_connection),\n                \"ran_successfully\": lrl.success,\n                \"last_run_at\": lrl.run_at,\n                \"created_by_user\":\n                    str(q.created_by_user) if q.created_by_user else None,\n                \"is_favorite\": q.id in query_favorites_for_user\n            })\n            dict_list.append(model_dict)\n        return dict_list\n\n\nclass ListQueryLogView(PermissionRequiredMixin, ExplorerContextMixin, ListView):\n    context_object_name = \"recent_logs\"\n    model = QueryLog\n    paginate_by = 20\n    permission_required = \"view_permission\"\n\n    def get_queryset(self):\n        kwargs = {\"sql__isnull\": False}\n        if url_get_query_id(self.request):\n            kwargs[\"query_id\"] = url_get_query_id(self.request)\n        return QueryLog.objects.filter(**kwargs).all()\n"
  },
  {
    "path": "explorer/views/mixins.py",
    "content": "from django.conf import settings\nfrom django.shortcuts import render\n\nfrom explorer import app_settings\n\n\nclass ExplorerContextMixin:\n\n    def gen_ctx(self):\n        return {\n            \"can_view\": app_settings.EXPLORER_PERMISSION_VIEW(\n                self.request\n            ),\n            \"can_change\": app_settings.EXPLORER_PERMISSION_CHANGE(\n                self.request\n            ),\n            \"can_manage_connections\": app_settings.EXPLORER_PERMISSION_CONNECTIONS(\n                self.request\n            ),\n            \"assistant_enabled\": app_settings.has_assistant(),\n            \"db_connections_enabled\": app_settings.db_connections_enabled(),\n            \"user_uploads_enabled\": app_settings.user_uploads_enabled(),\n            \"csrf_cookie_name\": settings.CSRF_COOKIE_NAME,\n            \"csrf_token_in_dom\": settings.CSRF_COOKIE_HTTPONLY or settings.CSRF_USE_SESSIONS,\n            \"view_name\": self.request.resolver_match.view_name,\n            \"hosted\": app_settings.EXPLORER_HOSTED\n        }\n\n    def get_context_data(self, **kwargs):\n        ctx = super().get_context_data(**kwargs)\n        ctx.update(self.gen_ctx())\n        return ctx\n\n    def render_template(self, template, ctx):\n        ctx.update(self.gen_ctx())\n        return render(self.request, template, ctx)\n"
  },
  {
    "path": "explorer/views/query.py",
    "content": "from django.core.exceptions import ValidationError\nfrom django.http import HttpResponseRedirect\nfrom django.shortcuts import get_object_or_404\nfrom django.urls import reverse_lazy\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views import View\n\nfrom explorer import app_settings\nfrom explorer.forms import QueryForm\nfrom explorer.models import MSG_FAILED_BLACKLIST, Query, QueryLog\nfrom explorer.utils import (\n    url_get_fullscreen, url_get_log_id, url_get_params, url_get_query_id, url_get_rows, url_get_show,\n    InvalidExplorerConnectionException\n)\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\nfrom explorer.views.utils import query_viewmodel\nfrom explorer.ee.db_connections.utils import default_db_connection_id\n\n\nclass PlayQueryView(PermissionRequiredMixin, ExplorerContextMixin, View):\n    permission_required = \"change_permission\"\n\n    def get(self, request):\n        if url_get_query_id(request):\n            query = get_object_or_404(Query, pk=url_get_query_id(request))\n            return self.render_with_sql(request, query, run_query=False)\n\n        if url_get_log_id(request):\n            log = get_object_or_404(QueryLog, pk=url_get_log_id(request))\n            c = log.database_connection_id or \"\"\n            query = Query(sql=log.sql, title=\"Playground\", database_connection_id=c)\n            return self.render_with_sql(request, query)\n\n        return self.render()\n\n    def post(self, request):\n        c = request.POST.get(\"database_connection\", default_db_connection_id())\n        show = url_get_show(request)\n        sql = request.POST.get(\"sql\", \"\")\n\n        query = Query(sql=sql, title=\"Playground\", database_connection_id=c)\n\n        passes_blacklist, failing_words = query.passes_blacklist()\n\n        error = MSG_FAILED_BLACKLIST % \", \".join(\n            failing_words\n        ) if not passes_blacklist else None\n\n        run_query = not bool(error) if show else False\n        return self.render_with_sql(\n            request,\n            query,\n            run_query=run_query,\n            error=error\n        )\n\n    def render(self):\n        return self.render_template(\n            \"explorer/play.html\",\n            {\n                \"title\": \"Playground\",\n                \"form\": QueryForm()\n            }\n        )\n\n    def render_with_sql(self, request, query, run_query=True, error=None):\n        rows = url_get_rows(request)\n        fullscreen = url_get_fullscreen(request)\n        template = \"fullscreen\" if fullscreen else \"play\"\n        form = QueryForm(\n            request.POST if len(request.POST) else None,\n            instance=query\n        )\n        return self.render_template(\n            f\"explorer/{template}.html\",\n            query_viewmodel(\n                request,\n                query,\n                title=\"Playground\",\n                run_query=run_query,\n                error=error,\n                rows=rows,\n                form=form\n            )\n        )\n\n\nclass QueryView(PermissionRequiredMixin, ExplorerContextMixin, View):\n    permission_required = \"view_permission\"\n\n    def get(self, request, query_id):\n        query, form = QueryView.get_instance_and_form(request, query_id)\n        query.save()  # updates the modified date\n        show = url_get_show(request)\n        rows = url_get_rows(request)\n        params = query.available_params()\n        if not app_settings.EXPLORER_AUTORUN_QUERY_WITH_PARAMS and params:\n            show = False\n        try:\n            vm = query_viewmodel(\n                request,\n                query,\n                form=form,\n                run_query=show,\n                rows=rows\n            )\n        except InvalidExplorerConnectionException as e:\n            vm = query_viewmodel(\n                request,\n                query,\n                form=form,\n                run_query=False,\n                error=str(e)\n            )\n        fullscreen = url_get_fullscreen(request)\n        template = \"fullscreen\" if fullscreen else \"query\"\n        return self.render_template(\n            f\"explorer/{template}.html\", vm\n        )\n\n    def post(self, request, query_id):\n        if not app_settings.EXPLORER_PERMISSION_CHANGE(request):\n            return HttpResponseRedirect(\n                reverse_lazy(\"query_detail\", kwargs={\"query_id\": query_id})\n            )\n        show = url_get_show(request)\n        query, form = QueryView.get_instance_and_form(request, query_id)\n        success = form.is_valid() and form.save()\n        try:\n            vm = query_viewmodel(\n                request,\n                query,\n                form=form,\n                run_query=show,\n                rows=url_get_rows(request),\n                message=_(\"Query saved.\") if success else None\n            )\n        except ValidationError as ve:\n            vm = query_viewmodel(\n                request,\n                query,\n                form=form,\n                run_query=False,\n                rows=url_get_rows(request),\n                error=ve.message\n            )\n        return self.render_template(\"explorer/query.html\", vm)\n\n    @staticmethod\n    def get_instance_and_form(request, query_id):\n        query = get_object_or_404(Query.objects.prefetch_related(\"favorites\"), pk=query_id)\n        query.params = url_get_params(request)\n        form = QueryForm(\n            request.POST if len(request.POST) else None,\n            instance=query\n        )\n        return query, form\n"
  },
  {
    "path": "explorer/views/query_favorite.py",
    "content": "from django.http import JsonResponse\nfrom django.views import View\n\nfrom explorer.models import QueryFavorite\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.mixins import ExplorerContextMixin\n\n\nclass QueryFavoritesView(PermissionRequiredMixin, ExplorerContextMixin, View):\n    permission_required = \"view_permission\"\n\n    def get(self, request):\n        favorites = QueryFavorite.objects.filter(user=request.user).select_related(\"query\", \"user\").order_by(\n            \"query__title\")\n        return self.render_template(\n            \"explorer/query_favorites.html\", {\"favorites\": favorites}\n        )\n\n\nclass QueryFavoriteView(PermissionRequiredMixin, ExplorerContextMixin, View):\n    permission_required = \"view_permission\"\n\n    @staticmethod\n    def build_favorite_response(user, query_id):\n        is_favorite = QueryFavorite.objects.filter(user=user, query_id=query_id).exists()\n        data = {\n            \"status\": \"success\",\n            \"query_id\": query_id,\n            \"is_favorite\": is_favorite\n        }\n        return data\n\n    def get(self, request, query_id):\n        return JsonResponse(QueryFavoriteView.build_favorite_response(request.user, query_id))\n\n    def post(self, request, query_id):\n        # toggle favorite\n        if QueryFavorite.objects.filter(user=request.user, query_id=query_id).exists():\n            QueryFavorite.objects.filter(user=request.user, query_id=query_id).delete()\n        else:\n            QueryFavorite.objects.get_or_create(user=request.user, query_id=query_id)\n        return JsonResponse(QueryFavoriteView.build_favorite_response(request.user, query_id))\n"
  },
  {
    "path": "explorer/views/schema.py",
    "content": "from django.http import Http404, JsonResponse\nfrom django.shortcuts import render, get_object_or_404\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\nfrom django.views.decorators.clickjacking import xframe_options_sameorigin\nfrom explorer.ee.db_connections.models import DatabaseConnection\nfrom explorer.ee.db_connections.utils import default_db_connection_id\n\nfrom explorer.schema import schema_info, schema_json_info\nfrom explorer.views.auth import PermissionRequiredMixin\n\n\nclass SchemaView(PermissionRequiredMixin, View):\n\n    permission_required = \"change_permission\"\n\n    @method_decorator(xframe_options_sameorigin)\n    def dispatch(self, *args, **kwargs):\n        return super().dispatch(*args, **kwargs)\n\n    def get(self, request, *args, **kwargs):\n        connection_id = kwargs.get(\"connection\", default_db_connection_id())\n        try:\n            connection = DatabaseConnection.objects.get(id=connection_id)\n        except DatabaseConnection.DoesNotExist as e:\n            raise Http404 from e\n        except ValueError as e:\n            raise Http404 from e\n        schema = schema_info(connection)\n        if schema:\n            return render(\n                request,\n                \"explorer/schema.html\",\n                {\"schema\": schema}\n            )\n        else:\n            return render(request,\n                          \"explorer/schema_error.html\",\n                          {\"connection\": connection.alias})\n\n\nclass SchemaJsonView(PermissionRequiredMixin, View):\n\n    permission_required = \"change_permission\"\n\n    def get(self, request, *args, **kwargs):\n        connection = kwargs.get(\"connection\", default_db_connection_id())\n        conn = get_object_or_404(DatabaseConnection, id=connection)\n        return JsonResponse(schema_json_info(conn))\n"
  },
  {
    "path": "explorer/views/stream.py",
    "content": "from django.shortcuts import get_object_or_404\nfrom django.views import View\n\nfrom explorer.models import Query\nfrom explorer.views.auth import PermissionRequiredMixin\nfrom explorer.views.export import _export\nfrom explorer.telemetry import Stat, StatNames\n\n\nclass StreamQueryView(PermissionRequiredMixin, View):\n\n    permission_required = \"view_permission\"\n\n    def get(self, request, query_id, *args, **kwargs):\n        query = get_object_or_404(Query, pk=query_id)\n        Stat(StatNames.QUERY_STREAM, {\n            \"fmt\": request.GET.get(\"format\", \"csv\"),\n        }).track()\n        return _export(request, query, download=False)\n"
  },
  {
    "path": "explorer/views/utils.py",
    "content": "from django.db import DatabaseError\nimport logging\nfrom explorer import app_settings\nfrom explorer.charts import get_chart\nfrom explorer.models import QueryFavorite\nfrom explorer.schema import schema_json_info\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef query_viewmodel(request, query, title=None, form=None, message=None,\n                    run_query=True, error=None,\n                    rows=app_settings.EXPLORER_DEFAULT_ROWS):\n    \"\"\"\n\n    :return: Returns the context required for a view\n    :rtype: dict\n    \"\"\"\n    res = None\n    ql = None\n    if run_query:\n        try:\n            res, ql = query.execute_with_logging(request.user)\n        except DatabaseError as e:\n            error = str(e)\n    has_valid_results = not error and res and run_query\n\n    fullscreen_params = request.GET.copy()\n    if \"fullscreen\" not in fullscreen_params:\n        fullscreen_params.update({\n            \"fullscreen\": 1\n        })\n    if \"rows\" not in fullscreen_params:\n        fullscreen_params.update({\n            \"rows\": rows\n        })\n    if \"querylog_id\" not in fullscreen_params and ql:\n        fullscreen_params.update({\n            \"querylog_id\": ql.id\n        })\n\n    user = request.user\n    is_favorite = False\n    if user.is_authenticated and query.pk:\n        is_favorite = QueryFavorite.objects.filter(user=user, query=query).exists()\n\n    charts = {\"line_chart_svg\": None,\n              \"bar_chart_svg\": None}\n\n    try:\n        if app_settings.EXPLORER_CHARTS_ENABLED and has_valid_results:\n            charts[\"line_chart_svg\"] = get_chart(res,\"line\", rows)\n            charts[\"bar_chart_svg\"] = get_chart(res,\"bar\", rows)\n    except TypeError as e:\n        if ql is not None:\n            msg = f\"Error generating charts for querylog {ql.id}: {e}\"\n        else:\n            msg = f\"Error generating charts for query {query.id}: {e}\"\n        logger.error(msg)\n\n    ret = {\n        \"tasks_enabled\": app_settings.ENABLE_TASKS,\n        \"params\": query.available_params_w_labels(),\n        \"title\": title,\n        \"shared\": query.shared,\n        \"query\": query,\n        \"form\": form,\n        \"message\": message,\n        \"error\": error,\n        \"rows\": rows,\n        \"query_id\": query.id,\n        \"data\": res.data[:rows] if has_valid_results else None,\n        \"headers\": res.headers if has_valid_results else None,\n        \"total_rows\": len(res.data) if has_valid_results else None,\n        \"duration\": res.duration if has_valid_results else None,\n        \"has_stats\":\n            len([h for h in res.headers if h.summary])\n            if has_valid_results else False,\n        \"snapshots\": query.snapshots if query.snapshot else [],\n        \"ql_id\": ql.id if ql else None,\n        \"unsafe_rendering\": app_settings.UNSAFE_RENDERING,\n        \"fullscreen_params\": fullscreen_params.urlencode(),\n        \"charts_enabled\": app_settings.EXPLORER_CHARTS_ENABLED,\n        \"is_favorite\": is_favorite,\n        \"show_sql_by_default\": app_settings.EXPLORER_SHOW_SQL_BY_DEFAULT,\n        \"schema_json\": schema_json_info(query.database_connection) if query and query.database_connection else None,\n    }\n    return {**ret, **charts}\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python\nimport os\nimport sys\n\nfrom django.core import management\n\nsys.path.append(os.path.join(os.path.dirname(__file__), \"explorer\"))\nos.environ[\"DJANGO_SETTINGS_MODULE\"] = \"test_project.settings\"\n\nif __name__ == \"__main__\":\n    management.execute_from_command_line()\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"django-sql-explorer\",\n  \"description\": \"Django SQL Explorer\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"export APP_VERSION=$(python -c 'from explorer import __version__; print(__version__)') && npx vite --config vite.config.mjs\",\n    \"build\": \"export APP_VERSION=$(python -c 'from explorer import __version__; print(__version__)') && npx vite build --config vite.config.mjs\",\n    \"preview\": \"export APP_VERSION=$(python -c 'from explorer import __version__; print(__version__)') && npx vite preview --config vite.config.mjs\"\n  },\n  \"devDependencies\": {\n    \"sass\": \"~1.69.0\",\n    \"vite\": \"^5.4.6\",\n    \"vite-plugin-copy\": \"^0.1.6\",\n    \"vite-plugin-static-copy\": \"^1.0.5\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/explorerhq/sql-explorer.git\"\n  },\n  \"author\": \"Chris Clark\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/explorerhq/sql-explorer/issues\"\n  },\n  \"homepage\": \"https://www.sqlexplorer.io\",\n  \"dependencies\": {\n    \"@codemirror/lang-sql\": \"^6.5.4\",\n    \"@codemirror/language-data\": \"^6.3.1\",\n    \"bootstrap\": \"^5.0.1\",\n    \"bootstrap-icons\": \"^1.11.2\",\n    \"choices.js\": \"^10.2.0\",\n    \"codemirror\": \"^6.0.1\",\n    \"cookiejs\": \"^2.1.3\",\n    \"dompurify\": \"^3.1.3\",\n    \"jquery\": \"^3.7.1\",\n    \"list.js\": \"^2.3.1\",\n    \"marked\": \"^11.1.1\",\n    \"sortablejs\": \"^1.15.2\"\n  },\n  \"optionalDependencies\": {\n    \"@rollup/rollup-linux-x64-gnu\": \"^4.18.1\"\n  }\n}\n"
  },
  {
    "path": "public_key.pem",
    "content": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr1Lfhtla6gpaBlKb2r9s\n5udyW6zHt0my9924sFtJ8OWzWpUFzGTDHySclWfKJkxdAS//zVhID0+i3FvkMCAe\nkldnU9qDvSWx40zebWZ6JlnYA/cEl2XbiXOrLyAueeYZsr0Y1ZomMbFuMee8Kmjl\ngj/YV+XGzlA3BBT3ajeCsBEYuSUIuGKsn7VUG6fjKX/TsOKNcGOCCXXyE9gcsrSe\nJ4h63eJTN+IZBC78E9f+0y8VqWZ/k1lpmPFLMSwr/Z5oeS1mvadFIikRq6UWNBC7\nYZxh7XSsfDSC1oaTSCgWiN+DDvK+mhBC4tpm1vJJ6lFWjS/pUrCoas0BMU5r3QJp\noQIDAQAB\n-----END PUBLIC KEY-----\n"
  },
  {
    "path": "pypi-release-checklist.md",
    "content": "- [x] Update HISTORY\n- [x] Update README and check formatting with http://rst.ninjs.org/\n- [x] Make sure any new files are included in MANIFEST.in\n- [x] Update version number in `explorer/__init__.py`\n- [x] Update any package dependencies in `setup.py`\n- [x] Commit the changes and add the tag *in master*:\n```\ngit add .\ngit commit -m \"Release 1.0.0\"\ngit tag -a \"1.0.0\"\ngit push\ngit push --tags\n```\n\n- Be sure to test the built JS source by running `npm run build` and setting `VITE_DEV_MODE = False` in settings.py\n\n- [x] Check the PyPI listing page (https://pypi.python.org/pypi/django-sql-explorer) to make sure that the release\n  went out and that the README is displaying properly.\n"
  },
  {
    "path": "requirements/base.txt",
    "content": "Django>=3.2\nsqlparse>=0.4.0\nrequests>=2.2\ndjango-cryptography-django5==2.2\ncryptography>=42.0\n"
  },
  {
    "path": "requirements/dev.txt",
    "content": "-r ./base.txt\n-r ./extra/assistant.txt\n-r ./extra/charts.txt\n-r ./extra/snapshots.txt\n-r ./extra/xls.txt\n-r ./extra/uploads.txt\n-r ./tests.txt\n\n# The Celery broker that test_project uses. Not required if not using async tasks, or if you have\n# a Celery config that uses a different broker.\nredis>=5.0\n"
  },
  {
    "path": "requirements/extra/assistant.txt",
    "content": "openai>=1.6.1\n"
  },
  {
    "path": "requirements/extra/charts.txt",
    "content": "matplotlib>=3.9\n"
  },
  {
    "path": "requirements/extra/snapshots.txt",
    "content": "boto3>=1.30.0\ncelery>=4.0\n"
  },
  {
    "path": "requirements/extra/uploads.txt",
    "content": "python-dateutil>=2.9\npandas>=2.2\nboto3>=1.30.0\n"
  },
  {
    "path": "requirements/extra/xls.txt",
    "content": "xlsxwriter>=1.3.6"
  },
  {
    "path": "requirements/tests.txt",
    "content": "-r ./base.txt\n\nimportlib-metadata<5.0; python_version <= '3.7'\ncoverage\nfactory-boy>=3.1.0\n"
  },
  {
    "path": "ruff.toml",
    "content": "line-length = 120\n\nextend-exclude = [\n  \".ruff_cache\",\n  \".env\",\n  \".venv\",\n  \"**migrations/**\",\n]\n\n[lint]\nselect = [\n  \"E\",  # pycodestyle errors\n  \"W\",  # pycodestyle warnings\n  \"F\",  # pyflakes\n  \"I\",  # isort\n  \"C\",  # flake8-comprehensions\n  \"B\",  # flake8-bugbear\n  \"Q\", # flake8-quotes\n  \"PLE\", # pylint error\n  \"PLR\", # pylint refactor\n  \"PLW\", # pylint warning\n  \"UP\", # pyupgrade\n]\n\nignore = [\n  \"I001\",  # Import block is un-sorted or un-formatted (would be nice not to do this)\n]\n\n[lint.per-file-ignores]\n\"__init__.py\" = [\n  \"F401\"  # unused-import\n]\n\"explorer/charts.py\" = [\n  \"C419\",  # Unnecessary list comprehension.\n  \"PLR2004\",  # Magic value used in comparison, consider replacing 2 with a constant variable\n]\n\"explorer/exporters.py\" = [\n  \"PLW2901\",  # `for` loop variable `data` overwritten by assignment target\n]\n\"explorer/models.py\" = [\n  \"C417\",  # Unnecessary `map` usage (rewrite using a generator expression)\n]\n\"explorer/schema.py\" = [\n  \"C419\",  # Unnecessary list comprehension.\n]\n\"explorer/tests/test_utils.py\" = [\n  \"C416\",  # Unnecessary `list` comprehension (rewrite using `list()`)\n]\n\"explorer/views/utils.py\" = [\n  \"PLR0913\",  # Too many arguments in function definition (8 > 5)\n]\n\n[lint.isort]\ncombine-as-imports = true\nknown-first-party = [\n  \"explorer\",\n]\nextra-standard-library = [\"dataclasses\"]\n\n[lint.pyupgrade]\n# Preserve types, even if a file imports `from __future__ import annotations`.\nkeep-runtime-typing = true\n\n[format]\nquote-style = \"double\"\nindent-style = \"space\"\ndocstring-code-format = true\ndocstring-code-line-length = 80\n"
  },
  {
    "path": "setup.cfg",
    "content": "[coverage:run]\nbranch = True\nparallel = True\nomit =\n    explorer/__init__.py,\n    explorer/migrations/*,\n    explorer/tests/*,\n    test_project/*,\n    */setup.py\n    */manage.py\n    test_project/*\nsource =\n\texplorer\n\n[coverage:paths]\nsource =\n\texplorer\n\t.tox/*/site-packages\n\n[coverage:report]\nshow_missing = True\n\n[flake8]\nmax-line-length = 119\nexclude =\n    *.egg-info,\n    .eggs,\n    .git,\n    .settings,\n    .tox,\n    .venv,\n    build,\n    data,\n    dist,\n    docs,\n    *migrations*,\n    requirements,\n    tmp\n\n[isort]\nline_length = 119\nskip = manage.py, *migrations*, .tox, .eggs, data, .env, .venv\ninclude_trailing_comma = true\nmulti_line_output = 5\nlines_after_imports = 2\ndefault_section = THIRDPARTY\nsections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER\nknown_first_party = explorer\nknown_django = django\n"
  },
  {
    "path": "setup.py",
    "content": "import os\nimport sys\nfrom pathlib import Path\n\nfrom setuptools import setup\ntry:\n    from sphinx.setup_command import BuildDoc\nexcept ImportError:\n    BuildDoc = None\n\nfrom explorer import get_version\n\n\nname = \"django-sql-explorer\"\nversion = get_version()\nrelease = get_version(True)\n\n\ndef requirements(fname):\n    path = os.path.join(os.path.dirname(__file__), \"requirements\", fname)\n    with open(path) as f:\n        return f.read().splitlines()\n\n\nif sys.argv[-1] == \"build\":\n    os.system(\"python setup.py sdist bdist_wheel\")\n    print(f\"Built release {release} (version {version})\")\n    sys.exit()\n\nif sys.argv[-1] == \"release\":\n    os.system(\"twine upload --skip-existing dist/*\")\n    sys.exit()\n\nif sys.argv[-1] == \"tag\":\n    print(\"Tagging the version:\")\n    os.system(f\"git tag -a {version} -m 'version {version}'\")\n    os.system(\"git push --tags\")\n    sys.exit()\n\nthis_directory = Path(__file__).parent\nlong_description = (this_directory / \"README.rst\").read_text()\n\nsetup(\n    name=name,\n    version=version,\n    author=\"Chris Clark\",\n    author_email=\"chris@sqlexplorer.io\",\n    maintainer=\"Chris Clark\",\n    maintainer_email=\"chris@sqlexplorer.io\",\n    description=(\"SQL Reporting that Just Works. Fast, simple, and confusion-free.\"\n                 \"Write and share queries in a delightful SQL editor, with AI assistance\"),\n    license=\"MIT\",\n    keywords=\"django sql explorer reports reporting csv json database query\",\n    url=\"https://www.sqlexplorer.io\",\n    project_urls={\n      \"Changes\": \"https://django-sql-explorer.readthedocs.io/en/latest/history.html\",\n      \"Documentation\": \"https://django-sql-explorer.readthedocs.io/en/latest/\",\n      \"Issues\": \"https://github.com/explorerhq/sql-explorer/issues\"\n    },\n    packages=[\"explorer\"],\n    long_description=long_description,\n    long_description_content_type=\"text/x-rst\",\n    classifiers=[\n        \"Development Status :: 5 - Production/Stable\",\n        \"Intended Audience :: Developers\",\n        \"License :: OSI Approved :: MIT License\",\n        \"Topic :: Utilities\",\n        \"Framework :: Django :: 3.2\",\n        \"Framework :: Django :: 4.2\",\n        \"Framework :: Django :: 5.0\",\n        \"Programming Language :: Python :: 3\",\n        \"Programming Language :: Python :: 3.10\",\n        \"Programming Language :: Python :: 3.11\",\n        \"Programming Language :: Python :: 3.12\",\n        \"Programming Language :: Python :: 3 :: Only\",\n    ],\n    python_requires=\">=3.8\",\n    install_requires=[\n        requirements(\"base.txt\"),\n    ],\n    extras_require={\n        \"charts\": requirements(\"extra/charts.txt\"),\n        \"snapshots\": requirements(\"extra/snapshots.txt\"),\n        \"xls\": requirements(\"extra/xls.txt\"),\n        \"assistant\": requirements(\"extra/assistant.txt\"),\n        \"uploads\": requirements(\"extra/uploads.txt\"),\n    },\n    cmdclass={\n        \"build_sphinx\": BuildDoc,\n    },\n    command_options={\n        \"build_sphinx\": {\n            \"project\": (\"setup.py\", name),\n            \"version\": (\"setup.py\", version),\n            \"release\": (\"setup.py\", release),\n            \"source_dir\": (\"setup.py\", \"docs\"),\n            \"build_dir\": (\"setup.py\", \"./docs/_build\")\n        }\n    },\n    include_package_data=True,\n    zip_safe=False,\n)\n"
  },
  {
    "path": "test_project/__init__.py",
    "content": "try:\n    from .celery_config import app as celery_app\n\n    __all__ = [\"celery_app\"]\nexcept ImportError:\n    pass\n"
  },
  {
    "path": "test_project/celery_config.py",
    "content": "import os\n\nfrom celery import Celery\nfrom celery.schedules import crontab\n\n\n# Set the default Django settings module for the \"celery\" program.\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"test_project.settings\")\n\napp = Celery(\"test_project\")\n\n# Using a string here means the worker doesn\"t have to serialize\n# the configuration object to child processes.\n# - namespace=\"CELERY\" means all celery-related configuration keys\n#   should have a `CELERY_` prefix.\napp.config_from_object(\"django.conf:settings\", namespace=\"CELERY\")\n\n# Load task modules from all registered Django apps.\napp.autodiscover_tasks()\n\napp.conf.beat_schedule = {\n    \"explorer.tasks.snapshot_queries\": {\n        \"task\": \"explorer.tasks.snapshot_queries\",\n        \"schedule\": crontab(hour=\"1\", minute=\"0\")\n    },\n    \"explorer.tasks.truncate_querylogs\": {\n           \"task\": \"explorer.tasks.truncate_querylogs\",\n           \"schedule\": crontab(hour=\"1\", minute=\"10\"),\n           \"kwargs\": {\"days\": 30}\n    },\n    \"explorer.tasks.remove_unused_sqlite_dbs\": {\n        \"task\": \"explorer.tasks.remove_unused_sqlite_dbs\",\n        \"schedule\": crontab(hour=\"1\", minute=\"20\")\n    },\n    \"explorer.tasks.build_async_schemas\": {\n        \"task\": \"explorer.tasks.build_async_schemas\",\n        \"schedule\": crontab(hour=\"1\", minute=\"30\")\n    }\n}\n"
  },
  {
    "path": "test_project/settings.py",
    "content": "import os\n\nUSE_TZ = True\nSECRET_KEY = \"shhh\"\nDEBUG = True\nSTATIC_URL = \"/static/\"\nVITE_DEV_MODE = True\n\nALLOWED_HOSTS = [\"0.0.0.0\", \"localhost\", \"127.0.0.1\"]\n\nBASE_DIR = os.path.dirname(os.path.dirname(__file__))\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.sqlite3\",\n        \"NAME\": \"tmp\",\n        \"TEST\": {\n            \"NAME\": \"tmp\"\n        }\n    }\n}\n\nEXPLORER_CONNECTIONS = {\n    \"Primary\": \"default\",\n}\n\nEXPLORER_DEFAULT_CONNECTION = \"default\"\n\nROOT_URLCONF = \"test_project.urls\"\n\nPROJECT_PATH = os.path.realpath(os.path.dirname(__file__))\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [],\n        \"APP_DIRS\": True,\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n                \"django.template.context_processors.static\",\n                \"django.template.context_processors.request\",\n            ],\n            \"debug\": DEBUG\n        },\n    },\n]\n\nINSTALLED_APPS = (\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.staticfiles\",\n    \"django.contrib.admin\",\n    \"explorer\",\n)\n\nSTATICFILES_FINDERS = (\n    \"django.contrib.staticfiles.finders.FileSystemFinder\",\n    \"django.contrib.staticfiles.finders.AppDirectoriesFinder\",\n)\n\nSTORAGES = {\n    \"default\": {\n        \"BACKEND\": \"django.core.files.storage.FileSystemStorage\",\n    },\n    \"staticfiles\": {\n        \"BACKEND\": \"django.contrib.staticfiles.storage.StaticFilesStorage\",\n    },\n}\n\nAUTHENTICATION_BACKENDS = (\n    \"django.contrib.auth.backends.ModelBackend\",\n)\n\nMIDDLEWARE = [\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]\n\n# added to help debug tasks\nEMAIL_BACKEND = \"django.core.mail.backends.console.EmailBackend\"\n\n# Explorer-specific\n\nEXPLORER_TRANSFORMS = (\n    (\"foo\", '<a href=\"{0}\">{0}</a>'),\n    (\"bar\", \"x: {0}\")\n)\n\nEXPLORER_USER_QUERY_VIEWS = {}\n\n# Tasks disabled by default, but if you have celery installed\n# make sure the broker URL is set correctly\nEXPLORER_TASKS_ENABLED = False\nCELERY_BROKER_URL = os.environ.get(\"CELERY_BROKER_URL\")\n\nEXPLORER_S3_BUCKET = os.environ.get(\"EXPLORER_S3_BUCKET\")\nEXPLORER_S3_ACCESS_KEY = os.environ.get(\"EXPLORER_S3_ACCESS_KEY\")\nEXPLORER_S3_SECRET_KEY = os.environ.get(\"EXPLORER_S3_SECRET_KEY\")\nEXPLORER_AI_API_KEY = os.environ.get(\"AI_API_KEY\")\nEXPLORER_ASSISTANT_BASE_URL = os.environ.get(\"AI_BASE_URL\")\nEXPLORER_DB_CONNECTIONS_ENABLED = True\nEXPLORER_USER_UPLOADS_ENABLED = True\nEXPLORER_CHARTS_ENABLED = True\nEXPLORER_ASSISTANT_MODEL_NAME = \"anthropic/claude-3.5-sonnet\"\n"
  },
  {
    "path": "test_project/urls.py",
    "content": "from django.contrib import admin\nfrom django.contrib.staticfiles.urls import staticfiles_urlpatterns\nfrom django.urls import path, include\n\n# Installing to /explorer/ better mimics likely production setups\n# Explorer is probably *not* running at the Django project root\nurlpatterns = [\n    path(\"explorer/\", include(\"explorer.urls\"))\n]\n\nadmin.autodiscover()\n\nurlpatterns += [\n    path(\"admin/\", admin.site.urls),\n]\n\nurlpatterns += staticfiles_urlpatterns()\n"
  },
  {
    "path": "tox.ini",
    "content": "[tox]\nenvlist =\n    flake8\n    isort\n    {base-reqs,dev}-py{310,311,312}-dj{32,42,50}\n    {base-reqs,dev}-py{311,312}-dj{50,main}\n\nskip_missing_interpreters=True\n\n[testenv]\nallowlist_externals = coverage\ndeps =\n    base-reqs: -r requirements/tests.txt\n    dj32: django>=3.2,<4.0\n    dj42: django>=4.2,<5.0\n    dj50: django>=5.0,<5.1\n    djmain: https://github.com/django/django/archive/main.tar.gz\n    dev: -r requirements/dev.txt\ncommands =\n    {envpython} --version\n    base-reqs: coverage run manage.py test --settings=explorer.tests.settings_base --noinput\n    dev: coverage run manage.py test --settings=explorer.tests.settings --noinput\nignore_outcome =\n    djmain: True\nignore_errors =\n    djmain: True\n\n[testenv:flake8]\ndeps = flake8\ncommands = flake8\n\n[testenv:isort]\ndeps = isort\ncommands = isort --check --diff explorer\nskip_install = true\n"
  },
  {
    "path": "vite.config.mjs",
    "content": "import { resolve } from 'path';\nimport { defineConfig } from 'vite';\nimport { viteStaticCopy } from 'vite-plugin-static-copy';\n\nexport default defineConfig({\n  plugins: [\n      viteStaticCopy({\n        targets: [\n          { src: 'explorer/src/images/*', dest: 'images' },\n        ]\n    })\n  ],\n  root: resolve(__dirname, './'),\n  base: '',\n  server: {\n    host: true,\n    port: 5173,\n    strictPort: true,\n    open: false,\n    watch: {\n      usePolling: true,\n      disableGlobbing: false,\n    },\n  },\n  resolve: {\n    extensions: ['.js', '.json'],\n    alias: {\n      '~bootstrap': resolve(__dirname, './node_modules/bootstrap'),\n      '~bootstrap-icons': resolve(__dirname, './node_modules/bootstrap-icons'),\n    },\n  },\n  build: {\n    outDir: resolve(__dirname, './explorer/static/explorer'),\n    assetsDir: '',\n    emptyOutDir: true,\n    target: 'es2015',\n    rollupOptions: {\n      input: {\n        main: resolve(__dirname, './explorer/src/js/main.js'),\n        // Some magic here; Vite always builds to styles.css, we named our entrypoint SCSS file the same thing\n        // so that in the base template HTML file we can include 'styles.scss', and rename just the extension\n        // in the vite template tag, and get both the dev and prod builds to work.\n        styles: resolve(__dirname, '/explorer/src/scss/styles.scss'),\n      },\n      output: {\n        entryFileNames: `[name].${process.env.APP_VERSION}.js`,\n        chunkFileNames: `[name].${process.env.APP_VERSION}.js`,\n        assetFileNames: `[name].[ext]`\n      },\n    },\n  },\n});\n"
  }
]