diff --git a/.ci/.gitkeep b/.ci/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.ci/test-reports/.gitignore b/.ci/test-reports/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 5d223dc..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,72 +0,0 @@ -version: 2.1 -commands: - runtests: - parameters: - toxcommand: - type: string - default: tox - steps: - - checkout - - - run: - name: install tox - command: > - python3.10 -m pip install --upgrade --force-reinstall tox pip - - - run: - name: version checks - command: | - python3.10 --version - pip --version - tox --version - - - run: - name: run tox - command: << parameters.toxcommand >> - - - store_test_results: - path: .circleci/test-reports/ - - - run: - name: push code coverage - command: bash <(curl -s https://codecov.io/bash) -X coveragepy -t "f52ea144-6e93-4cda-b927-1f578a6e814c" - -jobs: - postgres: - docker: - - image: level12/python-test-multi - environment: - SQLALCHEMY_DATABASE_URI: "postgresql://postgres:password@localhost/test" - - image: postgres:latest - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: test - steps: - - runtests - sqlite: - docker: - - image: level12/python-test-multi - environment: - SQLALCHEMY_DATABASE_URI: "sqlite://" - steps: - - runtests - mssql: - docker: - - image: level12/python-test-multi - environment: - SQLALCHEMY_DATABASE_URI: "mssql+pyodbc_mssql://SA:Password12!@localhost:1433/tempdb?driver=ODBC+Driver+17+for+SQL+Server" - - image: mcr.microsoft.com/mssql/server:2017-latest - environment: - ACCEPT_EULA: Y - SA_PASSWORD: "Password12!" - steps: - - runtests - -workflows: - version: 2 - build: - jobs: - - postgres - - sqlite - - mssql diff --git a/.copier-answers-py.yaml b/.copier-answers-py.yaml new file mode 100644 index 0000000..f31bd36 --- /dev/null +++ b/.copier-answers-py.yaml @@ -0,0 +1,19 @@ +# Changes here will be overwritten by Copier +# +# Updating `_src` could be helpful, see notes at: +# https://github.com/level12/coppy/wiki#creating-a-project +# +# Otherwise, NEVER EDIT MANUALLY +_commit: v1.20250622.1 +_src_path: gh:level12/coppy +author_email: devteam@level12.io +author_name: Level 12 +gh_org: level12 +gh_repo: webgrid +hatch_version_tag_sign: true +project_name: WebGrid +py_module: webgrid +python_version: '3.10' +script_name: '' +use_circleci: false +use_gh_nox: true diff --git a/.coveragerc b/.coveragerc index 5144542..00d4f79 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,13 @@ +# .coveragerc to control coverage.py [run] branch = True -source = webgrid +omit = + src/webgrid/version.py + +source = + src/webgrid + tests/webgrid_tests + + +[html] +directory = tmp/coverage-html diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fbd51ff --- /dev/null +++ b/.editorconfig @@ -0,0 +1,38 @@ +# This is the "top-most" .editorconfig for this project. +# Like a `.gitignore` file, you can place additional +# .editorconfig files in specific directories, if you need +# local settings. However, this directive specifies that +# no "global" settings will be used (settings from higher- +# up in the directory hierarchy, wherever that may be) +root = true + +# Set the default whitespace settings for all files +[*] + +# Use UNIX-style line endings +end_of_line = lf + +# 4-space indents +indent_size = 4 +indent_style = space + +# end all files with a newline +insert_final_newline = true + +# trim whitespace from the ends of lines +trim_trailing_whitespace = true + + +[*.py] +# ensure Python source files are utf-8 +charset = utf-8 + + +[*.{yml,yaml}] +# Set two-space indents for YAML files +indent_size = 2 + + +[Makefile] +# Makefiles *must* use tabs! +indent_style = tab diff --git a/.github/actions/nox-run/action.yaml b/.github/actions/nox-run/action.yaml new file mode 100644 index 0000000..e438e66 --- /dev/null +++ b/.github/actions/nox-run/action.yaml @@ -0,0 +1,30 @@ +name: "Nox Run" + +inputs: + nox-session: + description: "Name of the nox session to execute" + required: true + +runs: + using: composite + steps: + - uses: ./.github/actions/uv-setup + + # MSSQL sessions need ODBC driver in the runner + - name: Install MS ODBC driver (Ubuntu) + if: contains(inputs.nox-session, 'pytest_mssql') + run: tasks/odbc-driver-install + shell: bash + + - name: Run nox session + run: uv run --only-group nox -- nox -s "${{ inputs.nox-session }}" + env: + UV_LINK_MODE: copy + shell: bash + + - name: upload coverage artifact + if: contains(inputs.nox-session, 'pytest') + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ inputs.nox-session }} + path: ci/coverage/${{ inputs.nox-session }}.xml diff --git a/.github/actions/uv-setup/action.yaml b/.github/actions/uv-setup/action.yaml new file mode 100644 index 0000000..eceff7d --- /dev/null +++ b/.github/actions/uv-setup/action.yaml @@ -0,0 +1,14 @@ +name: "uv setup" +description: "Install Python & uv" + +runs: + using: composite + steps: + # NOTE: use GH's action to install Python b/c mise docs say it will be faster due to caching + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + + - name: Install uv + uses: astral-sh/setup-uv@v6 diff --git a/.github/workflows/nox.yaml b/.github/workflows/nox.yaml new file mode 100644 index 0000000..8b50e81 --- /dev/null +++ b/.github/workflows/nox.yaml @@ -0,0 +1,142 @@ +name: Nox + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + pull_request: + workflow_dispatch: + +# Limit this workflow to a single run at a time per-branch to avoid wasting worker resources +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +jobs: + generate-matrix: + runs-on: ubuntu-24.04 + + outputs: + nox-sessions: ${{ steps.nox-sessions.outputs.sessions }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # - uses: ./.github/actions/uv-setup + + # - id: nox-sessions + # run: | + # sessions=$(uv run --only-group nox -- tasks/gh-nox-sessions) + # echo "sessions=$sessions" >> $GITHUB_OUTPUT + # env: + # PYTHONPATH: './src' + + # nox-other: + # needs: generate-matrix + # runs-on: ubuntu-24.04 + + # strategy: + # fail-fast: false + # matrix: + # session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).other }} + + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + + # - uses: ./.github/actions/nox-run + # with: + # nox-session: ${{ matrix.session }} + + + nox-pg: + needs: generate-matrix + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).pg }} + + services: + postgres: + image: postgres:17 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=3s + --health-timeout=3s + --health-retries=15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/actions/nox-run + with: + nox-session: ${{ matrix.session }} + + + # nox-mssql: + # needs: generate-matrix + # runs-on: ubuntu-24.04 + + # strategy: + # fail-fast: false + # matrix: + # session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).mssql }} + + # services: + # mssql: + # image: mcr.microsoft.com/mssql/server:2019-latest + # env: + # ACCEPT_EULA: Y + # SA_PASSWORD: Docker-sa-password + # ports: + # - 1433:1433 + # options: >- + # --health-cmd="/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U SA -P Docker-sa-password -Q \"select 'ok'\"" + # --health-interval=3s + # --health-timeout=3s + # --health-retries=15 + + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + + # - uses: ./.github/actions/nox-run + # with: + # nox-session: ${{ matrix.session }} + + + # codecov: + # needs: [nox-other, nox-pg, nox-mssql] + # runs-on: ubuntu-latest + + # permissions: + # id-token: write # For codecov OIDC + + # steps: + # # Codecov action says we have to have done a checkout + # - name: Checkout + # uses: actions/checkout@v4 + + # - uses: actions/download-artifact@v5 + # with: + # path: ci/github-coverage + # merge-multiple: true + + # - name: Coverage files + # run: ls -R ci/ + + # - uses: codecov/codecov-action@v5 + # with: + # use_oidc: true + # files: ci/github-coverage/*.xml diff --git a/.github/workflows/pypi.yaml b/.github/workflows/pypi.yaml new file mode 100644 index 0000000..bf6bdc8 --- /dev/null +++ b/.github/workflows/pypi.yaml @@ -0,0 +1,45 @@ +name: PyPI publish + +on: + workflow_run: + workflows: + - Nox + types: + - completed + +# Limit this workflow to a single run at a time per-branch to avoid wasting worker resources +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +jobs: + build: + name: Build project then publish to pypi + runs-on: ubuntu-latest + + # Only run when the nox run is a success and we have a version tag + if: github.event.workflow_run.conclusion == 'success' + + env: + PYPI_URL: ${{ github.event_name == 'pull_request' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} + + permissions: + # required for pypa/gh-action-pypi-publish + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: ./.github/actions/uv-setup + + - name: Hatch build + run: | + uv run --only-group release -- hatch --version + uv run --only-group release -- hatch build + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: tmp/dist diff --git a/.gitignore b/.gitignore index dfd176e..c5d904d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,39 @@ -*.komodo* +# Python *.pyc *.egg-info -dist/* -build/* -.tox/ -venv -pytest-reports +.venv + +# Tox/Nox/Tests/Coverage +/.nox +/.tox +/ci/coverage-html *.coverage -coverage.xml -*.nose.xml -*.py~ -.eggs -.ci/* +/coverage.xml +*.pytests.xml +.pytest_cache + + +# Editor Clutter +.vscode +.idea + + +# Build related +dist +/build +npm-debug.log +.*-cache +node_modules + + +# Local dev files +/mise.local.toml +/tmp + +# Terraform +*.tfstate* +tf.plan +.terraform + + +# Project specific diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8f5bcbc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-ast + types: [ python ] + - id: debug-statements + types: [ python ] + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + - id: check-yaml + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.12.7 + hooks: + # i.e. `ruff check` + - id: ruff + # i.e. `ruff format --check` + - id: ruff-format + # Due to the Ruff config we use (see comment in pyproject.yaml), it's possible that the + # formatter creates linting failures. By only doing a check here, it forces the dev to run + # the formatter before pre-commit runs (presumably on save through their editor) and then + # the linting check above would always catch a problem created by the formatter. + args: [ --check ] + - repo: https://github.com/level12/pre-commit-hooks + rev: v0.20250226.1 + hooks: + - id: check-ruff-versions + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.8.4 + hooks: + - id: uv-lock diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 41a189d..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -recursive-include webgrid * -recursive-include webgrid_ta * -global-exclude *.pyc -include *.rst diff --git a/ci/.gitignore b/ci/.gitignore new file mode 100644 index 0000000..212cdf2 --- /dev/null +++ b/ci/.gitignore @@ -0,0 +1,3 @@ +artifacts +test-reports +coverage diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..622d389 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,23 @@ +services: + pg: + image: postgres:17-alpine + container_name: webgrid-pg + ports: + - '${DC_POSTGRES_HOST:-127.0.0.1}:${DC_POSTGRES_PORT:-5432}:5432' + environment: + # Ok for local dev, UNSAFE in most other applications. Don't blindly copy & paste + # without considering implications. + POSTGRES_HOST_AUTH_METHOD: trust + mssql: + image: mcr.microsoft.com/mssql/server:2019-latest + container_name: webgrid-mssql + environment: + ACCEPT_EULA: Y + SA_PASSWORD: Docker-sa-password + ports: + - '${DC_MSSQL_HOST:-127.0.0.1}:${DC_MSSQL_PORT:-1433}:1433' + healthcheck: + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-C", "-S", "127.0.0.1", "-U", "sa", "-P", "Docker-sa-password", "-Q", "select 1"] + interval: 10s + timeout: 15s + retries: 5 diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css index cb20347..58a5d21 100644 --- a/docs/source/_static/css/custom.css +++ b/docs/source/_static/css/custom.css @@ -1,3 +1,3 @@ dl.py.class, dl.py.function { margin-bottom: 2em; -} \ No newline at end of file +} diff --git a/docs/source/conf.py b/docs/source/conf.py index e745ddd..97786b0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,29 +14,16 @@ # import sys # sys.path.insert(0, os.path.abspath('.')) -import webgrid import datetime as dt -import configparser +import webgrid.version # -- Project information ----------------------------------------------------- project = 'WebGrid' -copyright = u"{year} Level 12".format(year=dt.datetime.utcnow().year) - - -cfg = configparser.SafeConfigParser() -cfg.read('../../setup.cfg') - -tag = cfg.get('egg_info', 'tag_build') - -html_context = { - 'prerelease': bool(tag), # True if tag is not the empty string -} - -# The full version, including alpha/beta/rc tags. -release = webgrid.__version__ + tag +copyright = f'{dt.datetime.utcnow().year} Level 12' +release = webgrid.version.VERSION # -- General configuration --------------------------------------------------- @@ -78,12 +65,11 @@ 'Level 12': 'https://www.level12.io', 'File an Issue': 'https://github.com/level12/webgrid/issues/new', }, - 'show_powered_by': True - + 'show_powered_by': True, } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] -html_css_files = ['css/custom.css'] \ No newline at end of file +html_css_files = ['css/custom.css'] diff --git a/docs/source/filters/base-filter.rst b/docs/source/filters/base-filter.rst index 9048544..c29ebc0 100644 --- a/docs/source/filters/base-filter.rst +++ b/docs/source/filters/base-filter.rst @@ -12,4 +12,3 @@ Base Filters .. autoclass:: webgrid.filters.OptionsIntFilterBase :members: - diff --git a/docs/source/filters/custom-filters.rst b/docs/source/filters/custom-filters.rst index b3dc640..1b95abd 100644 --- a/docs/source/filters/custom-filters.rst +++ b/docs/source/filters/custom-filters.rst @@ -56,4 +56,3 @@ search filters are aggregate. Using an aggregate filter will require a GROUP BY class AggregateTextFilter(TextFilter): is_aggregate = True - diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 0eebbb2..2b3414f 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -161,4 +161,3 @@ The desired workflow here is to run tox, update strings in the PO files as neces .. code:: tox -e i18n - diff --git a/docs/source/grid/grid.rst b/docs/source/grid/grid.rst index bff7439..12e1aeb 100644 --- a/docs/source/grid/grid.rst +++ b/docs/source/grid/grid.rst @@ -6,4 +6,3 @@ Grid Class .. autoclass:: webgrid.BaseGrid :members: :inherited-members: - diff --git a/docs/source/renderers/base-renderer.rst b/docs/source/renderers/base-renderer.rst index bbf1ef7..a756c61 100644 --- a/docs/source/renderers/base-renderer.rst +++ b/docs/source/renderers/base-renderer.rst @@ -4,4 +4,3 @@ Base Renderer .. autoclass:: webgrid.renderers.Renderer :members: :inherited-members: - diff --git a/docs/source/renderers/built-in-renderers.rst b/docs/source/renderers/built-in-renderers.rst index 230b80f..6ff5ce6 100644 --- a/docs/source/renderers/built-in-renderers.rst +++ b/docs/source/renderers/built-in-renderers.rst @@ -16,4 +16,3 @@ Built-in Renderers .. autoclass:: webgrid.renderers.CSV :members: :inherited-members: - diff --git a/docs/source/renderers/index.rst b/docs/source/renderers/index.rst index 840848c..017bdc1 100644 --- a/docs/source/renderers/index.rst +++ b/docs/source/renderers/index.rst @@ -6,4 +6,3 @@ Renderers base-renderer built-in-renderers - diff --git a/docs/source/testing/index.rst b/docs/source/testing/index.rst index 40df3e0..178624c 100644 --- a/docs/source/testing/index.rst +++ b/docs/source/testing/index.rst @@ -6,4 +6,3 @@ Testing test-helpers test-usage - diff --git a/env-config.yaml b/env-config.yaml new file mode 100644 index 0000000..2567aa4 --- /dev/null +++ b/env-config.yaml @@ -0,0 +1,4 @@ +profile: + pypi: + HATCH_INDEX_USER: '__token__' + HATCH_INDEX_AUTH: 'op://private/pypi.python.org/api-token' diff --git a/hatch.toml b/hatch.toml new file mode 100644 index 0000000..53b3068 --- /dev/null +++ b/hatch.toml @@ -0,0 +1,18 @@ +## Build +[build] +directory = 'tmp/dist' +dev-mode-dirs = ['src'] + +[build.targets.wheel] +packages = ['src/webgrid'] + + +## Env: default +[envs.default] +installer = "uv" + + +## Version +[version] +source = 'regex_commit' +path = 'src/webgrid/version.py' diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..530c63f --- /dev/null +++ b/mise.toml @@ -0,0 +1,44 @@ +[env] +DC_MSSQL_PORT = '12001' +DC_POSTGRES_PORT = '12000' +PROJECT_SLUG = '{{ config_root | basename | slugify }}' + +_.python.venv.path = '{% if env.UV_PROJECT_ENVIRONMENT %}{{ env.UV_PROJECT_ENVIRONMENT }}{% else %}.venv{% endif %}' +_.python.venv.create = true + +[tools] +python = ['3.10', '3.11', '3.12', '3.13'] + + +[task_config] +includes = [ + 'tasks', +] + + +################ TASKS ################# +[tasks.pytest-cov] +description = 'Full pytest run with html coverage report' +# .coveragerc sets directory to ./tmp/coverage-html +run = 'pytest --cov --cov-report=html --no-cov-on-fail' + + +[tasks.upgrade-deps] +description = 'Upgrade uv and pre-commit dependencies' +run = [ + 'uv sync --upgrade', + 'pre-commit autoupdate', +] + +[tasks.mssql-docker-check] +description = 'Check the mssql service from inside the docker container' +run = ''' + docker compose exec mssql /opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P Docker-sa-password -Q "select 'connected' as status" +''' + +[tasks.mssql-host-check] +description = 'Check the mssql service from our host' +# Task odbc-driver-install installs sqlcmd +run = ''' + /opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P Docker-sa-password -Q "select 'connected' as status" +''' diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..411dda3 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,121 @@ +from pathlib import Path + +from nox import Session, options, parametrize, session + + +package_path = Path.cwd() +tests_dpath = package_path / 'tests' +docs_dpath = package_path / 'docs' + +py_all = ['3.10', '3.11', '3.12', '3.13'] +py_single = py_all[-1:] +py_311 = ['3.11'] + +options.default_venv_backend = 'uv' + + +def pytest_run(session: Session, *args, **env): + session.run( + 'pytest', + '-ra', + '--tb=native', + '--strict-markers', + '--cov', + '--cov-config=.coveragerc', + f'--cov-report=xml:{package_path}/ci/coverage/{session.name}.xml', + '--no-cov-on-fail', + 'tests/webgrid_tests', + *args, + *session.posargs, + env=env, + ) + + +def uv_sync(session: Session, *groups, project, extra=None): + project_args = () if project else ('--no-install-project',) + group_args = [arg for group in groups for arg in ('--group', group)] + extra_args = ('--extra', extra) if extra else () + run_args = ( + 'uv', + 'sync', + '--active', + '--no-default-groups', + *project_args, + *group_args, + *extra_args, + ) + session.run(*run_args) + + +@session(py=py_all) +@parametrize('db', ['pg', 'sqlite']) +def pytest(session: Session, db: str): + uv_sync(session, 'tests', project=True) + pytest_run(session, WEBTEST_DB=db) + + +@session(py=py_single) +def pytest_mssql(session: Session): + uv_sync(session, 'tests', 'mssql', project=True) + pytest_run(session, WEBTEST_DB='mssql') + + +@session(py=py_single) +def pytest_i18n(session: Session): + uv_sync(session, 'tests', project=True, extra='i18n') + pytest_run(session, WEBTEST_DB='sqlite') + + +@session(py=py_single) +def wheel(session: Session): + """ + Package the wheel, install in the venv, and then run the tests for one version of Python. + Helps ensure nothing is wrong with how we package the wheel. + """ + uv_sync(session, 'tests', project=False) + + session.install('hatch', 'check-wheel-contents') + version = session.run('hatch', 'version', silent=True, stderr=None).strip() + wheel_fpath = package_path / 'tmp' / 'dist' / f'webgrid-{version}-py3-none-any.whl' + + if wheel_fpath.exists(): + wheel_fpath.unlink() + + session.run('hatch', 'build', '--clean') + session.run('check-wheel-contents', wheel_fpath) + session.run('uv', 'pip', 'install', wheel_fpath) + + out = session.run('python', '-c', 'import webgrid; print(webgrid.__file__)', silent=True) + assert 'site-packages/webgrid/__init__.py' in out + + pytest_run(session, WEBTEST_DB='sqlite') + + +@session(py=py_single) +def precommit(session: Session): + uv_sync(session, 'pre-commit', project=False) + session.run( + 'pre-commit', + 'run', + '--all-files', + ) + + +# Python 3.11 is required due to: https://github.com/level12/morphi/issues/11 +@session(python=py_311) +def translations(session: Session): + uv_sync(session, 'tests', project=True, extra='i18n') + # This is currently failing due to missing translations + # https://github.com/level12/webgrid/issues/194 + session.run( + 'python', + 'tests/webgrid_ta/manage.py', + 'verify-translations', + env={'PYTHONPATH': tests_dpath}, + ) + + +@session(py=py_single) +def docs(session: Session): + uv_sync(session, 'tests', 'docs', project=True) + session.run('make', '-C', docs_dpath, 'html', external=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..673e3a4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,124 @@ +[build-system] +requires = [ + 'hatchling', + 'hatch-regex-commit', +] +build-backend = 'hatchling.build' + + +[project] +name = 'WebGrid' +description = 'A library for rendering HTML tables and Excel files from SQLAlchemy models.' +authors = [ + {name = 'Level 12', email = 'devteam@level12.io'}, +] +requires-python = '>=3.10' +dynamic = ['version'] +readme = 'readme.md' +license.file = 'license.txt' +urls.homepage = 'https://github.com/level12/webgrid' +classifiers = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', +] + +# Dependencies +dependencies = [ + 'BlazeUtils>=0.6.0', + 'SQLAlchemy>=1.4.20', + 'jinja2', + 'python-dateutil', + 'Werkzeug', +] + + +[project.optional-dependencies] +i18n = ['morphi'] + + +[dependency-groups] +dev = [ + {include-group = 'tests'}, + {include-group = 'pre-commit'}, + {include-group = 'nox'}, + {include-group = 'docs'}, + 'click', + 'hatch', + 'ruff', +] + + +# Groups that follow are used indvidually by nox +docs = [ + 'pytz', + 'sphinx', +] + +# pyodbc is broken out by itself on the assumption that most devs are fine with testing sqlite or +# postgresql locally but probably won't go through the hassle of setting up the mssql driver and +# so don't need pyodbc installed either. +mssql = [ + # NOTE: you will also need the driver, which is an OS level install. + # See: tasks/odbc-driver-install + 'pyodbc', +] + +pre-commit = [ + 'pre-commit', + 'pre-commit-uv', +] + +tests = [ + 'arrow>=1.3.0', + 'flask>=3.0.3', + 'flask-bootstrap>=3.3.7.1', + 'flask-sqlalchemy>=3.1.1', + 'flask-webtest>=0.1.6', + 'flask-wtf>=1.2.2', + 'openpyxl>=3.1.5', + 'psycopg[binary]>=3.2.9', + 'pyquery>=2.0.1', + 'pytest', + 'pytest-cov', + 'sqlalchemy-utils>=0.41.2', + 'xlsxwriter>=3.2.5', +] + +# Used by CI +nox = [ + 'nox', +] + +# Used by CI +release = [ + 'hatch', +] + + +############## TOOLS ##################### + +[tool.babel.extract_messages] +input_dirs = ['src/webgrid'] +mapping_file = 'src/webgrid/i18n/babel.cfg' +output_file = 'src/webgrid/i18n/webgrid.pot' + +[tool.babel.init_catalog] +domain = 'webgrid' +input_file = 'src/webgrid/i18n/webgrid.pot' +output_dir = 'src/webgrid/i18n' + +[tool.babel.update_catalog] +domain = 'webgrid' +input_file = 'src/webgrid/i18n/webgrid.pot' +output_dir = 'src/webgrid/i18n' + +[tool.babel.compile_catalog] +domain = 'webgrid' +directory = 'src/webgrid/i18n' + +[tool.babel.compile_json] +domain = 'webgrid' +directory = 'src/webgrid/i18n' +output_dir = 'src/webgrid/static/i18n' diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index eea2c18..0000000 --- a/pytest.ini +++ /dev/null @@ -1 +0,0 @@ -[pytest] diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..17e42ba --- /dev/null +++ b/readme.md @@ -0,0 +1,132 @@ +# WebGrid +[![nox](https://github.com/level12/webgrid/actions/workflows/nox.yaml/badge.svg)](https://github.com/level12/webgrid/actions/workflows/nox.yaml) +[![Codecov](https://codecov.io/gh/level12/webgrid/branch/master/graph/badge.svg)](https://codecov.io/gh/level12/webgrid) +[![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/6s1886gojqi9c8h6?svg=true)](https://ci.appveyor.com/project/level12/webgrid) + + +## Introduction + +WebGrid is a datagrid library for Flask and other Python web frameworks designed to work with +SQLAlchemy ORM entities and queries. + +With a grid configured from one or more entities, WebGrid provides these features for reporting: + +- Automated SQL query construction based on specified columns and query join/filter/sort options +- Renderers to various targets/formats + + - HTML output paired with JS (jQuery) for dynamic features + - Excel (XLSX) + - CSV + +- User-controlled data filters + + - Per-column selection of filter operator and value(s) + - Generic single-entry search + +- Session storage/retrieval of selected filter options, sorting, and paging + + +## Installation + +Install via pip or uv: + +```bash +# Just the package +pip install webgrid +uv pip install webgrid + +# or, preferably in a uv project: +uv add webgrid +``` + +Some basic internationalization features are available via extra requirements: + +```bash +pip install webgrid[i18n] +uv pip install webgrid[i18n] + +# or, preferably in a uv project: +uv add webgrid --extra i18n +``` + + +## Getting Started + +For a quick start, see the [Getting Started guide](https://webgrid.readthedocs.io/en/stable/getting-started.html) in the docs. + + +## Links + +* [Documentation](https://webgrid.readthedocs.io/en/stable/index.html) +* [Releases](https://pypi.org/project/WebGrid/) +* [Code](https://github.com/level12/webgrid) +* [Issue tracker](https://github.com/level12/webgrid/issues) +* [Questions & comments](https://github.com/level12/webgrid/discussions) + + +## Dev + +### Copier Template + +Project structure and tooling mostly derives from the [Coppy](https://github.com/level12/coppy), +see its documentation for context and additional instructions. + +This project can be updated from the upstream repo, see +[Updating a Project](https://github.com/level12/coppy?tab=readme-ov-file#updating-a-project). + + +### Project Setup + +From zero to hero (passing tests that is): + +1. Ensure [host dependencies](https://github.com/level12/coppy/wiki/Mise) are installed + +2. Start docker service dependencies (if needed): + + ``` + ❯ docker compose config --services + mssql + pg + + ❯ docker compose up -d ... + ``` + +3. Sync [project](https://docs.astral.sh/uv/concepts/projects/) virtualenv w/ lock file: + + `uv sync` + +4. Configure pre-commit: + + `pre-commit install` + +5. Install mssql driver if intending to run mssql tests + + `mise odbc-driver-install` + +6. View sessions then run sessions: + + ``` + ❯ nox --list + + # all sessions + ❯ nox + + # selected sessions + ❯ nox -e ... + ``` + + +### Versions + +Versions are date based. A `bump` action exists to help manage versions: + +```shell +# Show current version +mise bump --show + +# Bump version based on date, tag, and push: +mise bump + +# See other options +mise bump -- --help +``` diff --git a/readme.rst b/readme.rst deleted file mode 100644 index 84e4875..0000000 --- a/readme.rst +++ /dev/null @@ -1,66 +0,0 @@ -WebGrid -####### - -.. image:: https://ci.appveyor.com/api/projects/status/6s1886gojqi9c8h6?svg=true - :target: https://ci.appveyor.com/project/level12/webgrid - -.. image:: https://circleci.com/gh/level12/webgrid.svg?style=shield - :target: https://circleci.com/gh/level12/webgrid - -.. image:: https://codecov.io/gh/level12/webgrid/branch/master/graph/badge.svg -   :target: https://codecov.io/gh/level12/webgrid - -Introduction ---------------- - -WebGrid is a datagrid library for Flask and other Python web frameworks designed to work with -SQLAlchemy ORM entities and queries. - -With a grid configured from one or more entities, WebGrid provides these features for reporting: - -- Automated SQL query construction based on specified columns and query join/filter/sort options -- Renderers to various targets/formats - - - HTML output paired with JS (jQuery) for dynamic features - - Excel (XLSX) - - CSV -- User-controlled data filters - - - Per-column selection of filter operator and value(s) - - Generic single-entry search -- Session storage/retrieval of selected filter options, sorting, and paging - -Installation ------------- - -Install using `pip`:: - - pip install webgrid - -Some basic internationalization features are available via extra requirements:: - - pip install webgrid[i18n] - -A Simple Example ----------------- - -For a simple example, see the `Getting Started guide `_ in the docs. - -Running the Tests ------------------ - -Webgrid uses `Tox `_ to manage testing environments & initiate tests. Once you -have installed it via `pip install tox` you can run `tox` to kick off the test suite. - -Webgrid is continuously tested against Python 3.6, 3.7, and 3.8. You can test against only a certain version by running -`tox -e py38-base` for whichever Python version you are testing. - - -Links ---------------------- - -* Documentation: https://webgrid.readthedocs.io/en/stable/index.html -* Releases: https://pypi.org/project/WebGrid/ -* Code: https://github.com/level12/webgrid -* Issue tracker: https://github.com/level12/webgrid/issues -* Questions & comments: http://groups.google.com/group/blazelibs diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..85f664f --- /dev/null +++ b/ruff.toml @@ -0,0 +1,93 @@ +line-length = 100 +target-version = 'py310' +output-format = 'concise' + +[format] +line-ending = 'lf' +quote-style = 'single' + + +[lint] +fixable = [ + 'C4', + 'COM', + 'I', + 'ISC', + 'PIE', + 'Q', + 'UP', + + 'E711', # Comparison to `None` should be `cond is None` + 'E712', # Comparison to `True` should be `cond is True` or `if cond:` + 'E713', # Test for membership should be `not in` + 'E714', # Test for object identity should be `is not` + 'F901', # `raise NotImplemented` should be `raise NotImplementedError` + 'RUF100', # Unused blanket noqa directive + 'W291', # Trailing whitespace + 'W293', # Blank line contains whitespace +] +select = [ + 'E', # ruff default: pycodestyle errors + 'W', # pycodestyle warnings + 'F', # ruff default: pyflakes + 'I', # isort + 'Q', # flake8-quotes + 'UP', # pyupgrade + 'YTT', # flake8-2020 + 'B', # flake8-bandit + 'A', # flake8-builtins + 'C4', # flake8-comprehensions + 'T10', # flake8-debugger + 'DJ', # flake8-django + 'EXE', # flake8-executable + 'PIE', # flake8-pie + 'COM', # flake-8 commas + 'RUF', # ruff specific + 'SIM', # flake8-simplify + 'ISC', # https://pypi.org/project/flake8-implicit-str-concat/ + 'PTH', # flake8-use-pathlib + # 'DTZ', # flake8-datetimez + +] +ignore = [ + 'A003', # Class attribute is shadowing a Python builtin + 'E731', # Do not assign a `lambda` expression, use a `def` + 'UP038', # Deprecated by Ruff, so ignore + + # Rules that conflict with the formatter. See: + # + # * https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + # * https://github.com/level12/coppy/issues/13 + # + # These are all redundant when the formatter is being used + 'W191', # tab-indentation + 'E111', # indentation-with-invalid-multiple + 'E114', # indentation-with-invalid-multiple-comment + 'E117', # over-indented + 'D206', # indent-with-spaces + 'D300', # triple-single-quotes + 'Q000', # bad-quotes-inline-string + 'Q001', # bad-quotes-multiline-string + 'Q002', # bad-quotes-docstring + 'Q003', # avoidable-escaped-quote +] + + +[lint.per-file-ignores] +'tasks/*.py' = ['EXE003'] + + +[lint.flake8-builtins] +ignorelist = ['id', 'help', 'compile', 'filter', 'copyright'] + + +[lint.flake8-quotes] +# Prefer using different quote to escaping strings +avoid-escape = true +inline-quotes = 'single' + + +[lint.isort] +lines-after-imports = 2 +force-sort-within-sections = true +known-first-party = ['webgrid_tasks_lib', 'webgrid_tests', 'webgrid_ta', 'webgrid_blazeweb_ta'] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 63b033c..0000000 --- a/setup.cfg +++ /dev/null @@ -1,31 +0,0 @@ -# Babel configuration - -[extract_messages] -input_dirs = webgrid -mapping_file = webgrid/i18n/babel.cfg -output_file = webgrid/i18n/webgrid.pot - -[init_catalog] -domain = webgrid -input_file = webgrid/i18n/webgrid.pot -output_dir = webgrid/i18n - -[update_catalog] -domain = webgrid -input_file = webgrid/i18n/webgrid.pot -output_dir = webgrid/i18n - -[compile_catalog] -domain = webgrid -directory = webgrid/i18n - -[compile_json] -domain = webgrid -directory = webgrid/i18n -output_dir = webgrid/static/i18n - -# Sphinx - -[egg_info] -tag_build = - diff --git a/setup.py b/setup.py deleted file mode 100644 index 4480789..0000000 --- a/setup.py +++ /dev/null @@ -1,83 +0,0 @@ -import os.path as osp -try: - from setuptools import setup -except ImportError: - from ez_setup import use_setuptools - use_setuptools() - from setuptools import setup - - -# pip install -e .[develop] -develop_requires = [ - 'arrow', - 'coverage', - 'mock', - 'openpyxl', - 'psycopg2-binary', - 'pyodbc', - 'pytest', - 'pytest-cov', - 'Flask', - 'Flask-Bootstrap', - 'Flask-SQLAlchemy', - 'Flask-WebTest', - 'Flask-WTF', - 'pyquery', - 'sqlalchemy_pyodbc_mssql', - 'sqlalchemy_utils', - 'wrapt', - 'xlsxwriter', -] - -cdir = osp.abspath(osp.dirname(__file__)) -README = open(osp.join(cdir, 'readme.rst')).read() -CHANGELOG = open(osp.join(cdir, 'changelog.rst')).read() - -version_fpath = osp.join(cdir, 'webgrid', 'version.py') -version_globals = {} -with open(version_fpath) as fo: - exec(fo.read(), version_globals) - -setup( - name="WebGrid", - version=version_globals['VERSION'], - description="A library for rendering HTML tables and Excel files from SQLAlchemy models.", - long_description='\n\n'.join((README, CHANGELOG)), - author="Randy Syring", - author_email="randy.syring@level12.io", - url='https://github.com/level12/webgrid', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - license='BSD-3-Clause', - packages=['webgrid'], - extras_require={ - 'develop': develop_requires, - 'i18n': [ - 'morphi' - ] - }, - zip_safe=False, - include_package_data=True, - setup_requires=[ - 'Babel' - ], - install_requires=[ - 'BlazeUtils>=0.6.0', - 'dataclasses; python_version < "3.7"', - 'SQLAlchemy>=1.4.20', - 'jinja2', - 'python-dateutil', - 'Werkzeug', - ], - entry_points=""" - [console_scripts] - webgrid_ta = webgrid_ta.manage:script_entry - """, -) diff --git a/webgrid/__init__.py b/src/webgrid/__init__.py similarity index 86% rename from webgrid/__init__.py rename to src/webgrid/__init__.py index 60ef4c8..a86ecd3 100644 --- a/webgrid/__init__.py +++ b/src/webgrid/__init__.py @@ -1,18 +1,18 @@ -from __future__ import absolute_import import datetime as dt import inspect import logging import sys -import six import time +from typing import ClassVar import urllib.parse from blazeutils.containers import HTMLAttributes from blazeutils.datastructures import BlankObject, OrderedDict from blazeutils.helpers import tolist from blazeutils.numbers import decimalfmt +from blazeutils.spreadsheets import openpyxl, xlsxwriter from blazeutils.strings import case_cw2us, randchars -from blazeutils.spreadsheets import xlsxwriter, openpyxl +import six import sqlalchemy as sa import sqlalchemy.sql as sasql @@ -21,6 +21,7 @@ from .renderers import HTML, XLSX from .version import VERSION as __version__ # noqa: F401 + # conditional imports to support libs without requiring them try: import arrow @@ -54,20 +55,17 @@ def subtotal_function_map(v): return v -class _None(object): +class _None: """ - A sentinal object to indicate no value + A sentinal object to indicate no value """ - pass class ExtractionError(TypeError): - """ raised when we are unable to extract a value from the record """ - pass + """raised when we are unable to extract a value from the record""" class _DeclarativeMeta(type): - def __new__(cls, name, bases, class_dict): class_dict['_rowstylers'] = [] class_dict['_colstylers'] = [] @@ -82,7 +80,7 @@ def __new__(cls, name, bases, class_dict): class_dict['__cls_cols__'] = class_columns # we have to assign the attribute name - for k, v in six.iteritems(class_dict): + for v in class_dict.values(): # catalog the row stylers if getattr(v, '__grid_rowstyler__', None): class_dict['_rowstylers'].append(v) @@ -97,10 +95,10 @@ def __new__(cls, name, bases, class_dict): if for_column: class_dict['_colfilters'].append((v, for_column)) - return super(_DeclarativeMeta, cls).__new__(cls, name, bases, class_dict) + return super().__new__(cls, name, bases, class_dict) -class Column(object): +class Column: """Column represents the data and render specification for a table column. Args: @@ -134,6 +132,7 @@ class Column(object): xls_num_format (str, optional): Default numeric/date format type. """ + xls_width = None xls_num_format = None json_type_helper = None @@ -177,7 +176,7 @@ def visible(self, val): self._visible = val def __new__(cls, *args, **kwargs): - col_inst = super(Column, cls).__new__(cls) + col_inst = super().__new__(cls) if '_dont_assign' not in kwargs: col_inst._assign_to_grid() return col_inst @@ -192,9 +191,20 @@ def _assign_to_grid(self): grid_cls_cols = grid_locals.setdefault('__cls_cols__', []) grid_cls_cols.append(self) - def __init__(self, label, key=None, filter=None, can_sort=True, # noqa: C901 - xls_width=None, xls_num_format=None, render_in=_None, has_subtotal=False, - visible=True, group=None, **kwargs): + def __init__( + self, + label, + key=None, + filter=None, + can_sort=True, + xls_width=None, + xls_num_format=None, + render_in=_None, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): self.label = label self.key = key self.filter = filter @@ -236,17 +246,25 @@ def __init__(self, label, key=None, filter=None, can_sort=True, # noqa: C901 key = getattr(col, 'key', getattr(col, 'name', None)) if key is None: - raise ValueError(_('expected filter to be a SQLAlchemy column-like' - ' object, but it did not have a "key" or "name"' - ' attribute')) + raise ValueError( + _( + 'expected filter to be a SQLAlchemy column-like' + ' object, but it did not have a "key" or "name"' + ' attribute', + ), + ) self.key = self._query_key = key # filters can be sent in as a class (not class instance) if needed if inspect.isclass(filter): if self.expr is None: - raise ValueError(_('the filter was a class type, but no' - ' column-like object is available from "key" to pass in as' - ' as the first argument')) + raise ValueError( + _( + 'the filter was a class type, but no' + ' column-like object is available from "key" to pass in as' + ' as the first argument', + ), + ) self.filter = filter(self.expr) def new_instance(self, grid): @@ -258,9 +276,7 @@ def new_instance(self, grid): """ cls = self.__class__ key = grid.get_unique_column_key( - self.key - or case_cw2us(str(self.label).replace(' ', '')) - or 'unnamed_expression' + self.key or case_cw2us(str(self.label).replace(' ', '')) or 'unnamed_expression', ) column = cls(self.label, key, None, self.can_sort, group=self.group, _dont_assign=True) @@ -271,7 +287,8 @@ def new_instance(self, grid): if self.filter: column.filter = self.filter.new_instance( - dialect=grid.manager.db.engine.dialect, col=column.expr + dialect=grid.manager.db.engine.dialect, + col=column.expr, ) column.head = BlankObject() @@ -282,13 +299,18 @@ def new_instance(self, grid): # try to be smart about which attributes should get copied to the # new instance by looking for attributes on the class that have the # same name as arguments to the classes __init__ method - args = (inspect.getargspec(self.__init__).args - if six.PY2 else inspect.getfullargspec(self.__init__).args) + args = ( + inspect.getargspec(self.__init__).args + if six.PY2 + else inspect.getfullargspec(self.__init__).args + ) for argname in args: - if argname != 'self' and argname not in ( - 'label', 'key', 'filter', 'can_sort', 'render_in', 'visible' - ) and hasattr(self, argname): + if ( + argname != 'self' + and argname not in ('label', 'key', 'filter', 'can_sort', 'render_in', 'visible') + and hasattr(self, argname) + ): setattr(column, argname, getattr(self, argname)) # Copy underlying value of render_in and visible, in case they are @@ -300,8 +322,8 @@ def new_instance(self, grid): def extract_and_format_data(self, record): """ - Extract a value from the record for this column and run it through - the data formaters. + Extract a value from the record for this column and run it through + the data formaters. """ data = self.extract_data(record) data = self.format_data(data) @@ -311,9 +333,9 @@ def extract_and_format_data(self, record): data = _filter(self.grid, data) return data - def extract_data(self, record): # noqa: C901 + def extract_data(self, record): """ - Locate the data for this column in the record and return it. + Locate the data for this column in the record and return it. """ # key style based on key try: @@ -324,10 +346,7 @@ def extract_data(self, record): # noqa: C901 pass # index style based on position in query and key - if ( - self._query_idx is not None - and hasattr(record, '_fields') - ): + if self._query_idx is not None and hasattr(record, '_fields'): try: if record._fields[self._query_idx] == self._query_key: return record[self._query_idx] @@ -338,7 +357,7 @@ def extract_data(self, record): # noqa: C901 try: return getattr(record, self._query_key) except AttributeError as e: - if ("object has no attribute '%s'" % self._query_key) not in str(e): + if (f"object has no attribute '{self._query_key}'") not in str(e): raise except TypeError as e: if 'attribute name must be string' not in str(e): @@ -348,15 +367,15 @@ def extract_data(self, record): # noqa: C901 try: return getattr(record, self.key) except AttributeError as e: - if ("object has no attribute '%s'" % self.key) not in str(e): + if (f"object has no attribute '{self.key}'") not in str(e): raise raise ExtractionError(_('key "{key}" not found in record', key=self.key)) def format_data(self, value): """ - Use to adjust the value extracted from the record for this column. - By default, no change is made. Useful in sub-classes. + Use to adjust the value extracted from the record for this column. + By default, no change is made. Useful in sub-classes. """ return value @@ -369,7 +388,7 @@ def render(self, render_type, record, *args, **kwargs): Renderer-specific methods are expected to be named `render_`, e.g. `render_html` or `render_xlsx`. """ - render_attr = 'render_{0}'.format(render_type) + render_attr = f'render_{render_type}' if hasattr(self, render_attr): return getattr(self, render_attr)(record, *args, **kwargs) return self.extract_and_format_data(record) @@ -378,13 +397,13 @@ def apply_sort(self, query, flag_desc): """Query modifier to enable sort for this column's expression.""" if self.expr is None: direction = 'DESC' if flag_desc else 'ASC' - return query.order_by(sasql.text('{0} {1}'.format(self.key, direction))) + return query.order_by(sasql.text(f'{self.key} {direction}')) if flag_desc: return query.order_by(self.expr.desc()) return query.order_by(self.expr) def __repr__(self): - return ''.format(self) + return f'' def xls_width_calc(self, value): """Calculate a width to use for an Excel renderer. @@ -411,14 +430,37 @@ class LinkColumnBase(Column): link_attrs (dict): Additional attributes to render on the A tag. """ - link_attrs = {} - - def __init__(self, label, key=None, filter=None, can_sort=True, - link_label=None, xls_width=None, xls_num_format=None, - render_in=_None, has_subtotal=False, visible=True, group=None, **kwargs): - super().__init__(label, key, filter, can_sort, xls_width, - xls_num_format, render_in, has_subtotal, visible, - group=group, **kwargs) + + link_attrs: ClassVar = {} + + def __init__( + self, + label, + key=None, + filter=None, + can_sort=True, + link_label=None, + xls_width=None, + xls_num_format=None, + render_in=_None, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): + super().__init__( + label, + key, + filter, + can_sort, + xls_width, + xls_num_format, + render_in, + has_subtotal, + visible, + group=group, + **kwargs, + ) self.link_label = link_label def link_to(self, label, url, **kwargs): @@ -427,7 +469,7 @@ def link_to(self, label, url, **kwargs): '{{label}}', url=url, attrs=kwargs, - label=label + label=label, ) def render_html(self, record, hah): @@ -457,18 +499,42 @@ class BoolColumn(Column): false_label (str, optional): String to use for the false case. """ + json_type_helper = 'boolean' - def __init__(self, label, key_or_filter=None, key=None, can_sort=True, - reverse=False, true_label=_('True'), false_label=_('False'), - xls_width=None, xls_num_format=None, render_in=_None, has_subtotal=False, - visible=True, group=None, **kwargs): - super().__init__(label, key_or_filter, key, can_sort, xls_width, - xls_num_format, render_in, - has_subtotal, visible, group=group, **kwargs) + def __init__( + self, + label, + key_or_filter=None, + key=None, + can_sort=True, + reverse=False, + true_label=None, + false_label=None, + xls_width=None, + xls_num_format=None, + render_in=_None, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): + super().__init__( + label, + key_or_filter, + key, + can_sort, + xls_width, + xls_num_format, + render_in, + has_subtotal, + visible, + group=group, + **kwargs, + ) self.reverse = reverse - self.true_label = true_label - self.false_label = false_label + self.true_label = _('True') if true_label is None else true_label + self.false_label = _('False') if false_label is None else false_label def format_data(self, data): if self.reverse: @@ -486,12 +552,37 @@ class YesNoColumn(BoolColumn): """ - def __init__(self, label, key_or_filter=None, key=None, can_sort=True, - reverse=False, xls_width=None, xls_num_format=None, render_in=_None, - has_subtotal=False, visible=True, group=None, **kwargs): - super().__init__(label, key_or_filter, key, can_sort, reverse, - _('Yes'), _('No'), xls_width, xls_num_format, - render_in, has_subtotal, visible, group=group, **kwargs) + def __init__( + self, + label, + key_or_filter=None, + key=None, + can_sort=True, + reverse=False, + xls_width=None, + xls_num_format=None, + render_in=_None, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): + super().__init__( + label, + key_or_filter, + key, + can_sort, + reverse, + _('Yes'), + _('No'), + xls_width, + xls_num_format, + render_in, + has_subtotal, + visible, + group=group, + **kwargs, + ) class DateColumnBase(Column): @@ -508,25 +599,46 @@ class DateColumnBase(Column): """ - def __init__(self, label, key_or_filter=None, key=None, can_sort=True, - html_format=None, csv_format=None, xls_width=None, - xls_num_format=None, render_in=_None, has_subtotal=False, visible=True, group=None, - **kwargs): - super().__init__(label, key_or_filter, key, can_sort, xls_width, - xls_num_format, render_in, has_subtotal, - visible, group=group, **kwargs) + def __init__( + self, + label, + key_or_filter=None, + key=None, + can_sort=True, + html_format=None, + csv_format=None, + xls_width=None, + xls_num_format=None, + render_in=_None, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): + super().__init__( + label, + key_or_filter, + key, + can_sort, + xls_width, + xls_num_format, + render_in, + has_subtotal, + visible, + group=group, + **kwargs, + ) if html_format: self.html_format = html_format if csv_format: self.csv_format = csv_format - def _format_datetime(self, data, format): + def _format_datetime(self, data, fmt): # if we have an arrow date, allow html_format to use that functionality - if arrow and isinstance(data, arrow.Arrow): - if data.strftime(format) == format: - return data.format(format) - return data.strftime(format) + if arrow and isinstance(data, arrow.Arrow) and data.strftime(fmt) == fmt: + return data.format(fmt) + return data.strftime(fmt) def render_html(self, record, hah): data = self.extract_and_format_data(record) @@ -584,6 +696,7 @@ class DateColumn(DateColumnBase): xls_num_format (str, optional): Date format string for Excel. """ + # !!!: localize html_format = '%m/%d/%Y' csv_format = '%Y-%m-%d' @@ -604,6 +717,7 @@ class DateTimeColumn(DateColumnBase): xls_num_format (str, optional): Date format string for Excel. """ + # !!!: localize html_format = '%m/%d/%Y %I:%M %p' csv_format = '%Y-%m-%d %H:%M:%S%z' @@ -624,6 +738,7 @@ class TimeColumn(DateColumnBase): xls_num_format (str, optional): Date format string for Excel. """ + # !!!: localize html_format = '%I:%M %p' csv_format = '%H:%M' @@ -660,20 +775,51 @@ class NumericColumn(Column): `xls_fmt_general`, `xls_fmt_accounting`, `xls_fmt_percent` are Excel number formats used for the corresponding `format_as` setting. """ + # !!!: localize xls_fmt_general = '#,##0{dec_places};{neg_prefix}-#,##0{dec_places}' - xls_fmt_accounting = '_($* #,##0{dec_places}_);{neg_prefix}_($* (#,##0{dec_places})' + \ - ';_($* "-"??_);_(@_)' + xls_fmt_accounting = ( + '_($* #,##0{dec_places}_);{neg_prefix}_($* (#,##0{dec_places})' + ';_($* "-"??_);_(@_)' + ) xls_fmt_percent = '0{dec_places}%;{neg_prefix}-0{dec_places}%' - def __init__(self, label, key_or_filter=None, key=None, can_sort=True, - reverse=False, xls_width=None, xls_num_format=None, - render_in=_None, format_as='general', places=2, curr='', - sep=',', dp='.', pos='', neg='-', trailneg='', - xls_neg_red=True, has_subtotal=False, visible=True, group=None, **kwargs): - super().__init__(label, key_or_filter, key, can_sort, xls_width, - xls_num_format, render_in, - has_subtotal, visible, group=group, **kwargs) + def __init__( + self, + label, + key_or_filter=None, + key=None, + can_sort=True, + reverse=False, + xls_width=None, + xls_num_format=None, + render_in=_None, + format_as='general', + places=2, + curr='', + sep=',', + dp='.', + pos='', + neg='-', + trailneg='', + xls_neg_red=True, + has_subtotal=False, + visible=True, + group=None, + **kwargs, + ): + super().__init__( + label, + key_or_filter, + key, + can_sort, + xls_width, + xls_num_format, + render_in, + has_subtotal, + visible, + group=group, + **kwargs, + ) self.places = places self.curr = curr self.sep = sep @@ -744,9 +890,7 @@ def get_num_format(self): @property def xlsx_style(self): """Number format for XLSX target.""" - return { - 'num_format': self.get_num_format() - } + return {'num_format': self.get_num_format()} class EnumColumn(Column): @@ -754,19 +898,21 @@ class EnumColumn(Column): This column type is meant to be used with python `enum.Enum` type columns. It expects that the display value is the `value` attribute of the enum instance. """ + def format_data(self, value): if value is None: return None return value.value -class ColumnGroup(object): +class ColumnGroup: r"""Represents a grouping of grid columns which may be rendered within a group label. Args: label (str): Grouping label to be rendered for the column set. class\_ (str): CSS class name to apply in HTML rendering. - """ # noqa: W605 + """ + label = None class_ = None @@ -811,9 +957,9 @@ def args_paging(self): def args_sort(self): # sort args are stored as tuple of (key, flag_desc) - return map( - lambda item: (f'sort{item[0]}', ('-' if item[1][1] else '') + item[1][0]), - enumerate(self.grid.order_by, 1), + return ( + (f'sort{item[0]}', ('-' if item[1][1] else '') + item[1][0]) + for item in enumerate(self.grid.order_by, 1) ) def args_filter(self): @@ -825,10 +971,9 @@ def args_filter(self): continue grid_args.append((f'op({col.key})', _filter.op)) if _filter.value1: - grid_args.extend(map( - lambda item: (f'v1({col.key})', item), - tolist(_filter.value1), - )) + grid_args.extend( + ((f'v1({col.key})', item) for item in tolist(_filter.value1)), + ) if _filter.value2: grid_args.append((f'v2({col.key})', _filter.value2)) return grid_args @@ -914,7 +1059,8 @@ class BaseGrid(six.with_metaclass(_DeclarativeMeta, object)): are not set on the grid. Note, relationship attributes must be referenced within tuples, due to SQLAlchemy magic. - """ # noqa: W605 + """ + __cls_cols__ = () identifier = None sorter_on = True @@ -951,8 +1097,15 @@ class BaseGrid(six.with_metaclass(_DeclarativeMeta, object)): # Set to None to disable this check unconfirmed_export_limit = 10000 - def __init__(self, ident=None, per_page=_None, on_page=_None, qs_prefix='', class_='datagrid', - **kwargs): + def __init__( + self, + ident=None, + per_page=_None, + on_page=_None, + qs_prefix='', + class_='datagrid', + **kwargs, + ): self._ident = ident self.hah = HTMLAttributes(kwargs) self.hah.id = self.ident @@ -1011,25 +1164,17 @@ def add_column(self, column): if new_col.filter is not None: self.filtered_cols[new_col.key] = new_col if new_col.has_subtotal is not False and new_col.has_subtotal is not None: - self.subtotal_cols[new_col.key] = ( - subtotal_function_map(new_col.has_subtotal), - new_col - ) + self.subtotal_cols[new_col.key] = (subtotal_function_map(new_col.has_subtotal), new_col) def drop_columns(self, column_keys): - self.columns = list(filter( - lambda col: col.key not in tolist(column_keys), - self.columns - )) + self.columns = list(filter(lambda col: col.key not in tolist(column_keys), self.columns)) for key in tolist(column_keys): self.key_column_map.pop(key, None) self.filtered_cols.pop(key, None) self.subtotal_cols.pop(key, None) def post_init(self): - """Provided for subclasses to run post-initialization customizations. - """ - pass + """Provided for subclasses to run post-initialization customizations.""" def set_column_order(self, column_keys): """Most renderers output columns in the order they appear in the grid's ``columns`` @@ -1041,14 +1186,10 @@ def set_column_order(self, column_keys): if key_check: raise Exception(f'Keys not recognized on grid: {key_check}') - self.columns = [ - self.key_column_map[key] for key in column_keys - ] + self.columns = [self.key_column_map[key] for key in column_keys] def before_query_hook(self): - """Hook to give subclasses a chance to change things before executing the query. - """ - pass + """Hook to give subclasses a chance to change things before executing the query.""" def build(self, grid_args=None): """Apply query args, run `before_query_hook`, and execute a record count query. @@ -1061,7 +1202,7 @@ def build(self, grid_args=None): # this will force the query to execute. We used to wait to evaluate this but it ended # up causing AttributeErrors to be hidden when the grid was used in Jinja. # Calling build is now preferred over calling .apply_qs_args() and then .html() - self.record_count + self.record_count # noqa: B018 def check_auth(self): """For API usage, provides a hook for grids to specify authorization that should be @@ -1072,7 +1213,6 @@ def check_auth(self): Note, this method is not part of normal grid/render operation. It will only be executed if run by a calling layer, such as the Flask WebGridAPI manager/extension. """ - pass def column(self, ident): """Retrieve a grid column instance via either the key string or index int. @@ -1127,7 +1267,7 @@ def get_unique_column_key(self, key): new_key = key while self.has_column(new_key): suffix_counter += 1 - new_key = '{}_{}'.format(key, suffix_counter) + new_key = f'{key}_{suffix_counter}' return new_key def iter_columns(self, render_type): @@ -1167,16 +1307,21 @@ def search_expression_generators(self): def check_expression_generator(expr_gen): if expr_gen is not None and not callable(expr_gen): raise Exception( - 'bad filter search expression: {} is not callable'.format(str(expr_gen)) + f'bad filter search expression: {expr_gen} is not callable', ) return expr_gen is not None # Also conditionally filter aggregate/non-aggregate so we're not mixing expression types. - return tuple(filter( - check_expression_generator, - [col.filter.get_search_expr() for col in self.filtered_cols.values() - if col.filter.is_aggregate is is_aggregate] - )) + return tuple( + filter( + check_expression_generator, + [ + col.filter.get_search_expr() + for col in self.filtered_cols.values() + if col.filter.is_aggregate is is_aggregate + ], + ), + ) @property def search_uses_aggregate(self): @@ -1199,8 +1344,7 @@ def search_uses_aggregate(self): return has_search def set_renderers(self): - """Renderers assigned as attributes on the grid instance, named by render target. - """ + """Renderers assigned as attributes on the grid instance, named by render target.""" self.html = HTML(self) for key, value in self.allowed_export_targets.items(): setattr(self, key, value(self)) @@ -1269,9 +1413,7 @@ def clear_record_cache(self, preserve_count=False): @property def ident(self): - return self._ident \ - or self.identifier \ - or case_cw2us(self.__class__.__name__) + return self._ident or self.identifier or case_cw2us(self.__class__.__name__) @property def default_session_key(self): @@ -1313,7 +1455,7 @@ def record_count(self): t0 = time.perf_counter() self._record_count = query.count() t1 = time.perf_counter() - log.debug('Count query ran in {} seconds'.format(t1 - t0)) + log.debug(f'Count query ran in {t1 - t0} seconds') return self._record_count @property @@ -1331,7 +1473,7 @@ def records(self): t0 = time.perf_counter() self._records = query.all() t1 = time.perf_counter() - log.debug('Data query ran in {} seconds'.format(t1 - t0)) + log.debug(f'Data query ran in {t1 - t0} seconds') return self._records def _totals_col_results(self, page_totals_only): @@ -1354,17 +1496,13 @@ def _totals_col_results(self, page_totals_only): # can be applied. # This will apply to any columns with an expr. Other subtotaled columns can be # tacked onto the end - these will not be indexed and must be referred to by name - for colobj in [ - col for col in self.columns if col.expr is not None - ] + [ - coltuple[1] for _, coltuple in self.subtotal_cols.items() if coltuple[1].expr is None + for colobj in [col for col in self.columns if col.expr is not None] + [ + coltuple[1] for coltuple in self.subtotal_cols.values() if coltuple[1].expr is None ]: colname = colobj._query_key or colobj.key if colobj.key not in self.subtotal_cols: - cols.append( - sa.literal(None).label(colname) - ) + cols.append(sa.literal(None).label(colname)) continue sa_aggregate_func, _ = self.subtotal_cols[colobj.key] @@ -1395,7 +1533,7 @@ def _totals_col_results(self, page_totals_only): query._set_select_from([SUB], True) result = query.first() t1 = time.perf_counter() - log.debug('Totals query ran in {} seconds'.format(t1 - t0)) + log.debug(f'Totals query ran in {t1 - t0} seconds') return result @@ -1412,11 +1550,7 @@ def page_totals(self): Returns: Any: Single result record, or None if page totals are not configured. """ - if ( - self._page_totals is None - and self.subtotals in ('page', 'all') - and self.subtotal_cols - ): + if self._page_totals is None and self.subtotals in ('page', 'all') and self.subtotal_cols: self._page_totals = self._totals_col_results(page_totals_only=True) return self._page_totals @@ -1433,18 +1567,13 @@ def grand_totals(self): Returns: Any: Single result record, or None if grand totals are not configured. """ - if ( - self._grand_totals is None - and self.subtotals in ('grand', 'all') - and self.subtotal_cols - ): + if self._grand_totals is None and self.subtotals in ('grand', 'all') and self.subtotal_cols: self._grand_totals = self._totals_col_results(page_totals_only=False) return self._grand_totals @property def page_count(self): - """Page count, or 1 if no `per_page` is set. - """ + """Page count, or 1 if no `per_page` is set.""" if self.per_page is None: return 1 return max(0, self.record_count - 1) // self.per_page + 1 @@ -1524,10 +1653,10 @@ def query_base(self, has_sort, has_filters): if self.query_select_from is not None: query = query.select_from(*tolist(self.query_select_from)) - for join_terms in (tolist(self.query_joins) or tuple()): + for join_terms in tolist(self.query_joins) or (): query = query.join(*tolist(join_terms)) - for join_terms in (tolist(self.query_outer_joins) or tuple()): + for join_terms in tolist(self.query_outer_joins) or (): query = query.outerjoin(*tolist(join_terms)) if self.query_filter: @@ -1574,7 +1703,7 @@ def query_filters(self, query): for col in six.itervalues(self.filtered_cols): if col.filter.is_active: - filter_display.append('{}: {}'.format(col.key, str(col.filter))) + filter_display.append(f'{col.key}: {col.filter}') query = col.filter.apply(query) if filter_display: log.debug(';'.join(filter_display)) @@ -1596,10 +1725,12 @@ def apply_search(self, query, value): Query: SQLAlchemy query """ filter_method = query.having if self.search_uses_aggregate else query.filter - filter_clauses = list(filter( - lambda item: item is not None, - (expr(value) for expr in self.search_expression_generators) - )) + filter_clauses = list( + filter( + lambda item: item is not None, + (expr(value) for expr in self.search_expression_generators), + ), + ) if not filter_clauses: return query @@ -1617,7 +1748,7 @@ def query_paging(self, query): if self.on_page and self.per_page: offset = (self.on_page - 1) * self.per_page query = query.offset(offset).limit(self.per_page) - log.debug('Page {}; {} per page'.format(self.on_page, self.per_page)) + log.debug(f'Page {self.on_page}; {self.per_page} per page') return query def query_sort(self, query): @@ -1669,7 +1800,7 @@ def _fix_mssql_order_by(self, query): if query._order_by_clauses: return query raise Exception( - 'Paging is enabled, but query does not have ORDER BY clause required for MSSQL' + 'Paging is enabled, but query does not have ORDER BY clause required for MSSQL', ) def build_qs_args(self, include_session=False): @@ -1722,10 +1853,7 @@ def apply_qs_args(self, add_user_warnings=True, grid_args=None): self.manager.flash_message('warning', msg) def _apply_search(self, args): - if ( - 'search' in args - and self.can_search() - ): + if 'search' in args and self.can_search(): self.search_value = args['search'].strip() if args['search'] else None def _apply_filtering(self, args): @@ -1736,9 +1864,9 @@ def _apply_filtering(self, args): """ for col in six.itervalues(self.filtered_cols): filter = col.filter - filter_op_qsk = 'op({0})'.format(col.key) - filter_v1_qsk = 'v1({0})'.format(col.key) - filter_v2_qsk = 'v2({0})'.format(col.key) + filter_op_qsk = f'op({col.key})' + filter_v1_qsk = f'v1({col.key})' + filter_v2_qsk = f'v2({col.key})' filter_op_value = args.get(filter_op_qsk, None) @@ -1802,7 +1930,7 @@ def _apply_sorting(self, args): while True: counter += 1 - sort_arg = 'sort{}'.format(counter) + sort_arg = f'sort{counter}' if sort_arg not in args: break sort_qs_values.append(args[sort_arg]) @@ -1827,7 +1955,7 @@ def prefix_qs_arg_key(self, key): Returns: str: Prefixed arg key. """ - return '{0}{1}'.format(self.qs_prefix, key) + return f'{self.qs_prefix}{key}' def apply_validator(self, validator, value, qs_arg_key): """Apply a webgrid validator to value, and produce a warning if invalid. @@ -1877,7 +2005,7 @@ def export_as_response(self, wb=None, sheet_name=None): return exporter.as_response() def __repr__(self): - return ''.format(self.__class__.__name__) + return f'' def row_styler(f): @@ -1889,6 +2017,7 @@ def col_styler(for_column): def decorator(f): f.__grid_colstyler__ = for_column return f + return decorator @@ -1896,4 +2025,5 @@ def col_filter(for_column): def decorator(f): f.__grid_colfilter__ = for_column return f + return decorator diff --git a/webgrid/blazeweb.py b/src/webgrid/blazeweb.py similarity index 86% rename from webgrid/blazeweb.py rename to src/webgrid/blazeweb.py index 8c41540..407d798 100644 --- a/webgrid/blazeweb.py +++ b/src/webgrid/blazeweb.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from blazeweb.content import getcontent from blazeweb.globals import ag, rg, user from blazeweb.routing import abs_static_url @@ -8,6 +6,7 @@ from blazeweb.wrappers import StreamResponse from jinja2.exceptions import TemplateNotFound from sqlalchemybwc import db as sabwc_db + from webgrid import BaseGrid from webgrid.extensions import FrameworkManager, gettext from webgrid.renderers import render_html_attributes @@ -54,13 +53,13 @@ def request(self): return rg.request def static_url(self, url_tail): - return abs_static_url('component/webgrid/{0}'.format(url_tail)) + return abs_static_url(f'component/webgrid/{url_tail}') def file_as_response(self, data_stream, file_name, mime_type): rp = StreamResponse(data_stream) rp.headers['Content-Type'] = mime_type if file_name is not None: - rp.headers['Content-Disposition'] = 'attachment; filename={}'.format(file_name) + rp.headers['Content-Disposition'] = f'attachment; filename={file_name}' abort(rp) def render_template(self, endpoint, **kwargs): @@ -69,8 +68,10 @@ def render_template(self, endpoint, **kwargs): except TemplateNotFound: if ':' in endpoint: raise - return getcontent('{0}:{1}'.format(self.component, endpoint), **kwargs) -wg_blaze_manager = WebGrid() # noqa: E305 + return getcontent(f'{self.component}:{endpoint}', **kwargs) + + +wg_blaze_manager = WebGrid() class Grid(BaseGrid): diff --git a/webgrid/extensions.py b/src/webgrid/extensions.py similarity index 93% rename from webgrid/extensions.py rename to src/webgrid/extensions.py index e3938ff..81fc42f 100644 --- a/webgrid/extensions.py +++ b/src/webgrid/extensions.py @@ -4,7 +4,7 @@ import json from pathlib import Path import re -from typing import Any, Dict +from typing import Any import warnings import arrow @@ -13,6 +13,7 @@ from . import types + MORPHI_PACKAGE_NAME = 'webgrid' # begin morphi boilerplate @@ -56,7 +57,7 @@ def ngettext(singular, plural, num, **variables): class CustomJsonEncoder(json.JSONEncoder): def default(self, obj): - if isinstance(obj, datetime.date) or isinstance(obj, arrow.Arrow): + if isinstance(obj, (datetime.date, arrow.Arrow)): return obj.isoformat() elif isinstance(obj, Decimal): return float(obj) @@ -68,12 +69,13 @@ def default(self, obj): class ArgsLoader(ABC): - """ Base args loader class. + """Base args loader class. When a grid calls for its args, it requests them from the manager. The manager will have one or more args loaders to be run in order. Each loader fetches its args from the request, then ensuing loaders have the opportunity to modify or perform other operations as needed. """ + def __init__(self, manager): self.manager = manager @@ -85,7 +87,7 @@ def get_args(self, grid, previous_args): class GridPrefixBase(ArgsLoader): def sanitize_arg_name(self, arg_name, qs_prefix): if qs_prefix and arg_name.startswith(qs_prefix): - return arg_name[len(qs_prefix):] + return arg_name[len(qs_prefix) :] return arg_name def get_sanitized_args(self, grid, args): @@ -107,8 +109,8 @@ def get_args(self, grid, previous_args): request_args = self.get_args_from_request() if 'dgreset' in request_args: if 'session_key' in request_args: - return MultiDict(dict(dgreset=1, session_key=request_args['session_key'])) - return MultiDict(dict(dgreset=1)) + return MultiDict({'dgreset': 1, 'session_key': request_args['session_key']}) + return MultiDict({'dgreset': 1}) request_args = self.get_sanitized_args(grid, request_args) request_args.update(previous_args) @@ -120,7 +122,7 @@ def get_args_from_request(self): class RequestArgsLoader(GridPrefixBase, ArgsLoader): - """ Simple args loader for web request. + """Simple args loader for web request. Args are usually passed through directly from the request. If the grid has a query string prefix, the relevant args will be namespaced - sanitize them here and return the subset @@ -128,12 +130,13 @@ class RequestArgsLoader(GridPrefixBase, ArgsLoader): In the reset case, ignore most args, and return only the reset flag and session key (if any). """ + def get_args_from_request(self): return self.manager.request_url_args() class RequestFormLoader(GridPrefixBase, ArgsLoader): - """ Simple form loader for web request. + """Simple form loader for web request. Form values are usually passed through directly from the request. If the grid has a prefix, the relevant args will be namespaced - sanitize them here and return the subset needed for the given @@ -141,32 +144,31 @@ class RequestFormLoader(GridPrefixBase, ArgsLoader): In the reset case, ignore most args, and return only the reset flag and session key (if any). """ + def get_args_from_request(self): return self.manager.request_form_args() class RequestJsonLoader(ArgsLoader): - """ JSON loader for web request. + """JSON loader for web request. See :meth:`webgrid.types.GridSettings` for the expected JSON structure. The parsed arguments are converted to the querystring arg format and merged with any previous args. """ - def json_to_args(self, data: Dict[str, Any]): + + def json_to_args(self, data: dict[str, Any]): meta = types.GridSettings.from_dict(data) return MultiDict(meta.to_args()) def get_args(self, grid, previous_args): data = self.manager.request_json() - if data: - current_args = self.json_to_args(data) - else: - current_args = MultiDict() + current_args = self.json_to_args(data) if data else MultiDict() current_args.update(previous_args) return current_args class WebSessionArgsLoader(ArgsLoader): - """ Session manager for grid args. + """Session manager for grid args. Args are assumed to have been sanitized from the request already. But, args may be combined from the request and the session store for a number of cases. @@ -179,6 +181,7 @@ class WebSessionArgsLoader(ArgsLoader): In the reset case, ignore most args, and return only the reset flag and session key (if any). And clear the session store for the given grid. """ + _session_exclude_keys = ( '__foreign_session_loaded__', 'apply', @@ -198,7 +201,7 @@ def args_have_op(self, args): bool: True if at least one op is present. """ regex = re.compile(r'(op\(.*\))') - return any(regex.match(a) for a in args.keys()) + return any(regex.match(a) for a in args) def args_have_page(self, args): """Check args for containing any page args. @@ -210,7 +213,7 @@ def args_have_page(self, args): bool: True if at least one page arg is present. """ regex = re.compile('(onpage|perpage)') - return any(regex.match(a) for a in args.keys()) + return any(regex.match(a) for a in args) def args_have_sort(self, args): """Check args for containing any sort keys. @@ -223,7 +226,8 @@ def args_have_sort(self, args): """ regex = re.compile('(sort[1-9][0-9]*)') return [ - arg.string for arg in filter( + arg.string + for arg in filter( lambda match: match is not None, [regex.match(a) for a in args], ) @@ -240,7 +244,7 @@ def remove_grid_session(self, session_key): self.manager.persist_web_session() def apply_session_overrides(self, session_args, previous_args): - """ Update session args as needed from the incoming request. + """Update session args as needed from the incoming request. If session override case, wholesale update from the incoming request. This is useful if a single filter needs to be changed via the URL, but we don't @@ -350,7 +354,7 @@ def save_session_store(self, grid, args): # and also as the default args for this grid web_session = self.manager.web_session() if 'dgsessions' not in web_session: - web_session['dgsessions'] = dict() + web_session['dgsessions'] = {} dgsessions = web_session['dgsessions'] grid_session_key = args.get('session_key') or grid.session_key # work with a copy here @@ -368,8 +372,9 @@ def save_session_store(self, grid, args): return self.manager.persist_web_session() args['datagrid'] = grid.default_session_key - args['session_timestamp'] = existing_default_store['session_timestamp'] \ - = arrow.utcnow().isoformat() + args['session_timestamp'] = existing_default_store['session_timestamp'] = ( + arrow.utcnow().isoformat() + ) # if we're pulling a grid matching the default session, but with a different key, # no need to store the sepearate session @@ -387,7 +392,7 @@ def save_session_store(self, grid, args): self.manager.persist_web_session() def get_args(self, grid, previous_args): - """ Retrieve args from session and override as appropriate. + """Retrieve args from session and override as appropriate. Submitting the header form flushes all args to the URL, so no need to load them from session. @@ -418,7 +423,7 @@ def get_args(self, grid, previous_args): if grid.session_on: self.remove_grid_session(previous_args.get('session_key') or grid.session_key) self.remove_grid_session(grid.default_session_key) - return MultiDict(dict(dgreset=1, session_key=previous_args.get('session_key'))) + return MultiDict({'dgreset': 1, 'session_key': previous_args.get('session_key')}) # From here on, work with a copy so as not to mutate the incoming args request_args = previous_args.copy() @@ -428,7 +433,8 @@ def get_args(self, grid, previous_args): if (not self.args_have_op(request_args) and not is_apply) or is_override: # We are in a session-loading case. session_args = self.apply_session_overrides( - self.get_session_store(grid, request_args), request_args + self.get_session_store(grid, request_args), + request_args, ) # Flag a foreign session if loading from another grid's session. @@ -459,6 +465,7 @@ class FrameworkManager(ABC): disable. Default 12. """ + jinja_loader = lambda self: jinja.PackageLoader('webgrid', 'templates') args_loaders = ( RequestArgsLoader, @@ -491,7 +498,7 @@ def init_jinja(self): self.jinja_environment = jinja.Environment( loader=self.jinja_loader, finalize=lambda x: x if x is not None else '', - autoescape=True + autoescape=True, ) def static_path(self): @@ -513,7 +520,8 @@ def test_request_context(self, url='/'): def request_args(self): warnings.warn( 'request_args is deprecated and will be removed in a future version.', - DeprecationWarning, 2 + DeprecationWarning, + 2, ) return self.request_url_args() diff --git a/webgrid/filters.py b/src/webgrid/filters.py similarity index 69% rename from webgrid/filters.py rename to src/webgrid/filters.py index dfce0bc..ce95801 100644 --- a/webgrid/filters.py +++ b/src/webgrid/filters.py @@ -1,23 +1,22 @@ -from __future__ import absolute_import import calendar import datetime as dt from decimal import Decimal as D import inspect +from typing import ClassVar from blazeutils import tolist from blazeutils.dates import ensure_date, ensure_datetime from dateutil.parser import parse -from dateutil.relativedelta import relativedelta, SU -from sqlalchemy.sql import or_, and_ -import sqlalchemy as sa +from dateutil.relativedelta import SU, relativedelta import six +import sqlalchemy as sa +from sqlalchemy.sql import and_, or_ from werkzeug.datastructures import ImmutableDict -from .extensions import ( - gettext, - lazy_gettext as _ -) from . import types, validators +from .extensions import gettext +from .extensions import lazy_gettext as _ + try: import arrow @@ -33,7 +32,7 @@ class UnrecognizedOperator(ValueError): pass -class Operator(object): +class Operator: """Filter operator representing name and potential inputs. See webgrid.filters.ops for a collection of predefined operators. @@ -52,6 +51,7 @@ class Operator(object): hint (str, optional): Input field hint to show in UI. Defaults to None. """ + def __init__(self, key, display, field_type, hint=None): self.key = key self.display = display @@ -65,7 +65,7 @@ def __hash__(self): return hash(self.key) -class ops(object): +class ops: eq = Operator('eq', _('is'), 'input') not_eq = Operator('!eq', _('is not'), 'input') is_ = Operator('is', _('is'), 'select') @@ -73,7 +73,7 @@ class ops(object): empty = Operator('empty', _('empty'), None) not_empty = Operator('!empty', _('not empty'), None) contains = Operator('contains', _('contains'), 'input') - not_contains = Operator('!contains', _('doesn\'t contain'), 'input') + not_contains = Operator('!contains', _("doesn't contain"), 'input') less_than_equal = Operator('lte', _('less than or equal'), 'input') greater_than_equal = Operator('gte', _('greater than or equal'), 'input') between = Operator('between', _('between'), '2inputs') @@ -95,7 +95,7 @@ class ops(object): this_year = Operator('thisyear', _('this year'), None) -class FilterBase(object): +class FilterBase: """Base filter class interface for webgrid filters. Contains filter operators, inputs, render information, and the inner workings to apply @@ -143,6 +143,7 @@ class FilterBase(object): error (bool): True if input processing encountered a validation error. """ + operators = ops.eq, ops.not_eq, ops.empty, ops.not_empty # one operator may be specified as the "primary", i.e. the one to select when filter is added # note, the renderer is responsible for using this operator @@ -152,16 +153,22 @@ class FilterBase(object): # new_instance() init_attrs_for_instance = () # current HTML renderer allows for "input", "input2", and/or "select" - input_types = 'input', + input_types = ('input',) # match operators to the HTML5 type(s) - html_input_types = {} + html_input_types: ClassVar = {} # does this filter take a list of values in it's set() method receives_list = False # does this filter apply to the HAVING clause is_aggregate = False - def __init__(self, sa_col=None, default_op=None, default_value1=None, default_value2=None, - dialect=None): + def __init__( + self, + sa_col=None, + default_op=None, + default_value1=None, + default_value2=None, + dialect=None, + ): # attributes from static instance self.sa_col = sa_col self._default_op = default_op @@ -186,14 +193,17 @@ def __init__(self, sa_col=None, default_op=None, default_value1=None, default_va # Can't use inspect.stack() here because when called from within a Jinja template, # inspect.getframeinfo raises an exception while frame: - if frame.f_code.co_name != '__init__' or \ - not isinstance(frame.f_locals.get('self'), FilterBase): + if frame.f_code.co_name != '__init__' or not isinstance( + frame.f_locals.get('self'), + FilterBase, + ): break outermost = inspect.getargvalues(frame) frame = frame.f_back - self._vargs = [outermost.locals[a] for a in outermost.args[1:]] + \ - list(outermost.locals[outermost.varargs] if outermost.varargs else []) + self._vargs = [outermost.locals[a] for a in outermost.args[1:]] + list( + outermost.locals[outermost.varargs] if outermost.varargs else [], + ) self._kwargs = outermost.locals[outermost.keywords] if outermost.keywords else {} @@ -201,8 +211,10 @@ def __init__(self, sa_col=None, default_op=None, default_value1=None, default_va def is_active(self): """Filter is active if op is set and input requirements are met.""" operator_by_key = {op.key: op for op in self.operators} - return self.op is not None and not self.error and ( - operator_by_key[self.op].field_type is None or self.value1 is not None + return ( + self.op is not None + and not self.error + and (operator_by_key[self.op].field_type is None or self.value1 is not None) ) @property @@ -239,7 +251,7 @@ def set(self, op, value1, value2=None): if not op: self.default_op = self._default_op() if callable(self._default_op) else self._default_op self.op = self.default_op - self.using_default_op = (self.default_op is not None) + self.using_default_op = self.default_op is not None if self.op is None: return @@ -287,28 +299,25 @@ def apply(self, query): def process(self, value, is_value2): """ - Process the values as given to .set(), validating and manipulating - as needed. + Process the values as given to .set(), validating and manipulating + as needed. """ return value def format_invalid(self, exc, col): """Wrapper for generating a validation error string.""" - return '{0}: {1}'.format( - col.label, - str(exc) - ) + return f'{col.label}: {exc}' def get_search_expr(self): """ - Filters can be used for the general "single search" function on the grid. For this to - work in SQL, the grid needs to pull search expressions from all filters and OR them - together. + Filters can be used for the general "single search" function on the grid. For this to + work in SQL, the grid needs to pull search expressions from all filters and OR them + together. - Return value is expected to be a callable taking one argument (the search value). - E.g. `lambda value: self.sa_col.like('%{}%'.format(value))` + Return value is expected to be a callable taking one argument (the search value). + E.g. `lambda value: self.sa_col.like('%{}%'.format(value))` - Return value of `None` is filtered out, essentially disabling search for the filter. + Return value of `None` is filtered out, essentially disabling search for the filter. """ return None @@ -332,8 +341,7 @@ def serialize_filter_option(self, key, value): def serialize_filter_spec(self): return types.FilterSpec( operators=[self.serialize_filter_operator(op) for op in self.operators], - primary_op=self.serialize_filter_operator( - self.primary_op) if self.primary_op else None, + primary_op=self.serialize_filter_operator(self.primary_op) if self.primary_op else None, ) def new_instance(self, **kwargs): @@ -349,12 +357,11 @@ def new_instance(self, **kwargs): return new_filter def __repr__(self): - return 'class={}, op={}, value1={}, value2={}'.format( - self.__class__.__name__, self.op, self.value1, self.value2 - ) + cls_name = self.__class__.__name__ + return f'class={cls_name}, op={self.op}, value1={self.value1}, value2={self.value2}' -class _NoValue(object): +class _NoValue: pass @@ -386,10 +393,21 @@ class attribute, but can be overridden as an instance attribute, property, or me receives_list = True options_from = () - def __init__(self, sa_col, value_modifier='auto', default_op=None, default_value1=None, - default_value2=None): - FilterBase.__init__(self, sa_col, default_op=default_op, default_value1=default_value1, - default_value2=default_value2) + def __init__( + self, + sa_col, + value_modifier='auto', + default_op=None, + default_value1=None, + default_value2=None, + ): + FilterBase.__init__( + self, + sa_col, + default_op=default_op, + default_value1=default_value1, + default_value2=default_value2, + ) # attributes from static instance self.value_modifier = value_modifier @@ -402,9 +420,7 @@ def serialize_filter_spec(self): return types.OptionsFilterSpec( operators=base_spec.operators, primary_op=base_spec.primary_op, - options=[ - self.serialize_filter_option(key, value) - for key, value in self.options_seq], + options=[self.serialize_filter_option(key, value) for key, value in self.options_seq], ) def new_instance(self, **kwargs): @@ -441,9 +457,14 @@ def setup_validator(self): # a set() operation should be converted to if self.value_modifier == 'auto' or self.value_modifier is None: if self.value_modifier and len(self.option_keys) == 0: - raise ValueError(_('value_modifier argument set to "auto", but ' - 'the options set is empty and the type can therefore not ' - 'be determined for {name}', name=self.__class__.__name__)) + raise ValueError( + _( + 'value_modifier argument set to "auto", but ' + 'the options set is empty and the type can therefore not ' + 'be determined for {name}', + name=self.__class__.__name__, + ), + ) first_key = self.option_keys[0] if isinstance(first_key, six.string_types) or self.value_modifier is None: self.value_modifier = validators.StringValidator() @@ -455,17 +476,21 @@ def setup_validator(self): self.value_modifier = validators.DecimalValidator() else: raise TypeError( - _("can't use value_modifier='auto' when option keys are {key_type}", - key_type=type(first_key)) + _( + "can't use value_modifier='auto' when option keys are {key_type}", + key_type=type(first_key), + ), ) else: # if its not the string 'auto' and its not a webgrid validator, assume # its a callable and wrap with a webgrid validator if not hasattr(self.value_modifier, 'process'): - if not hasattr(self.value_modifier, '__call__'): + if not callable(self.value_modifier): raise TypeError( - _('value_modifier must be the string "auto", have a "process" attribute, ' - 'or be a callable') + _( + 'value_modifier must be the string "auto", have a "process" attribute, ' + 'or be a callable', + ), ) self.value_modifier = validators.CustomValidator(processor=self.value_modifier) @@ -486,7 +511,7 @@ def set(self, op, values, value2=None): if not op and not self.default_op: return self.op = op or self.default_op - self.using_default_op = (self.default_op is not None) + self.using_default_op = self.default_op is not None if self.using_default_op and op is None and self.default_value1 is not None: values = tolist(self._default_value(self.default_value1)) @@ -531,20 +556,23 @@ def process(self, value): def match_keys_for_value(self, value): """Used for single-search to match search value to part of an option's display string.""" return [ - key for (key, _) in filter( + key + for (key, _) in filter( lambda item: value.lower() in str(item[1]).lower(), - self.options_seq + self.options_seq, ) ] def get_search_expr(self): """Match up a search value to option display, grab the corresponding keys, and search.""" + # The important thing to remember here is that a user will be searching for the displayed # value, not the key that generated it. We need to do some prep work to search options # to get the keys needed for lookup into the data source. def search(value): matching_keys = self.match_keys_for_value(value) return self.sa_col.in_(matching_keys) + return search def apply(self, query): @@ -573,10 +601,23 @@ class OptionsIntFilterBase(OptionsFilterBase): as the `value_modifier`. """ - def __init__(self, sa_col, value_modifier=validators.IntValidator, default_op=None, - default_value1=None, default_value2=None): - OptionsFilterBase.__init__(self, sa_col, value_modifier, default_op, default_value1, - default_value2) + + def __init__( + self, + sa_col, + value_modifier=validators.IntValidator, + default_op=None, + default_value1=None, + default_value2=None, + ): + OptionsFilterBase.__init__( + self, + sa_col, + value_modifier, + default_op, + default_value1, + default_value2, + ) class OptionsEnumFilter(OptionsFilterBase): @@ -588,16 +629,17 @@ class OptionsEnumFilter(OptionsFilterBase): Notable args: enum_type (Enum): Python Enum type to use for options list. """ + enum_type = None def __init__( - self, - sa_col, - value_modifier=None, - default_op=None, - default_value1=None, - default_value2=None, - enum_type=None, + self, + sa_col, + value_modifier=None, + default_op=None, + default_value1=None, + default_value2=None, + enum_type=None, ): self.enum_type = enum_type or self.__class__.enum_type @@ -607,7 +649,7 @@ def __init__( if value_modifier is None: value_modifier = self.default_modifier - super(OptionsEnumFilter, self).__init__( + super().__init__( sa_col, value_modifier=value_modifier, default_op=default_op, @@ -622,15 +664,15 @@ def default_modifier(self, value): try: return self.enum_type[value] - except KeyError: - raise ValueError('Not a valid selection') + except KeyError as e: + raise ValueError('Not a valid selection') from e def options_from(self): """Override as an instance method here, returns the options tuples from the Enum.""" return [(x.name, x.value) for x in self.enum_type] def new_instance(self, **kwargs): - new_inst = super(OptionsEnumFilter, self).new_instance(**kwargs) + new_inst = super().new_instance(**kwargs) new_inst.enum_type = self.enum_type return new_inst @@ -651,6 +693,7 @@ class OptionsEnumArrayFilter(OptionsEnumFilter): def get_search_expr(self): """Match up a search value to option display, grab the corresponding keys, and search.""" + # The important thing to remember here is that a user will be searching for the displayed # value, not the key that generated it. We need to do some prep work to search options # to get the keys needed for lookup into the data source. @@ -663,6 +706,7 @@ def search(value): if matching_keys: return self.sa_col.contains(matching_keys) return None + return search def apply(self, query): @@ -686,6 +730,7 @@ def apply(self, query): class TextFilter(FilterBase): """Filter with single freeform text input.""" + operators = (ops.eq, ops.not_eq, ops.contains, ops.not_contains, ops.empty, ops.not_empty) primary_op = ops.contains @@ -706,14 +751,14 @@ def comparisons(self): return { ops.eq: lambda col, value: sa.func.upper(col) == sa.func.upper(value), ops.not_eq: lambda col, value: sa.func.upper(col) != sa.func.upper(value), - ops.contains: lambda col, value: col.ilike(u'%{}%'.format(value)), - ops.not_contains: lambda col, value: ~col.ilike(u'%{}%'.format(value)) + ops.contains: lambda col, value: col.ilike(f'%{value}%'), + ops.not_contains: lambda col, value: ~col.ilike(f'%{value}%'), } return { ops.eq: lambda col, value: col == value, ops.not_eq: lambda col, value: col != value, - ops.contains: lambda col, value: col.like(u'%{}%'.format(value)), - ops.not_contains: lambda col, value: ~col.like(u'%{}%'.format(value)) + ops.contains: lambda col, value: col.like(f'%{value}%'), + ops.not_contains: lambda col, value: ~col.like(f'%{value}%'), } def get_search_expr(self): @@ -723,15 +768,19 @@ def apply(self, query): if self.op == self.default_op and not self.value1: return query if self.op == ops.empty: - return query.filter(or_( - self.sa_col.is_(None), - self.sa_col == u'', - )) + return query.filter( + or_( + self.sa_col.is_(None), + self.sa_col == '', + ), + ) if self.op == ops.not_empty: - return query.filter(and_( - self.sa_col.isnot(None), - self.sa_col != u'', - )) + return query.filter( + and_( + self.sa_col.isnot(None), + self.sa_col != '', + ), + ) if self.op in self.comparisons: return query.filter(self.comparisons[self.op](self.sa_col, self.value1)) @@ -744,14 +793,25 @@ class NumberFilterBase(FilterBase): Class attributes: validator (Validator): webgrid validator to use on input values. """ - operators = (ops.eq, ops.not_eq, ops.less_than_equal, ops.greater_than_equal, ops.between, - ops.not_between, ops.empty, ops.not_empty) + + operators = ( + ops.eq, + ops.not_eq, + ops.less_than_equal, + ops.greater_than_equal, + ops.between, + ops.not_between, + ops.empty, + ops.not_empty, + ) def process(self, value, is_value2): if self.op == self.default_op and not value: return None - if self.op in (ops.eq, ops.not_eq, ops.less_than_equal, - ops.greater_than_equal) and not is_value2: + if ( + self.op in (ops.eq, ops.not_eq, ops.less_than_equal, ops.greater_than_equal) + and not is_value2 + ): v_required = validators.RequiredValidator() return self.validator().process(v_required.process(value)) if value is None or value == '': @@ -763,7 +823,7 @@ def get_search_expr(self): # uses a LIKE. We could go nuts with things like stripping thousands separators, # parenthesis, monetary symbols, etc. from the search value, but then we get to deal with # locale. - return lambda value: sa.sql.cast(self.sa_col, sa.Unicode).like('%{}%'.format(value)) + return lambda value: sa.sql.cast(self.sa_col, sa.Unicode).like(f'%{value}%') def apply(self, query): filter_method = query.filter if not self.is_aggregate else query.having @@ -776,19 +836,22 @@ def apply(self, query): class IntFilter(NumberFilterBase): """Number filter validating inputs as integers.""" + validator = validators.IntValidator class AggregateIntFilter(IntFilter): """Number filter validating inputs as integers, for use on aggregate columns.""" + is_aggregate = True class NumberFilter(NumberFilterBase): """ - Same as int filter, but will handle real numbers and type - everything as a decimal.Decimal object + Same as int filter, but will handle real numbers and type + everything as a decimal.Decimal object """ + # our process() doesn't use a validator to return, but parent class does validator = validators.FloatValidator @@ -803,73 +866,94 @@ def process(self, value, is_value2): class AggregateNumberFilter(NumberFilter): """Number filter validating inputs as Decimal, for use on aggregate columns.""" + is_aggregate = True -class _DateMixin(object): - options_from = [ - (1, _('01-Jan')), (2, _('02-Feb')), (3, _('03-Mar')), (4, _('04-Apr')), - (5, _('05-May')), (6, _('06-Jun')), (7, _('07-Jul')), (8, _('08-Aug')), - (9, _('09-Sep')), (10, _('10-Oct')), (11, _('11-Nov')), (12, _('12-Dec')), - ] - op_to_date_range = ImmutableDict({ - # these filters can be set as default ops without input values, so don't ignore them - ops.this_month: lambda self, today: ( - today + relativedelta(day=1), - today + relativedelta(day=1, months=+1, days=-1), - ), - ops.last_month: lambda self, today: ( - today + relativedelta(day=1, months=-1), - today + relativedelta(day=1, days=-1), - ), - ops.select_month: lambda self, today: self._select_month(today), - ops.this_year: lambda self, today: ( - dt.date(today.year, 1, 1), - dt.date(today.year, 12, 31), - ), - ops.this_week: lambda self, today: ( - today - relativedelta(weekday=SU(-1)), - today + relativedelta(weekday=calendar.SATURDAY), - ), - ops.last_week: lambda self, today: ( - today - relativedelta(weekday=SU(-1)) - relativedelta(days=7), - today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), - ), - ops.today: lambda self, today: (today, today), - ops.in_past: lambda self, today: (today, today), - ops.in_future: lambda self, today: (today, today), - # ops with both dates populated - ops.between: lambda self, today: self._between_range(), - ops.not_between: lambda self, today: self._between_range(), - # ops with single date populated - ops.less_than_days_ago: lambda self, today: ( - today - dt.timedelta(days=self.value1), - today, - ) if self.value1 is not None else (None, None), - ops.in_less_than_days: lambda self, today: ( - today, - today + dt.timedelta(days=self.value1), - ) if self.value1 is not None else (None, None), - ops.days_ago: lambda self, today: ( - today - dt.timedelta(days=self.value1), - today - dt.timedelta(days=self.value1), - ) if self.value1 is not None else (None, None), - ops.more_than_days_ago: lambda self, today: ( - None, today - dt.timedelta(days=self.value1) - ) if self.value1 is not None else (None, None), - ops.in_days: lambda self, today: self._in_days(today), - ops.in_more_than_days: lambda self, today: self._in_days(today), - ops.eq: lambda self, today: self._equality(), - ops.not_eq: lambda self, today: self._equality(), - ops.less_than_equal: lambda self, today: self._equality(), - ops.greater_than_equal: lambda self, today: self._equality(), - }) +class _DateMixin: + options_from = ( + (1, _('01-Jan')), + (2, _('02-Feb')), + (3, _('03-Mar')), + (4, _('04-Apr')), + (5, _('05-May')), + (6, _('06-Jun')), + (7, _('07-Jul')), + (8, _('08-Aug')), + (9, _('09-Sep')), + (10, _('10-Oct')), + (11, _('11-Nov')), + (12, _('12-Dec')), + ) + op_to_date_range = ImmutableDict( + { + # these filters can be set as default ops without input values, so don't ignore them + ops.this_month: lambda self, today: ( + today + relativedelta(day=1), + today + relativedelta(day=1, months=+1, days=-1), + ), + ops.last_month: lambda self, today: ( + today + relativedelta(day=1, months=-1), + today + relativedelta(day=1, days=-1), + ), + ops.select_month: lambda self, today: self._select_month(today), + ops.this_year: lambda self, today: ( + dt.date(today.year, 1, 1), + dt.date(today.year, 12, 31), + ), + ops.this_week: lambda self, today: ( + today - relativedelta(weekday=SU(-1)), + today + relativedelta(weekday=calendar.SATURDAY), + ), + ops.last_week: lambda self, today: ( + today - relativedelta(weekday=SU(-1)) - relativedelta(days=7), + today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), + ), + ops.today: lambda self, today: (today, today), + ops.in_past: lambda self, today: (today, today), + ops.in_future: lambda self, today: (today, today), + # ops with both dates populated + ops.between: lambda self, today: self._between_range(), + ops.not_between: lambda self, today: self._between_range(), + # ops with single date populated + ops.less_than_days_ago: lambda self, today: ( + today - dt.timedelta(days=self.value1), + today, + ) + if self.value1 is not None + else (None, None), + ops.in_less_than_days: lambda self, today: ( + today, + today + dt.timedelta(days=self.value1), + ) + if self.value1 is not None + else (None, None), + ops.days_ago: lambda self, today: ( + today - dt.timedelta(days=self.value1), + today - dt.timedelta(days=self.value1), + ) + if self.value1 is not None + else (None, None), + ops.more_than_days_ago: lambda self, today: ( + None, + today - dt.timedelta(days=self.value1), + ) + if self.value1 is not None + else (None, None), + ops.in_days: lambda self, today: self._in_days(today), + ops.in_more_than_days: lambda self, today: self._in_days(today), + ops.eq: lambda self, today: self._equality(), + ops.not_eq: lambda self, today: self._equality(), + ops.less_than_equal: lambda self, today: self._equality(), + ops.greater_than_equal: lambda self, today: self._equality(), + }, + ) @property def options_seq(self): _options_seq = self.options_from if self.default_op: - _options_seq = [(-1, _('-- All --'))] + _options_seq + _options_seq = [(-1, _('-- All --')), *_options_seq] return _options_seq def format_display_vals(self): @@ -879,14 +963,11 @@ def format_display_vals(self): ops.less_than_equal.key, ops.greater_than_equal.key, ops.between.key, - ops.not_between.key + ops.not_between.key, ): # !!!: localize self.value1_set_with = self.value1.strftime('%Y-%m-%d') - if isinstance(self.value2, dt.date) and self.op in ( - ops.between.key, - ops.not_between.key - ): + if isinstance(self.value2, dt.date) and self.op in (ops.between.key, ops.not_between.key): # !!!: localize self.value2_set_with = self.value2.strftime('%Y-%m-%d') @@ -920,9 +1001,10 @@ def _select_month(self, today): def _description_data(self): today = self._get_today() - first_day, last_day = self.op_to_date_range.get( - self.op, lambda self, today: (None, None) - )(self, today) + first_day, last_day = self.op_to_date_range.get(self.op, lambda self, today: (None, None))( + self, + today, + ) prefix = { ops.more_than_days_ago: _('before '), @@ -940,17 +1022,15 @@ def _description_data(self): @property def description(self): """ - String description of the filter operation and values - - Useful for Excel reports + String description of the filter operation and values + - Useful for Excel reports """ # simple cases if self.error: return _('invalid') elif self.op == ops.select_month: - if not ( - isinstance(self.value1, int) and isinstance(self.value2, int) - ): + if not (isinstance(self.value1, int) and isinstance(self.value2, int)): return _('All') if self.value1 < 1 or self.value1 > 12: return self.value2 @@ -964,34 +1044,35 @@ def description(self): first_day, last_day, prefix = self._description_data() if not first_day and ( - not self.op or ( - self.default_op == self.op and ( - self.value2 is None and self.value1 is None - ) - ) + not self.op + or (self.default_op == self.op and (self.value2 is None and self.value1 is None)) ): return _('all') if self.op in ( - ops.today, ops.eq, ops.not_eq, ops.less_than_equal, - ops.greater_than_equal, ops.days_ago, ops.in_past, ops.in_future, + ops.today, + ops.eq, + ops.not_eq, + ops.less_than_equal, + ops.greater_than_equal, + ops.days_ago, + ops.in_past, + ops.in_future, ): # !!!: localize - return _('{descriptor}{date}', - descriptor=prefix, - date=first_day.strftime('%m/%d/%Y')) + return _('{descriptor}{date}', descriptor=prefix, date=first_day.strftime('%m/%d/%Y')) elif last_day and first_day: # !!!: localize dates - return _('{descriptor}{first_date} - {second_date}', - descriptor=prefix, - first_date=first_day.strftime('%m/%d/%Y'), - second_date=last_day.strftime('%m/%d/%Y')) + return _( + '{descriptor}{first_date} - {second_date}', + descriptor=prefix, + first_date=first_day.strftime('%m/%d/%Y'), + second_date=last_day.strftime('%m/%d/%Y'), + ) else: # !!!: localize target_date = first_day if first_day else last_day - return _('{descriptor}{date}', - descriptor=prefix, - date=target_date.strftime('%m/%d/%Y')) + return _('{descriptor}{date}', descriptor=prefix, date=target_date.strftime('%m/%d/%Y')) def valid_date_for_backend(self, value): """ @@ -1007,12 +1088,12 @@ def valid_date_for_backend(self, value): def min_dt(*args): m = dt.datetime.min parts = [m.year, m.month, m.day, m.hour, m.minute, m.second, m.microsecond] - return dt.datetime(*(max(a, m) for a, m in zip(args, parts))) + return dt.datetime(*(max(a, m) for a, m in zip(args, parts, strict=False))) def max_dt(*args): m = dt.datetime.max parts = [m.year, m.month, m.day, m.hour, m.minute, m.second, m.microsecond] - return dt.datetime(*(min(a, m) for a, m in zip(args, parts))) + return dt.datetime(*(min(a, m) for a, m in zip(args, parts, strict=False))) if not isinstance(value, dt.datetime): value = dt.datetime.combine(value, dt.time.min) @@ -1035,18 +1116,16 @@ def get_search_expr(self, date_comparator=None): date_comparator = date_comparator or (lambda value: self.sa_col == value) def expr(value): - base_expr = sa.sql.cast(self.sa_col, sa.Unicode).like('%{}%'.format(value)) + base_expr = sa.sql.cast(self.sa_col, sa.Unicode).like(f'%{value}%') try: date_value = parse(value) if not self.valid_date_for_backend(date_value): return base_expr - return or_( - base_expr, - date_comparator(date_value) - ) + return or_(base_expr, date_comparator(date_value)) except (ValueError, OverflowError): pass return base_expr + return expr def serialize_filter_spec(self): @@ -1054,56 +1133,62 @@ def serialize_filter_spec(self): return types.OptionsFilterSpec( operators=base_spec.operators, primary_op=base_spec.primary_op, - options=[ - self.serialize_filter_option(key, value) - for key, value in self.options_seq], + options=[self.serialize_filter_option(key, value) for key, value in self.options_seq], ) class _DateOpQueryMixin: - op_to_query = ImmutableDict({ - ops.today: lambda self, query, today: query.filter( - self.sa_col == today - ), - ops.in_past: lambda self, query, today: query.filter(self.sa_col < today), - ops.in_future: lambda self, query, today: query.filter(self.sa_col > today), - ops.this_week: lambda self, query, today: query.filter(self.sa_col.between( - today - relativedelta(weekday=SU(-1)), - today + relativedelta(weekday=calendar.SATURDAY), - )), - ops.last_week: lambda self, query, today: query.filter(self.sa_col.between( - today - relativedelta(weekday=SU(-1)) - relativedelta(days=7), - today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), - )), - ops.select_month: lambda self, query, today: ( - self._month_year_filter(query) if self.value1 and self.value2 else query - ), - ops.this_month: lambda self, query, today: self._month_year_filter(query), - ops.last_month: lambda self, query, today: self._month_year_filter(query), - ops.this_year: lambda self, query, today: self._month_year_filter(query), - ops.between: lambda self, query, today: query.filter(self._between_clause()), - ops.not_between: lambda self, query, today: query.filter(~self._between_clause()), - ops.days_ago: lambda self, query, today: query.filter( - self.sa_col == today - dt.timedelta(days=self.value1) - ), - ops.less_than_days_ago: lambda self, query, today: query.filter(and_( - self.sa_col > today - dt.timedelta(days=self.value1), - self.sa_col <= today, - )), - ops.more_than_days_ago: lambda self, query, today: query.filter( - self.sa_col < today - dt.timedelta(days=self.value1) - ), - ops.in_days: lambda self, query, today: query.filter( - self.sa_col == today + dt.timedelta(days=self.value1) - ), - ops.in_less_than_days: lambda self, query, today: query.filter(and_( - self.sa_col >= today, - self.sa_col < today + dt.timedelta(days=self.value1), - )), - ops.in_more_than_days: lambda self, query, today: query.filter( - self.sa_col > today + dt.timedelta(days=self.value1) - ), - }) + op_to_query = ImmutableDict( + { + ops.today: lambda self, query, today: query.filter(self.sa_col == today), + ops.in_past: lambda self, query, today: query.filter(self.sa_col < today), + ops.in_future: lambda self, query, today: query.filter(self.sa_col > today), + ops.this_week: lambda self, query, today: query.filter( + self.sa_col.between( + today - relativedelta(weekday=SU(-1)), + today + relativedelta(weekday=calendar.SATURDAY), + ), + ), + ops.last_week: lambda self, query, today: query.filter( + self.sa_col.between( + today - relativedelta(weekday=SU(-1)) - relativedelta(days=7), + today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), + ), + ), + ops.select_month: lambda self, query, today: ( + self._month_year_filter(query) if self.value1 and self.value2 else query + ), + ops.this_month: lambda self, query, today: self._month_year_filter(query), + ops.last_month: lambda self, query, today: self._month_year_filter(query), + ops.this_year: lambda self, query, today: self._month_year_filter(query), + ops.between: lambda self, query, today: query.filter(self._between_clause()), + ops.not_between: lambda self, query, today: query.filter(~self._between_clause()), + ops.days_ago: lambda self, query, today: query.filter( + self.sa_col == today - dt.timedelta(days=self.value1), + ), + ops.less_than_days_ago: lambda self, query, today: query.filter( + and_( + self.sa_col > today - dt.timedelta(days=self.value1), + self.sa_col <= today, + ), + ), + ops.more_than_days_ago: lambda self, query, today: query.filter( + self.sa_col < today - dt.timedelta(days=self.value1), + ), + ops.in_days: lambda self, query, today: query.filter( + self.sa_col == today + dt.timedelta(days=self.value1), + ), + ops.in_less_than_days: lambda self, query, today: query.filter( + and_( + self.sa_col >= today, + self.sa_col < today + dt.timedelta(days=self.value1), + ), + ), + ops.in_more_than_days: lambda self, query, today: query.filter( + self.sa_col > today + dt.timedelta(days=self.value1), + ), + }, + ) def _get_today(self): # this filter is date-only, so our "now" is a date without time @@ -1131,24 +1216,54 @@ class DateFilter(_DateOpQueryMixin, _DateMixin, FilterBase): _now (datetime, optional): Useful for testing, supplies a date the filter will use instead of the true `datetime.now()`. Defaults to None. """ + operators = ( - ops.eq, ops.not_eq, ops.in_past, ops.in_future, ops.less_than_equal, - ops.greater_than_equal, ops.between, ops.not_between, - ops.days_ago, ops.less_than_days_ago, ops.more_than_days_ago, - ops.today, ops.this_week, ops.last_week, ops.in_days, ops.in_less_than_days, - ops.in_more_than_days, ops.empty, ops.not_empty, ops.this_month, - ops.last_month, ops.select_month, ops.this_year + ops.eq, + ops.not_eq, + ops.in_past, + ops.in_future, + ops.less_than_equal, + ops.greater_than_equal, + ops.between, + ops.not_between, + ops.days_ago, + ops.less_than_days_ago, + ops.more_than_days_ago, + ops.today, + ops.this_week, + ops.last_week, + ops.in_days, + ops.in_less_than_days, + ops.in_more_than_days, + ops.empty, + ops.not_empty, + ops.this_month, + ops.last_month, + ops.select_month, + ops.this_year, ) days_operators = ( - ops.days_ago, ops.less_than_days_ago, ops.more_than_days_ago, - ops.in_less_than_days, ops.in_more_than_days, ops.in_days + ops.days_ago, + ops.less_than_days_ago, + ops.more_than_days_ago, + ops.in_less_than_days, + ops.in_more_than_days, + ops.in_days, ) no_value_operators = ( - ops.empty, ops.not_empty, ops.today, ops.this_week, ops.last_week, ops.this_month, - ops.last_month, ops.this_year, ops.in_past, ops.in_future, + ops.empty, + ops.not_empty, + ops.today, + ops.this_week, + ops.last_week, + ops.this_month, + ops.last_month, + ops.this_year, + ops.in_past, + ops.in_future, ) input_types = 'input', 'select', 'input2' - html_input_types = { + html_input_types: ClassVar = { ops.eq: 'date', ops.not_eq: 'date', ops.less_than_equal: 'date', @@ -1157,12 +1272,23 @@ class DateFilter(_DateOpQueryMixin, _DateMixin, FilterBase): ops.not_between: 'date', } - def __init__(self, sa_col, _now=None, default_op=None, default_value1=None, - default_value2=None): + def __init__( + self, + sa_col, + _now=None, + default_op=None, + default_value1=None, + default_value2=None, + ): self.first_day = None self.last_day = None - FilterBase.__init__(self, sa_col, default_op=default_op, default_value1=default_value1, - default_value2=default_value2) + FilterBase.__init__( + self, + sa_col, + default_op=default_op, + default_value1=default_value1, + default_value2=default_value2, + ) # attributes from static instance self._now = _now @@ -1179,13 +1305,14 @@ def check_arrow_type(self): self.sa_col = sa.sql.cast(self.sa_col, sa.Date) def set(self, op, value1, value2=None): - super(DateFilter, self).set(op, value1, value2) + super().set(op, value1, value2) self.format_display_vals() # store first/last day for customized usage today = self._get_today() self.first_day, self.last_day = self.op_to_date_range.get( - self.op, lambda self, today: (None, None) + self.op, + lambda self, today: (None, None), )(self, today) def apply(self, query): @@ -1200,8 +1327,15 @@ def apply(self, query): filtered_query = super().apply(query) if self.op in ( - ops.today, ops.this_week, ops.last_week, ops.select_month, ops.this_month, - ops.last_month, ops.this_year, ops.in_past, ops.in_future, + ops.today, + ops.this_week, + ops.last_week, + ops.select_month, + ops.this_month, + ops.last_month, + ops.this_year, + ops.in_past, + ops.in_future, ): return filtered_query @@ -1224,16 +1358,22 @@ def _process_days_operator(self, value, is_value2): if self.op in (ops.days_ago, ops.less_than_days_ago, ops.more_than_days_ago): try: self._get_today() - dt.timedelta(days=filter_value) - except OverflowError: - raise validators.ValueInvalid(gettext('date filter given is out of range'), - value, self) + except OverflowError as e: + raise validators.ValueInvalid( + gettext('date filter given is out of range'), + value, + self, + ) from e if self.op in (ops.in_days, ops.in_less_than_days, ops.in_more_than_days): try: self._get_today() + dt.timedelta(days=filter_value) - except OverflowError: - raise validators.ValueInvalid(gettext('date filter given is out of range'), - value, self) + except OverflowError as e: + raise validators.ValueInvalid( + gettext('date filter given is out of range'), + value, + self, + ) from e return filter_value @@ -1246,12 +1386,12 @@ def _process_date(self, value, is_value2): return v_range.process(d.year) return d - except (ValueError, OverflowError): + except (ValueError, OverflowError) as e: # allow open ranges when blanks are submitted as a second value if is_value2 and not value: return self._get_today() - raise validators.ValueInvalid(gettext('invalid date'), value, self) + raise validators.ValueInvalid(gettext('invalid date'), value, self) from e def process(self, value, is_value2): # None is ok for default_ops @@ -1295,7 +1435,8 @@ class DateTimeFilter(DateFilter): _now (datetime, optional): Useful for testing, supplies a date the filter will use instead of the true `datetime.now()`. Defaults to None. """ - html_input_types = { + + html_input_types: ClassVar = { ops.eq: 'datetime-local', ops.not_eq: 'datetime-local', ops.less_than_equal: 'datetime-local', @@ -1303,105 +1444,145 @@ class DateTimeFilter(DateFilter): ops.between: 'datetime-local', ops.not_between: 'datetime-local', } - op_to_query = ImmutableDict({**DateFilter.op_to_query, **{ - ops.today: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today), - ensure_datetime(today, time_part=dt.time(23, 59, 59, 999999)), - )), - ops.in_past: lambda self, query, today: query.filter( - self.sa_col < ensure_datetime(today), - ), - ops.in_future: lambda self, query, today: query.filter( - self.sa_col > ensure_datetime(today, time_part=dt.time(23, 59, 59, 999999)), - ), - ops.this_week: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today - relativedelta(weekday=SU(-1))), - ensure_datetime( - today + relativedelta(weekday=calendar.SATURDAY), - time_part=dt.time(23, 59, 59, 999999) + op_to_query = ImmutableDict( + { + **DateFilter.op_to_query, + ops.today: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today), + ensure_datetime(today, time_part=dt.time(23, 59, 59, 999999)), + ), ), - )), - ops.last_week: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today - relativedelta(weekday=SU(-1)) - relativedelta(days=7)), - ensure_datetime( - today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), - time_part=dt.time(23, 59, 59, 999999) + ops.in_past: lambda self, query, today: query.filter( + self.sa_col < ensure_datetime(today), ), - )), - ops.this_month: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today + relativedelta(day=1)), - today + relativedelta(day=1, months=+1, microseconds=-1), - )), - ops.last_month: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today + relativedelta(day=1, months=-1)), - today + relativedelta(day=1, microseconds=-1), - )), - ops.this_year: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today + relativedelta(day=1, month=1)), - today + relativedelta(day=31, month=12, days=1, microseconds=-1), - )), - ops.select_month: lambda self, query, today: ( - query.filter(self.sa_col.between( - ensure_datetime(self.first_day), - self.last_day + relativedelta(days=1, microseconds=-1), - )) if self.value2 else query - ), - ops.days_ago: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today - dt.timedelta(days=self.value1)), - ensure_datetime( - today - dt.timedelta(days=self.value1), - time_part=dt.time(23, 59, 59, 999999) + ops.in_future: lambda self, query, today: query.filter( + self.sa_col > ensure_datetime(today, time_part=dt.time(23, 59, 59, 999999)), ), - )), - ops.less_than_days_ago: lambda self, query, today: query.filter(and_( - self.sa_col > ensure_datetime(today - dt.timedelta(days=self.value1), - time_part=dt.time(23, 59, 59, 999999)), - self.sa_col <= self._get_now() - )), - ops.more_than_days_ago: lambda self, query, today: query.filter( - self.sa_col < ensure_datetime(today - dt.timedelta(days=self.value1)) - ), - ops.in_days: lambda self, query, today: query.filter(self.sa_col.between( - ensure_datetime(today + dt.timedelta(days=self.value1)), - ensure_datetime( - today + dt.timedelta(days=self.value1), time_part=dt.time(23, 59, 59, 999999) + ops.this_week: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today - relativedelta(weekday=SU(-1))), + ensure_datetime( + today + relativedelta(weekday=calendar.SATURDAY), + time_part=dt.time(23, 59, 59, 999999), + ), + ), ), - )), - ops.in_less_than_days: lambda self, query, today: query.filter(and_( - self.sa_col >= self._get_now(), - self.sa_col < ensure_datetime(today + dt.timedelta(days=self.value1)), - )), - ops.in_more_than_days: lambda self, query, today: query.filter( - self.sa_col > ensure_datetime( - today + dt.timedelta(days=self.value1), - time_part=dt.time(23, 59, 59, 999999) - ) - ), - ops.eq: lambda self, query, today: ( - query.filter(self._eq_clause()) - ), - ops.not_eq: lambda self, query, today: ( - query.filter(~self._eq_clause()) - ), - ops.less_than_equal: lambda self, query, today: query.filter( - self.sa_col <= ensure_datetime( - self.value1.date(), time_part=dt.time(23, 59, 59, 999999) + ops.last_week: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime( + today - relativedelta(weekday=SU(-1)) - relativedelta(days=7), + ), + ensure_datetime( + today + relativedelta(weekday=calendar.SATURDAY) - relativedelta(days=7), + time_part=dt.time(23, 59, 59, 999999), + ), + ), + ), + ops.this_month: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today + relativedelta(day=1)), + today + relativedelta(day=1, months=+1, microseconds=-1), + ), + ), + ops.last_month: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today + relativedelta(day=1, months=-1)), + today + relativedelta(day=1, microseconds=-1), + ), + ), + ops.this_year: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today + relativedelta(day=1, month=1)), + today + relativedelta(day=31, month=12, days=1, microseconds=-1), + ), + ), + ops.select_month: lambda self, query, today: ( + query.filter( + self.sa_col.between( + ensure_datetime(self.first_day), + self.last_day + relativedelta(days=1, microseconds=-1), + ), + ) + if self.value2 + else query + ), + ops.days_ago: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today - dt.timedelta(days=self.value1)), + ensure_datetime( + today - dt.timedelta(days=self.value1), + time_part=dt.time(23, 59, 59, 999999), + ), + ), + ), + ops.less_than_days_ago: lambda self, query, today: query.filter( + and_( + self.sa_col + > ensure_datetime( + today - dt.timedelta(days=self.value1), + time_part=dt.time(23, 59, 59, 999999), + ), + self.sa_col <= self._get_now(), + ), + ), + ops.more_than_days_ago: lambda self, query, today: query.filter( + self.sa_col < ensure_datetime(today - dt.timedelta(days=self.value1)), + ), + ops.in_days: lambda self, query, today: query.filter( + self.sa_col.between( + ensure_datetime(today + dt.timedelta(days=self.value1)), + ensure_datetime( + today + dt.timedelta(days=self.value1), + time_part=dt.time(23, 59, 59, 999999), + ), + ), + ), + ops.in_less_than_days: lambda self, query, today: query.filter( + and_( + self.sa_col >= self._get_now(), + self.sa_col < ensure_datetime(today + dt.timedelta(days=self.value1)), + ), + ), + ops.in_more_than_days: lambda self, query, today: query.filter( + self.sa_col + > ensure_datetime( + today + dt.timedelta(days=self.value1), + time_part=dt.time(23, 59, 59, 999999), + ), + ), + ops.eq: lambda self, query, today: (query.filter(self._eq_clause())), + ops.not_eq: lambda self, query, today: (query.filter(~self._eq_clause())), + ops.less_than_equal: lambda self, query, today: query.filter( + self.sa_col + <= ensure_datetime(self.value1.date(), time_part=dt.time(23, 59, 59, 999999)), ) - ) if self._has_date_only1 else None, - ops.between: lambda self, query, today: query.filter(self._between_clause()), - ops.not_between: lambda self, query, today: query.filter(~self._between_clause()), - }}) + if self._has_date_only1 + else None, + ops.between: lambda self, query, today: query.filter(self._between_clause()), + ops.not_between: lambda self, query, today: query.filter(~self._between_clause()), + }, + ) - def __init__(self, sa_col, _now=None, default_op=None, default_value1=None, - default_value2=None): + def __init__( + self, + sa_col, + _now=None, + default_op=None, + default_value1=None, + default_value2=None, + ): self._has_date_only1 = self._has_date_only2 = False - super(DateTimeFilter, self).__init__(sa_col, _now=_now, default_op=default_op, - default_value1=default_value1, - default_value2=default_value2) + super().__init__( + sa_col, + _now=_now, + default_op=default_op, + default_value1=default_value1, + default_value2=default_value2, + ) def check_arrow_type(self): """DateTimeFilter has no problems with ArrowType. Pass this case through.""" - pass def format_display_vals(self): ops_single_val = ( @@ -1410,10 +1591,7 @@ def format_display_vals(self): ops.less_than_equal.key, ops.greater_than_equal.key, ) - ops_double_val = ( - ops.between.key, - ops.not_between.key - ) + ops_double_val = (ops.between.key, ops.not_between.key) if isinstance(self.value1, dt.datetime) and self.op in ops_single_val + ops_double_val: # !!!: localize self.value1_set_with = self.value1.strftime('%Y-%m-%dT%H:%M') @@ -1429,8 +1607,7 @@ def format_display_vals(self): def _between_clause(self): if self._has_date_only2: - right_side = ensure_datetime(self.value2.date(), - time_part=dt.time(23, 59, 59, 999999)) + right_side = ensure_datetime(self.value2.date(), time_part=dt.time(23, 59, 59, 999999)) elif self.value2.second == 0 and self.value2.microsecond == 0: right_side = self.value2 + dt.timedelta(seconds=59, microseconds=999999) else: @@ -1454,11 +1631,11 @@ def _eq_clause(self): def _process_datetime(self, value, is_value2): try: dt_value = parse(value) - except (ValueError, OverflowError): + except (ValueError, OverflowError) as e: # allow open ranges when blanks are submitted as a second value if is_value2 and not value: return self._get_now() - raise validators.ValueInvalid(gettext('invalid date'), value, self) + raise validators.ValueInvalid(gettext('invalid date'), value, self) from e if is_value2: self._has_date_only2 = self._has_date_only(dt_value, value) @@ -1505,7 +1682,7 @@ def _has_date_only(self, dt_value, value): dt_value.hour == 0 and dt_value.minute == 0 and dt_value.second == 0 - and '00:00' not in value + and '00:00' not in value, ) def get_search_expr(self): @@ -1524,10 +1701,19 @@ def date_comparator(value): class TimeFilter(FilterBase): """Time filter with one or two freeform inputs.""" - operators = (ops.eq, ops.not_eq, ops.less_than_equal, ops.greater_than_equal, ops.between, - ops.not_between, ops.empty, ops.not_empty) + + operators = ( + ops.eq, + ops.not_eq, + ops.less_than_equal, + ops.greater_than_equal, + ops.between, + ops.not_between, + ops.empty, + ops.not_empty, + ) input_types = 'input', 'input2' - html_input_types = { + html_input_types: ClassVar = { ops.eq: 'time', ops.not_eq: 'time', ops.less_than_equal: 'time', @@ -1560,7 +1746,7 @@ def apply(self, query): dt.datetime.combine(dt.date.today(), self.value1) + dt.timedelta(seconds=59, microseconds=999999) ).time(), - sa.Time + sa.Time, ) if self.op == ops.eq: @@ -1572,7 +1758,7 @@ def apply(self, query): elif self.op == ops.greater_than_equal: query = query.filter(self.sa_col >= val) else: - query = super(TimeFilter, self).apply(query) + query = super().apply(query) return query def process(self, value, is_value2): @@ -1584,13 +1770,13 @@ def process(self, value, is_value2): try: return dt.datetime.strptime(value, self.time_format).time() - except ValueError: - raise validators.ValueInvalid(_('invalid time'), value, self) + except ValueError as e: + raise validators.ValueInvalid(_('invalid time'), value, self) from e def get_search_expr(self, date_comparator=None): # This is a naive implementation that simply converts the time column to string and # uses a LIKE. - return lambda value: sa.sql.cast(self.sa_col, sa.Unicode).like('%{}%'.format(value)) + return lambda value: sa.sql.cast(self.sa_col, sa.Unicode).like(f'%{value}%') class YesNoFilter(FilterBase): @@ -1598,16 +1784,13 @@ class YesNoFilter(FilterBase): No inputs, just the all/yes/no operators. """ - class ops(object): + + class ops: all = Operator('a', _('all'), None) yes = Operator('y', _('yes'), None) no = Operator('n', _('no'), None) - operators = ( - ops.all, - ops.yes, - ops.no - ) + operators = (ops.all, ops.yes, ops.no) primary_op = ops.yes def get_search_expr(self): @@ -1617,6 +1800,7 @@ def expr(value): elif value.lower() in self.ops.no.display: return self.sa_col == sa.false() return None + return expr def apply(self, query): diff --git a/webgrid/flask.py b/src/webgrid/flask.py similarity index 86% rename from webgrid/flask.py rename to src/webgrid/flask.py index d039a09..5c582e2 100644 --- a/webgrid/flask.py +++ b/src/webgrid/flask.py @@ -1,14 +1,14 @@ -from __future__ import absolute_import import json import flask from webgrid import extensions, renderers + try: from morphi.helpers.jinja import configure_jinja_environment except ImportError: - configure_jinja_environment = lambda *args, **kwargs: None # noqa: E731 + configure_jinja_environment = lambda *args, **kwargs: None class WebGrid(extensions.FrameworkManager): @@ -38,16 +38,28 @@ class MyGrid(BaseGrid): Default "webgrid". Needs to be unique if multiple managers are initialized as flask extensions. """ + blueprint_name = 'webgrid' blueprint_class = flask.Blueprint - def __init__(self, db=None, jinja_loader=None, args_loaders=None, session_max_hours=None, - blueprint_name=None, blueprint_class=None): + def __init__( + self, + db=None, + jinja_loader=None, + args_loaders=None, + session_max_hours=None, + blueprint_name=None, + blueprint_class=None, + ): self.blueprint_name = blueprint_name or self.blueprint_name self.blueprint_class = blueprint_class or self.blueprint_class self._registered_grids = {} - super().__init__(db=db, jinja_loader=jinja_loader, args_loaders=args_loaders, - session_max_hours=session_max_hours) + super().__init__( + db=db, + jinja_loader=jinja_loader, + args_loaders=args_loaders, + session_max_hours=session_max_hours, + ) def init_db(self, db): """Set the db connector.""" @@ -72,6 +84,7 @@ def request_url_args(self): def csrf_token(self): """Return a CSRF token for POST.""" from flask_wtf.csrf import generate_csrf + return generate_csrf() def web_session(self): @@ -96,7 +109,7 @@ def test_request_context(self, url='/'): def static_url(self, url_tail): """Construct static URL from webgrid blueprint.""" - return flask.url_for('{}.static'.format(self.blueprint_name), filename=url_tail) + return flask.url_for(f'{self.blueprint_name}.static', filename=url_tail) def init_blueprint(self, app): """Create a blueprint for webgrid assets.""" @@ -104,7 +117,7 @@ def init_blueprint(self, app): self.blueprint_name, __name__, static_folder='static', - static_url_path=app.static_url_path + '/webgrid' + static_url_path=app.static_url_path + '/webgrid', ) def init_app(self, app): @@ -115,9 +128,13 @@ def init_app(self, app): def file_as_response(self, data_stream, file_name, mime_type): """Return response from framework for sending a file.""" - as_attachment = (file_name is not None) - return flask.send_file(data_stream, mimetype=mime_type, as_attachment=as_attachment, - download_name=file_name) + as_attachment = file_name is not None + return flask.send_file( + data_stream, + mimetype=mime_type, + as_attachment=as_attachment, + download_name=file_name, + ) class WebGridAPI(WebGrid): @@ -147,18 +164,32 @@ class attribute and apply ``CSRFProtect`` to your application. Note that in that Default "/webgrid-api". By default, ``api_route`` uses this to construct "/webgrid-api/". """ + blueprint_name = 'webgrid-api' api_route_prefix = '/webgrid-api' - args_loaders = (extensions.RequestJsonLoader, ) + args_loaders = (extensions.RequestJsonLoader,) csrf_protection = False - def __init__(self, db=None, jinja_loader=None, args_loaders=None, session_max_hours=None, - blueprint_name=None, blueprint_class=None, api_route_prefix=None): + def __init__( + self, + db=None, + jinja_loader=None, + args_loaders=None, + session_max_hours=None, + blueprint_name=None, + blueprint_class=None, + api_route_prefix=None, + ): self.api_route_prefix = api_route_prefix or self.api_route_prefix self._registered_grids = {} - super().__init__(db=db, jinja_loader=jinja_loader, args_loaders=args_loaders, - session_max_hours=session_max_hours, blueprint_name=blueprint_name, - blueprint_class=blueprint_class) + super().__init__( + db=db, + jinja_loader=jinja_loader, + args_loaders=args_loaders, + session_max_hours=session_max_hours, + blueprint_name=blueprint_name, + blueprint_class=blueprint_class, + ) def init_blueprint(self, app): """Create a blueprint for webgrid assets and set up a generic API endpoint.""" @@ -168,17 +199,15 @@ def init_blueprint(self, app): if not self.csrf_protection and app.extensions.get('csrf'): app.extensions['csrf'].exempt(blueprint) - blueprint.route(self.api_route, methods=('POST', ))( - self.api_view_method - ) - blueprint.route(self.api_route + '/count', methods=('POST', ))( - self.api_count_view_method - ) + blueprint.route(self.api_route, methods=('POST',))(self.api_view_method) + blueprint.route(self.api_route + '/count', methods=('POST',))(self.api_count_view_method) if app.config.get('TESTING'): - @blueprint.route(self.api_route_prefix + '/testing/__csrf__', methods=('GET', )) + + @blueprint.route(self.api_route_prefix + '/testing/__csrf__', methods=('GET',)) def csrf_get(): from flask_wtf.csrf import generate_csrf + return generate_csrf() return blueprint @@ -208,7 +237,6 @@ def api_init_grid(self, grid_cls_or_creator): def api_init_grid_post(self, grid): """Hook to run API-level init on every grid after instantiation""" - pass def api_on_render_limit_exceeded(self, grid): """Export failed due to number of records. Returns a JSON response.""" diff --git a/webgrid_ta/i18n/babel.cfg b/src/webgrid/i18n/babel.cfg similarity index 56% rename from webgrid_ta/i18n/babel.cfg rename to src/webgrid/i18n/babel.cfg index 8c8d137..6b9d3be 100644 --- a/webgrid_ta/i18n/babel.cfg +++ b/src/webgrid/i18n/babel.cfg @@ -1,4 +1,3 @@ [python: **.py] [jinja2: templates/**.html] -extensions=jinja2.ext.autoescape,jinja2.ext.with_ [javascript: **.js] diff --git a/src/webgrid/i18n/es/LC_MESSAGES/webgrid.mo b/src/webgrid/i18n/es/LC_MESSAGES/webgrid.mo new file mode 100644 index 0000000..f5102d4 Binary files /dev/null and b/src/webgrid/i18n/es/LC_MESSAGES/webgrid.mo differ diff --git a/webgrid/i18n/es/LC_MESSAGES/webgrid.po b/src/webgrid/i18n/es/LC_MESSAGES/webgrid.po similarity index 51% rename from webgrid/i18n/es/LC_MESSAGES/webgrid.po rename to src/webgrid/i18n/es/LC_MESSAGES/webgrid.po index 0e48a85..99f370b 100644 --- a/webgrid/i18n/es/LC_MESSAGES/webgrid.po +++ b/src/webgrid/i18n/es/LC_MESSAGES/webgrid.po @@ -7,22 +7,22 @@ msgid "" msgstr "" "Project-Id-Version: WebGrid 0.1.36\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-01-20 16:32-0500\n" +"POT-Creation-Date: 2025-08-04 14:38-0400\n" "PO-Revision-Date: 2018-08-03 20:49-0400\n" "Last-Translator: FULL NAME \n" "Language: es\n" "Language-Team: es \n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.1\n" +"Generated-By: Babel 2.17.0\n" -#: webgrid/__init__.py:227 +#: src/webgrid/__init__.py:235 msgid "expected group to be a subclass of ColumnGroup" msgstr "grupo esperado para ser una subclase de ColumnGroup" -#: webgrid/__init__.py:241 +#: src/webgrid/__init__.py:251 msgid "" "expected filter to be a SQLAlchemy column-like object, but it did not " "have a \"key\" or \"name\" attribute" @@ -30,7 +30,7 @@ msgstr "" "se esperaba que el filtro fuera un objeto tipo columna SQLAlchemy, pero " "no tenía un atributo \"key\" o \"name\"" -#: webgrid/__init__.py:249 +#: src/webgrid/__init__.py:263 msgid "" "the filter was a class type, but no column-like object is available from " "\"key\" to pass in as as the first argument" @@ -38,139 +38,144 @@ msgstr "" "el filtro era un tipo de clase, pero no hay ningún objeto similar a una " "columna en \"key\" para pasar como primer argumento" -#: webgrid/__init__.py:354 +#: src/webgrid/__init__.py:373 +#, python-brace-format msgid "key \"{key}\" not found in record" msgstr "clave \"{key}\" no encontrada en el registro" -#: webgrid/__init__.py:463 +#: src/webgrid/__init__.py:536 msgid "True" msgstr "Cierto" -#: webgrid/__init__.py:463 +#: src/webgrid/__init__.py:537 msgid "False" msgstr "Falso" -#: webgrid/__init__.py:493 +#: src/webgrid/__init__.py:576 msgid "Yes" msgstr "Sí" -#: webgrid/__init__.py:493 +#: src/webgrid/__init__.py:577 msgid "No" msgstr "No" -#: webgrid/__init__.py:1246 +#: src/webgrid/__init__.py:1390 +#, python-brace-format msgid "can't sort on invalid key \"{key}\"" msgstr "no se puede ordenar en clave no válida \"{key}\"" -#: webgrid/__init__.py:1843 +#: src/webgrid/__init__.py:1974 +#, python-brace-format msgid "\"{arg}\" grid argument invalid, ignoring" msgstr "\"{arg}\" argumento de la grilla no válido, ignorando" -#: webgrid/filters.py:71 webgrid/filters.py:73 +#: src/webgrid/filters.py:69 src/webgrid/filters.py:71 msgid "is" msgstr "es" -#: webgrid/filters.py:72 webgrid/filters.py:74 +#: src/webgrid/filters.py:70 src/webgrid/filters.py:72 msgid "is not" msgstr "no es" -#: webgrid/filters.py:75 +#: src/webgrid/filters.py:73 msgid "empty" msgstr "vacío" -#: webgrid/filters.py:76 +#: src/webgrid/filters.py:74 msgid "not empty" msgstr "no vacío" -#: webgrid/filters.py:77 +#: src/webgrid/filters.py:75 msgid "contains" msgstr "contiene" -#: webgrid/filters.py:78 +#: src/webgrid/filters.py:76 msgid "doesn't contain" msgstr "no contiene" -#: webgrid/filters.py:79 +#: src/webgrid/filters.py:77 msgid "less than or equal" msgstr "menor o igual" -#: webgrid/filters.py:80 +#: src/webgrid/filters.py:78 msgid "greater than or equal" msgstr "mayor que o igual" -#: webgrid/filters.py:81 +#: src/webgrid/filters.py:79 msgid "between" msgstr "entre" -#: webgrid/filters.py:82 +#: src/webgrid/filters.py:80 msgid "not between" msgstr "no entre" -#: webgrid/filters.py:83 +#: src/webgrid/filters.py:81 msgid "in the past" msgstr "en el pasado" -#: webgrid/filters.py:84 +#: src/webgrid/filters.py:82 msgid "in the future" msgstr "en el futuro" -#: webgrid/filters.py:85 +#: src/webgrid/filters.py:83 msgid "days ago" msgstr "hace días" -#: webgrid/filters.py:86 +#: src/webgrid/filters.py:84 msgid "less than days ago" msgstr "hace menos de un día" -#: webgrid/filters.py:87 +#: src/webgrid/filters.py:85 msgid "more than days ago" msgstr "hace más de un día" -#: webgrid/filters.py:88 +#: src/webgrid/filters.py:86 msgid "today" msgstr "hoy" -#: webgrid/filters.py:89 +#: src/webgrid/filters.py:87 msgid "this week" msgstr "esta semana" -#: webgrid/filters.py:90 +#: src/webgrid/filters.py:88 msgid "last week" msgstr "la semana pasada" -#: webgrid/filters.py:91 +#: src/webgrid/filters.py:89 msgid "in less than days" msgstr "en menos de días" -#: webgrid/filters.py:92 +#: src/webgrid/filters.py:90 msgid "in more than days" msgstr "en más de días" -#: webgrid/filters.py:93 +#: src/webgrid/filters.py:91 msgid "in days" msgstr "en días" -#: webgrid/filters.py:94 +#: src/webgrid/filters.py:92 msgid "this month" msgstr "este mes" -#: webgrid/filters.py:95 +#: src/webgrid/filters.py:93 msgid "last month" msgstr "el mes pasado" -#: webgrid/filters.py:96 +#: src/webgrid/filters.py:94 msgid "select month" msgstr "seleccione mes" -#: webgrid/filters.py:97 +#: src/webgrid/filters.py:95 msgid "this year" msgstr "este año" -#: webgrid/filters.py:268 +#: src/webgrid/filters.py:278 +#, python-brace-format msgid "unrecognized operator: {op}" msgstr "operador no reconocido: {op}" -#: webgrid/filters.py:446 +#: src/webgrid/filters.py:462 +#, python-brace-format msgid "" "value_modifier argument set to \"auto\", but the options set is empty and" " the type can therefore not be determined for {name}" @@ -179,235 +184,277 @@ msgstr "" "conjunto de opciones está vacío y, por lo tanto, el tipo no se puede " "determinar para {name}" -#: webgrid/filters.py:461 +#: src/webgrid/filters.py:480 +#, python-brace-format msgid "can't use value_modifier='auto' when option keys are {key_type}" msgstr "" "no se puede usar value_modifier='auto' cuando las teclas de opción son " "{key_type}" -#: webgrid/filters.py:469 +#: src/webgrid/filters.py:491 msgid "" -"value_modifier must be the string \"auto\", have a \"to_python\" " -"attribute, or be a callable" +"value_modifier must be the string \"auto\", have a \"process\" attribute," +" or be a callable" msgstr "" -"value_modifier debe ser la cadena \"auto\", tener un atributo " -"\"to_python\" o ser invocable" +"value_modifier debe ser la cadena \"auto\", tener un atributo \"process\"" +" o ser invocable" -#: webgrid/filters.py:807 +#: src/webgrid/filters.py:875 msgid "01-Jan" msgstr "01-Enero" -#: webgrid/filters.py:807 +#: src/webgrid/filters.py:876 msgid "02-Feb" msgstr "02-Feb" -#: webgrid/filters.py:807 +#: src/webgrid/filters.py:877 msgid "03-Mar" msgstr "03-Marzo" -#: webgrid/filters.py:807 +#: src/webgrid/filters.py:878 msgid "04-Apr" msgstr "04-Abr" -#: webgrid/filters.py:808 +#: src/webgrid/filters.py:879 msgid "05-May" msgstr "05-Mayo" -#: webgrid/filters.py:808 +#: src/webgrid/filters.py:880 msgid "06-Jun" msgstr "06-Jun" -#: webgrid/filters.py:808 +#: src/webgrid/filters.py:881 msgid "07-Jul" msgstr "07-Jul" -#: webgrid/filters.py:808 +#: src/webgrid/filters.py:882 msgid "08-Aug" msgstr "08-Agosto" -#: webgrid/filters.py:809 +#: src/webgrid/filters.py:883 msgid "09-Sep" msgstr "09-Set" -#: webgrid/filters.py:809 +#: src/webgrid/filters.py:884 msgid "10-Oct" msgstr "10-Oct" -#: webgrid/filters.py:809 +#: src/webgrid/filters.py:885 msgid "11-Nov" msgstr "11-Nov" -#: webgrid/filters.py:809 +#: src/webgrid/filters.py:886 msgid "12-Dec" msgstr "12-Dic" -#: webgrid/filters.py:868 webgrid/static/webgrid.js:41 +#: src/webgrid/filters.py:956 src/webgrid/static/webgrid.js:39 msgid "-- All --" msgstr "-- Todas --" -#: webgrid/filters.py:924 webgrid/filters.py:925 +#: src/webgrid/filters.py:1010 src/webgrid/filters.py:1011 msgid "before " msgstr "antes de " -#: webgrid/filters.py:926 webgrid/filters.py:929 +#: src/webgrid/filters.py:1012 src/webgrid/filters.py:1015 msgid "excluding " msgstr "excluyendo " -#: webgrid/filters.py:927 webgrid/filters.py:928 +#: src/webgrid/filters.py:1013 src/webgrid/filters.py:1014 msgid "after " msgstr "después " -#: webgrid/filters.py:930 +#: src/webgrid/filters.py:1016 msgid "up to " msgstr "arriba a " -#: webgrid/filters.py:931 +#: src/webgrid/filters.py:1017 msgid "beginning " msgstr "comenzando" -#: webgrid/filters.py:945 +#: src/webgrid/filters.py:1031 msgid "invalid" msgstr "inválido" -#: webgrid/filters.py:950 +#: src/webgrid/filters.py:1034 msgid "All" msgstr "Todas" -#: webgrid/filters.py:956 +#: src/webgrid/filters.py:1040 msgid "date not specified" msgstr "fecha no especificada" -#: webgrid/filters.py:958 +#: src/webgrid/filters.py:1042 msgid "any date" msgstr "cualquier fecha" -#: webgrid/filters.py:969 webgrid/filters.py:1581 +#: src/webgrid/filters.py:1050 src/webgrid/filters.py:1789 msgid "all" msgstr "todas" -#: webgrid/filters.py:978 webgrid/filters.py:990 +#: src/webgrid/filters.py:1063 src/webgrid/filters.py:1075 +#, python-brace-format msgid "{descriptor}{date}" msgstr "{descriptor}{date}" -#: webgrid/filters.py:984 +#: src/webgrid/filters.py:1067 +#, python-brace-format msgid "{descriptor}{first_date} - {second_date}" msgstr "{descriptor}{first_date} - {second_date}" -#: webgrid/filters.py:1222 webgrid/filters.py:1229 +#: src/webgrid/filters.py:1363 src/webgrid/filters.py:1373 msgid "date filter given is out of range" msgstr "filtro de fecha dado está fuera de rango" -#: webgrid/filters.py:1247 webgrid/filters.py:1266 webgrid/filters.py:1444 -#: webgrid/filters.py:1470 +#: src/webgrid/filters.py:1394 src/webgrid/filters.py:1413 +#: src/webgrid/filters.py:1638 src/webgrid/filters.py:1664 msgid "invalid date" msgstr "inválido" -#: webgrid/filters.py:1567 +#: src/webgrid/filters.py:1774 msgid "invalid time" msgstr "inválido" -#: webgrid/filters.py:1582 +#: src/webgrid/filters.py:1790 msgid "yes" msgstr "sí" -#: webgrid/filters.py:1583 +#: src/webgrid/filters.py:1791 msgid "no" msgstr "no" -#: webgrid/renderers.py:698 +#: src/webgrid/renderers.py:686 +#, python-brace-format msgid "{label} DESC" msgstr "{label} DESC" -#: webgrid/renderers.py:755 +#: src/webgrid/renderers.py:746 +#, python-brace-format msgid "of {page_count}" msgstr "de {page_count}" -#: webgrid/renderers.py:843 +#: src/webgrid/renderers.py:834 msgid "No records to display" msgstr "No hay registros que mostrar" -#: webgrid/renderers.py:1019 +#: src/webgrid/renderers.py:1003 +#, python-brace-format msgid "{label} ({num} record):" msgid_plural "{label} ({num} records):" msgstr[0] "{label} ({num} record):" msgstr[1] "{label} ({num} records):" -#: webgrid/renderers.py:1041 +#: src/webgrid/renderers.py:1025 msgid "Page Totals" msgstr "Total de Páginas" -#: webgrid/renderers.py:1046 +#: src/webgrid/renderers.py:1030 msgid "Grand Totals" msgstr "Totales Generales" -#: webgrid/renderers.py:1174 +#: src/webgrid/renderers.py:1160 msgid "Add Filter:" msgstr "Agregar Filtro:" -#: webgrid/renderers.py:1192 +#: src/webgrid/renderers.py:1178 msgid "Search" msgstr "Buscar" -#: webgrid/static/jquery.multiple.select.js:374 +#: src/webgrid/validators.py:37 +msgid "Please enter a value." +msgstr "Por favor, introduzca un valor." + +#: src/webgrid/validators.py:48 +msgid "Please enter an integer value." +msgstr "Introduzca un valor entero." + +#: src/webgrid/validators.py:58 src/webgrid/validators.py:68 +msgid "Please enter a number." +msgstr "Por favor, introduzca un número." + +#: src/webgrid/validators.py:74 +msgid "must specify either min or max for range validation" +msgstr "debe especificar mínimo o máximo para la validación del rango" + +#: src/webgrid/validators.py:81 +#, python-brace-format +msgid "Value must be greater than or equal to {}." +msgstr "El valor debe ser mayor o igual a {}." + +#: src/webgrid/validators.py:87 +#, python-brace-format +msgid "Value must be less than or equal to {}." +msgstr "El valor debe ser menor o igual a {}." + +#: src/webgrid/validators.py:103 +#, python-brace-format +msgid "Value must be one of {}." +msgstr "El valor debe ser uno de {}." + +#: src/webgrid/validators.py:113 +msgid "Processor should be callable and take a value argument" +msgstr "El procesador debe ser invocable y tomar un argumento de valor." + +#: src/webgrid/static/jquery.multiple.select.js:369 msgid "Select all" msgstr "Seleccionar todo" -#: webgrid/static/jquery.multiple.select.js:375 +#: src/webgrid/static/jquery.multiple.select.js:370 msgid "All selected" msgstr "Todos seleccionados" -#: webgrid/static/jquery.multiple.select.js:377 +#: src/webgrid/static/jquery.multiple.select.js:372 +#, python-brace-format msgid "{count} of {total} selected" msgstr "{count} de {total} seleccionados" -#: webgrid/templates/grid_footer.html:11 +#: src/webgrid/templates/grid_footer.html:11 msgid " Export to " msgstr " Exportar a " -#: webgrid/templates/grid_footer.html:24 webgrid/templates/grid_footer.html:31 +#: src/webgrid/templates/grid_footer.html:24 +#: src/webgrid/templates/grid_footer.html:31 msgid "first" msgstr "primero" -#: webgrid/templates/grid_footer.html:28 webgrid/templates/grid_footer.html:32 +#: src/webgrid/templates/grid_footer.html:28 +#: src/webgrid/templates/grid_footer.html:32 msgid "previous" msgstr "anterior" -#: webgrid/templates/grid_footer.html:37 webgrid/templates/grid_footer.html:44 +#: src/webgrid/templates/grid_footer.html:37 +#: src/webgrid/templates/grid_footer.html:44 msgid "next" msgstr "siguiente" -#: webgrid/templates/grid_footer.html:41 webgrid/templates/grid_footer.html:45 +#: src/webgrid/templates/grid_footer.html:41 +#: src/webgrid/templates/grid_footer.html:45 msgid "last" msgstr "último" -#: webgrid/templates/grid_header.html:23 +#: src/webgrid/templates/grid_header.html:23 msgid "Apply" msgstr "Aplicar" -#: webgrid/templates/grid_header.html:29 +#: src/webgrid/templates/grid_header.html:29 msgid "reset" msgstr "reiniciar" -#: webgrid/templates/header_paging.html:8 +#: src/webgrid/templates/header_paging.html:8 msgid "Records" msgstr "Archivos" -#: webgrid/templates/header_paging.html:14 +#: src/webgrid/templates/header_paging.html:14 msgid "Page" msgstr "Página" -#: webgrid/templates/header_paging.html:19 +#: src/webgrid/templates/header_paging.html:19 msgid "Per Page" msgstr "Por Página" -#: webgrid/templates/header_sorting.html:9 +#: src/webgrid/templates/header_sorting.html:9 msgid "Sort By" msgstr "Ordenar Por" -#: webgrid/tests/test_unit.py:42 -msgid "No Expression" -msgstr "Ninguna expresión" - #~ msgid "No matches found" #~ msgstr "No se encontraron coincidencias" @@ -419,3 +466,14 @@ msgstr "Ninguna expresión" #~ msgstr[0] "Totals ({num} record):" #~ msgstr[1] "Totals ({num} records):" +#~ msgid "" +#~ "value_modifier must be the string " +#~ "\"auto\", have a \"to_python\" attribute, " +#~ "or be a callable" +#~ msgstr "" +#~ "value_modifier debe ser la cadena " +#~ "\"auto\", tener un atributo \"to_python\" " +#~ "o ser invocable" + +#~ msgid "No Expression" +#~ msgstr "Ninguna expresión" diff --git a/src/webgrid/i18n/webgrid.pot b/src/webgrid/i18n/webgrid.pot new file mode 100644 index 0000000..b901922 --- /dev/null +++ b/src/webgrid/i18n/webgrid.pot @@ -0,0 +1,444 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-08-04 14:38-0400\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: src/webgrid/__init__.py:235 +msgid "expected group to be a subclass of ColumnGroup" +msgstr "" + +#: src/webgrid/__init__.py:251 +msgid "" +"expected filter to be a SQLAlchemy column-like object, but it did not " +"have a \"key\" or \"name\" attribute" +msgstr "" + +#: src/webgrid/__init__.py:263 +msgid "" +"the filter was a class type, but no column-like object is available from " +"\"key\" to pass in as as the first argument" +msgstr "" + +#: src/webgrid/__init__.py:373 +#, python-brace-format +msgid "key \"{key}\" not found in record" +msgstr "" + +#: src/webgrid/__init__.py:536 +msgid "True" +msgstr "" + +#: src/webgrid/__init__.py:537 +msgid "False" +msgstr "" + +#: src/webgrid/__init__.py:576 +msgid "Yes" +msgstr "" + +#: src/webgrid/__init__.py:577 +msgid "No" +msgstr "" + +#: src/webgrid/__init__.py:1390 +#, python-brace-format +msgid "can't sort on invalid key \"{key}\"" +msgstr "" + +#: src/webgrid/__init__.py:1974 +#, python-brace-format +msgid "\"{arg}\" grid argument invalid, ignoring" +msgstr "" + +#: src/webgrid/filters.py:69 src/webgrid/filters.py:71 +msgid "is" +msgstr "" + +#: src/webgrid/filters.py:70 src/webgrid/filters.py:72 +msgid "is not" +msgstr "" + +#: src/webgrid/filters.py:73 +msgid "empty" +msgstr "" + +#: src/webgrid/filters.py:74 +msgid "not empty" +msgstr "" + +#: src/webgrid/filters.py:75 +msgid "contains" +msgstr "" + +#: src/webgrid/filters.py:76 +msgid "doesn't contain" +msgstr "" + +#: src/webgrid/filters.py:77 +msgid "less than or equal" +msgstr "" + +#: src/webgrid/filters.py:78 +msgid "greater than or equal" +msgstr "" + +#: src/webgrid/filters.py:79 +msgid "between" +msgstr "" + +#: src/webgrid/filters.py:80 +msgid "not between" +msgstr "" + +#: src/webgrid/filters.py:81 +msgid "in the past" +msgstr "" + +#: src/webgrid/filters.py:82 +msgid "in the future" +msgstr "" + +#: src/webgrid/filters.py:83 +msgid "days ago" +msgstr "" + +#: src/webgrid/filters.py:84 +msgid "less than days ago" +msgstr "" + +#: src/webgrid/filters.py:85 +msgid "more than days ago" +msgstr "" + +#: src/webgrid/filters.py:86 +msgid "today" +msgstr "" + +#: src/webgrid/filters.py:87 +msgid "this week" +msgstr "" + +#: src/webgrid/filters.py:88 +msgid "last week" +msgstr "" + +#: src/webgrid/filters.py:89 +msgid "in less than days" +msgstr "" + +#: src/webgrid/filters.py:90 +msgid "in more than days" +msgstr "" + +#: src/webgrid/filters.py:91 +msgid "in days" +msgstr "" + +#: src/webgrid/filters.py:92 +msgid "this month" +msgstr "" + +#: src/webgrid/filters.py:93 +msgid "last month" +msgstr "" + +#: src/webgrid/filters.py:94 +msgid "select month" +msgstr "" + +#: src/webgrid/filters.py:95 +msgid "this year" +msgstr "" + +#: src/webgrid/filters.py:278 +#, python-brace-format +msgid "unrecognized operator: {op}" +msgstr "" + +#: src/webgrid/filters.py:462 +#, python-brace-format +msgid "" +"value_modifier argument set to \"auto\", but the options set is empty and" +" the type can therefore not be determined for {name}" +msgstr "" + +#: src/webgrid/filters.py:480 +#, python-brace-format +msgid "can't use value_modifier='auto' when option keys are {key_type}" +msgstr "" + +#: src/webgrid/filters.py:491 +msgid "" +"value_modifier must be the string \"auto\", have a \"process\" attribute," +" or be a callable" +msgstr "" + +#: src/webgrid/filters.py:875 +msgid "01-Jan" +msgstr "" + +#: src/webgrid/filters.py:876 +msgid "02-Feb" +msgstr "" + +#: src/webgrid/filters.py:877 +msgid "03-Mar" +msgstr "" + +#: src/webgrid/filters.py:878 +msgid "04-Apr" +msgstr "" + +#: src/webgrid/filters.py:879 +msgid "05-May" +msgstr "" + +#: src/webgrid/filters.py:880 +msgid "06-Jun" +msgstr "" + +#: src/webgrid/filters.py:881 +msgid "07-Jul" +msgstr "" + +#: src/webgrid/filters.py:882 +msgid "08-Aug" +msgstr "" + +#: src/webgrid/filters.py:883 +msgid "09-Sep" +msgstr "" + +#: src/webgrid/filters.py:884 +msgid "10-Oct" +msgstr "" + +#: src/webgrid/filters.py:885 +msgid "11-Nov" +msgstr "" + +#: src/webgrid/filters.py:886 +msgid "12-Dec" +msgstr "" + +#: src/webgrid/filters.py:956 src/webgrid/static/webgrid.js:39 +msgid "-- All --" +msgstr "" + +#: src/webgrid/filters.py:1010 src/webgrid/filters.py:1011 +msgid "before " +msgstr "" + +#: src/webgrid/filters.py:1012 src/webgrid/filters.py:1015 +msgid "excluding " +msgstr "" + +#: src/webgrid/filters.py:1013 src/webgrid/filters.py:1014 +msgid "after " +msgstr "" + +#: src/webgrid/filters.py:1016 +msgid "up to " +msgstr "" + +#: src/webgrid/filters.py:1017 +msgid "beginning " +msgstr "" + +#: src/webgrid/filters.py:1031 +msgid "invalid" +msgstr "" + +#: src/webgrid/filters.py:1034 +msgid "All" +msgstr "" + +#: src/webgrid/filters.py:1040 +msgid "date not specified" +msgstr "" + +#: src/webgrid/filters.py:1042 +msgid "any date" +msgstr "" + +#: src/webgrid/filters.py:1050 src/webgrid/filters.py:1789 +msgid "all" +msgstr "" + +#: src/webgrid/filters.py:1063 src/webgrid/filters.py:1075 +#, python-brace-format +msgid "{descriptor}{date}" +msgstr "" + +#: src/webgrid/filters.py:1067 +#, python-brace-format +msgid "{descriptor}{first_date} - {second_date}" +msgstr "" + +#: src/webgrid/filters.py:1363 src/webgrid/filters.py:1373 +msgid "date filter given is out of range" +msgstr "" + +#: src/webgrid/filters.py:1394 src/webgrid/filters.py:1413 +#: src/webgrid/filters.py:1638 src/webgrid/filters.py:1664 +msgid "invalid date" +msgstr "" + +#: src/webgrid/filters.py:1774 +msgid "invalid time" +msgstr "" + +#: src/webgrid/filters.py:1790 +msgid "yes" +msgstr "" + +#: src/webgrid/filters.py:1791 +msgid "no" +msgstr "" + +#: src/webgrid/renderers.py:686 +#, python-brace-format +msgid "{label} DESC" +msgstr "" + +#: src/webgrid/renderers.py:746 +#, python-brace-format +msgid "of {page_count}" +msgstr "" + +#: src/webgrid/renderers.py:834 +msgid "No records to display" +msgstr "" + +#: src/webgrid/renderers.py:1003 +#, python-brace-format +msgid "{label} ({num} record):" +msgid_plural "{label} ({num} records):" +msgstr[0] "" +msgstr[1] "" + +#: src/webgrid/renderers.py:1025 +msgid "Page Totals" +msgstr "" + +#: src/webgrid/renderers.py:1030 +msgid "Grand Totals" +msgstr "" + +#: src/webgrid/renderers.py:1160 +msgid "Add Filter:" +msgstr "" + +#: src/webgrid/renderers.py:1178 +msgid "Search" +msgstr "" + +#: src/webgrid/validators.py:37 +msgid "Please enter a value." +msgstr "" + +#: src/webgrid/validators.py:48 +msgid "Please enter an integer value." +msgstr "" + +#: src/webgrid/validators.py:58 src/webgrid/validators.py:68 +msgid "Please enter a number." +msgstr "" + +#: src/webgrid/validators.py:74 +msgid "must specify either min or max for range validation" +msgstr "" + +#: src/webgrid/validators.py:81 +#, python-brace-format +msgid "Value must be greater than or equal to {}." +msgstr "" + +#: src/webgrid/validators.py:87 +#, python-brace-format +msgid "Value must be less than or equal to {}." +msgstr "" + +#: src/webgrid/validators.py:103 +#, python-brace-format +msgid "Value must be one of {}." +msgstr "" + +#: src/webgrid/validators.py:113 +msgid "Processor should be callable and take a value argument" +msgstr "" + +#: src/webgrid/static/jquery.multiple.select.js:369 +msgid "Select all" +msgstr "" + +#: src/webgrid/static/jquery.multiple.select.js:370 +msgid "All selected" +msgstr "" + +#: src/webgrid/static/jquery.multiple.select.js:372 +#, python-brace-format +msgid "{count} of {total} selected" +msgstr "" + +#: src/webgrid/templates/grid_footer.html:11 +msgid " Export to " +msgstr "" + +#: src/webgrid/templates/grid_footer.html:24 +#: src/webgrid/templates/grid_footer.html:31 +msgid "first" +msgstr "" + +#: src/webgrid/templates/grid_footer.html:28 +#: src/webgrid/templates/grid_footer.html:32 +msgid "previous" +msgstr "" + +#: src/webgrid/templates/grid_footer.html:37 +#: src/webgrid/templates/grid_footer.html:44 +msgid "next" +msgstr "" + +#: src/webgrid/templates/grid_footer.html:41 +#: src/webgrid/templates/grid_footer.html:45 +msgid "last" +msgstr "" + +#: src/webgrid/templates/grid_header.html:23 +msgid "Apply" +msgstr "" + +#: src/webgrid/templates/grid_header.html:29 +msgid "reset" +msgstr "" + +#: src/webgrid/templates/header_paging.html:8 +msgid "Records" +msgstr "" + +#: src/webgrid/templates/header_paging.html:14 +msgid "Page" +msgstr "" + +#: src/webgrid/templates/header_paging.html:19 +msgid "Per Page" +msgstr "" + +#: src/webgrid/templates/header_sorting.html:9 +msgid "Sort By" +msgstr "" diff --git a/webgrid/renderers.py b/src/webgrid/renderers.py similarity index 90% rename from webgrid/renderers.py rename to src/webgrid/renderers.py index 320523b..a9ca755 100644 --- a/webgrid/renderers.py +++ b/src/webgrid/renderers.py @@ -1,54 +1,47 @@ -from __future__ import absolute_import - -import re from abc import ABC, abstractmethod +from collections import defaultdict +import csv from dataclasses import asdict import inspect import io -from operator import itemgetter -from collections import defaultdict import json -from typing import Dict, Union - -import six -from blazeutils.functional import identity -from markupsafe import Markup +from operator import itemgetter +import re +import typing from blazeutils.containers import HTMLAttributes, LazyDict +from blazeutils.functional import identity from blazeutils.helpers import tolist from blazeutils.jsonh import jsonmod -from blazeutils.spreadsheets import WriterX, xlsxwriter, openpyxl -from blazeutils.strings import reindent, randnumerics +from blazeutils.spreadsheets import WriterX, openpyxl, xlsxwriter +from blazeutils.strings import randnumerics, reindent import jinja2 as jinja - +from markupsafe import Markup +import six from werkzeug.datastructures import MultiDict from werkzeug.routing import Map, Rule -from .extensions import ( - CustomJsonEncoder, - gettext as _, - ngettext, - translation_manager -) -from .utils import current_url from . import extensions, types -import csv +from .extensions import CustomJsonEncoder, ngettext, translation_manager +from .extensions import gettext as _ +from .utils import current_url + if openpyxl: - from openpyxl.styles import Font, Border, Side, Alignment + from openpyxl.styles import Alignment, Border, Font, Side from openpyxl.utils import get_column_letter else: - Font = Border = Side = Alignment = None + Font = Border = Side = Alignment = typing.Any try: from morphi.helpers.jinja import configure_jinja_environment except ImportError: - configure_jinja_environment = lambda *args, **kwargs: None # noqa: E731 + configure_jinja_environment = lambda *args, **kwargs: None try: from speaklater import is_lazy_string except ImportError: - is_lazy_string = lambda value: False # noqa: E731 + is_lazy_string = lambda value: False def fix_xls_value(value): @@ -79,13 +72,13 @@ class Renderer(ABC): Args: grid (BaseGrid): Parent grid of this renderer instance. """ + _columns = None @property @abstractmethod def name(self): """Identifier used to find columns that will render on this target.""" - pass @property def columns(self): @@ -117,17 +110,12 @@ def can_render(self): @abstractmethod def render(self): """Main renderer method returning the output.""" - pass class GroupMixin: def has_groups(self): """Returns True if any of the renderer's columns is part of a column group.""" - for col in self.columns: - if col.group: - return True - - return False + return any(col.group for col in self.columns) def get_group_heading_colspans(self): """Computes the number of columns spanned by various groups. @@ -171,9 +159,9 @@ def _safe_id(idstring): TODO: Set IDs explicitly and don't rely on this being applied to name attributes """ # Transform all whitespace to underscore - idstring = re.sub(r'\s', "_", '%s' % idstring) + idstring = re.sub(r'\s', '_', f'{idstring}') # Remove everything that is not a hyphen or a member of \w - idstring = re.sub(r'(?!-)\W', "", idstring).lower() + idstring = re.sub(r'(?!-)\W', '', idstring).lower() return idstring @@ -187,7 +175,7 @@ def render_attr(key, value): return Markup.escape(key) elif value is False or value is None: return Markup('') - return Markup('{}="{}"'.format(Markup.escape(key), Markup.escape(value))) + return Markup(f'{Markup.escape(key)}="{Markup.escape(value)}"') attrs = sorted(attrs.items(), key=itemgetter(0)) rendered_attrs = filter(identity, (render_attr(k, v) for k, v in attrs)) @@ -196,6 +184,7 @@ def render_attr(key, value): class JSON(Renderer): """Renderer for JSON output""" + mime_type = 'application/json' @property @@ -219,10 +208,7 @@ def serialize_column_group(self, label, columns): def serialized_column_groups(self): group_to_keys = defaultdict(list) - for column in filter( - lambda col: col.group is not None, - self.columns - ): + for column in filter(lambda col: col.group is not None, self.columns): group_to_keys[column.group].append(column.key) return [ self.serialize_column_group(group.label, columns) @@ -324,6 +310,8 @@ def as_response(self): class HTML(GroupMixin, Renderer): """Renderer for HTML output.""" + NBSP = Markup(' ') + @property def name(self): return 'html' @@ -338,7 +326,7 @@ def init(self): self.jinja_env = jinja.Environment( loader=jinja.PackageLoader('webgrid', 'templates'), finalize=lambda x: x if x is not None else '', - autoescape=True + autoescape=True, ) self.jinja_env.filters['wg_safe'] = jinja.filters.do_mark_safe self.jinja_env.filters['wg_attributes'] = render_html_attributes @@ -370,11 +358,7 @@ def header(self): def header_form_attrs(self, **kwargs): """HTML attributes to render on the grid header form element.""" - return { - 'method': self.form_action_method(), - 'action': self.form_action_url(), - **kwargs - } + return {'method': self.form_action_method(), 'action': self.form_action_url(), **kwargs} def form_action_method(self): """Detect whether the header form should have a GET or POST action. @@ -404,7 +388,7 @@ def filtering_session_key(self): """Hidden input to preserve the session key on form submission.""" return self._render_jinja( '', - value=self.grid.session_key + value=self.grid.session_key, ) def filtering_fields(self): @@ -414,11 +398,7 @@ def filtering_fields(self): rows.append(self.filtering_table_row(col)) rows = Markup('\n'.join(rows)) - top_row = '' - if self.grid.can_search(): - top_row = self.get_search_row() - else: - top_row = self.get_add_filter_row() + top_row = self.get_search_row() if self.grid.can_search() else self.get_add_filter_row() return Markup('\n'.join([top_row, rows])) @@ -426,7 +406,7 @@ def filtering_table_row(self, col): """Single filter row with op and inputs.""" extra = getattr(col.filter, 'html_extra', {}) return self._render_jinja( - ''' + """ {{renderer.filtering_col_label(col)}} {{renderer.filtering_col_op_select(col)}} @@ -439,7 +419,7 @@ def filtering_table_row(self, col): - ''', + """, renderer=self, col=col, extra=extra, @@ -452,41 +432,38 @@ def filtering_col_label(self, col): def filtering_col_op_select(self, col): """Render select box for filter Operator options.""" filter = col.filter - if not filter.is_display_active: - current_selected = '' - else: - current_selected = filter.op + current_selected = '' if filter.is_display_active else filter.op primary_op = filter.primary_op or filter.operators[0] - is_primary = lambda op: 'primary' if op == primary_op else None # noqa: E731 + is_primary = lambda op: 'primary' if op == primary_op else None - field_name = 'op({0})'.format(col.key) + field_name = f'op({col.key})' field_name = self.grid.prefix_qs_arg_key(field_name) return self.render_select( [(op.key, op.display, is_primary(op)) for op in filter.operators], current_selected, - name=field_name + name=field_name, ) def filtering_col_inputs1(self, col): """Render the first input, which can be freeform or select.""" filter = col.filter - field_name = 'v1({0})'.format(col.key) + field_name = f'v1({col.key})' field_name = self.grid.prefix_qs_arg_key(field_name) inputs = Markup() if 'input' in filter.input_types: - ident = '{0}_input1'.format(col.key) + ident = f'{col.key}_input1' inputs += self._render_jinja( '', - attrs=dict( - name=field_name, - value=filter.value1_set_with, - id=ident, - type='text', - ) + attrs={ + 'name': field_name, + 'value': filter.value1_set_with, + 'id': ident, + 'type': 'text', + }, ) if 'select' in filter.input_types: current_selected = tolist(filter.value1) or [] @@ -495,13 +472,13 @@ def filtering_col_inputs1(self, col): current_selection=current_selected, placeholder=None, multiple=filter.receives_list, - name=field_name + name=field_name, ) if filter.receives_list: inputs += self.filtering_multiselect( field_name, current_selected, - self.filtering_filter_options_multi(filter, field_name) + self.filtering_filter_options_multi(filter, field_name), ) return inputs @@ -513,7 +490,7 @@ def filtering_multiselect(self, field_name, current_selected, options): main render/transform here. """ return self._render_jinja( - ''' + """
- ''', + """, field_name=field_name, current_selected=current_selected, options=options, @@ -556,7 +533,7 @@ def filtering_filter_options_multi(self, filter, field_name): if inspect.isclass(validator): validator = validator() return self._render_jinja( - ''' + """ {% for value, label in filter.options_seq %}
  • {% endfor %} - ''', + """, filter=filter, field_name=field_name, selected=selected, @@ -580,29 +557,29 @@ def filtering_filter_options_multi(self, filter, field_name): def filtering_col_inputs2(self, col): """Render the second filter input, currently only a freeform.""" filter = col.filter - field_name = 'v2({0})'.format(col.key) + field_name = f'v2({col.key})' field_name = self.grid.prefix_qs_arg_key(field_name) if 'input2' not in filter.input_types: return Markup('') # field will get modified by JS - ident = '{0}_input2'.format(col.key) + ident = f'{col.key}_input2' return self._render_jinja( '', - attrs=dict( - name=field_name, - value=filter.value2_set_with, - id=ident, - type='text' - ) + attrs={ + 'name': field_name, + 'value': filter.value2_set_with, + 'id': ident, + 'type': 'text', + }, ) def filtering_add_filter_select(self): """Render the select box for adding a new filter. Used by the filter template.""" return self.render_select( [(col.key, col.label) for col in self.grid.filtered_cols.values()], - name='datagrid-add-filter' + name='datagrid-add-filter', ) def filtering_json_data(self): @@ -630,17 +607,21 @@ def confirm_export(self): confirmation_required = False else: confirmation_required = count > self.grid.unconfirmed_export_limit - return jsonmod.dumps({ - 'confirm_export': confirmation_required, - 'record_count': count - }) + return jsonmod.dumps({'confirm_export': confirmation_required, 'record_count': count}) def header_sorting(self): """Render the sort area. Used by the header template.""" return self.load_content('header_sorting.html') - def render_select(self, options, current_selection=None, placeholder=('', Markup(' ')), - name=None, id=None, **kwargs): + def render_select( + self, + options, + current_selection=None, + placeholder=('', NBSP), + name=None, + id=None, + **kwargs, + ): """Generalized select box renderer. Args: @@ -672,7 +653,7 @@ def render_select(self, options, current_selection=None, placeholder=('', Markup kwargs['id'] = id return self._render_jinja( - ''' + """ {% for value, label, data in options %}