├── docs
├── .gitignore
├── source
│ ├── _static
│ │ └── css
│ │ │ └── custom.css
│ ├── testing
│ │ ├── index.rst
│ │ ├── test-helpers.rst
│ │ └── test-usage.rst
│ ├── renderers
│ │ ├── index.rst
│ │ ├── base-renderer.rst
│ │ └── built-in-renderers.rst
│ ├── grid
│ │ ├── grid.rst
│ │ ├── managers.rst
│ │ ├── types.rst
│ │ └── args-loaders.rst
│ ├── filters
│ │ ├── index.rst
│ │ ├── base-filter.rst
│ │ ├── built-in-filters.rst
│ │ └── custom-filters.rst
│ ├── columns
│ │ ├── index.rst
│ │ ├── base-column.rst
│ │ ├── built-in-columns.rst
│ │ ├── custom-columns.rst
│ │ └── column-usage.rst
│ ├── index.rst
│ ├── conf.py
│ └── getting-started.rst
├── requirements.txt
├── Makefile
└── make.bat
├── tests
├── webgrid_ta
│ ├── __init__.py
│ ├── i18n
│ │ ├── es
│ │ │ └── LC_MESSAGES
│ │ │ │ ├── webgrid_ta.mo
│ │ │ │ └── webgrid_ta.po
│ │ ├── babel.cfg
│ │ └── webgrid_ta.pot
│ ├── templates
│ │ ├── groups.html
│ │ └── index.html
│ ├── extensions.py
│ ├── views.py
│ ├── data
│ │ ├── basic_table.html
│ │ └── people_table.html
│ ├── manage.py
│ ├── app.py
│ ├── model
│ │ ├── __init__.py
│ │ └── entities.py
│ ├── helpers.py
│ └── grids.py
├── webgrid_tests
│ ├── __init__.py
│ ├── conftest.py
│ ├── data
│ │ ├── basic_table.html
│ │ ├── people_table.html
│ │ └── stopwatch_table.html
│ ├── helpers.py
│ ├── test_types.py
│ ├── test_testing.py
│ └── test_api.py
├── webgrid_blazeweb_ta
│ ├── __init__.py
│ ├── model
│ │ ├── __init__.py
│ │ └── orm.py
│ ├── tasks
│ │ ├── __init__.py
│ │ └── init_db.py
│ ├── tests
│ │ ├── __init__.py
│ │ └── grids.py
│ ├── config
│ │ ├── __init__.py
│ │ ├── site_settings.py_tpl
│ │ └── settings.py
│ ├── templates
│ │ ├── nonstandard_header.css
│ │ ├── nonstandard_header.html
│ │ └── manage_people.html
│ ├── i18n
│ │ ├── babel.cfg
│ │ ├── es
│ │ │ └── LC_MESSAGES
│ │ │ │ ├── webgrid_blazeweb_ta.mo
│ │ │ │ └── webgrid_blazeweb_ta.po
│ │ └── webgrid_blazeweb_ta.pot
│ ├── application.py
│ ├── views.py
│ └── extensions.py
└── conftest.py
├── pyp.ini
├── src
├── webgrid
│ ├── version.py
│ ├── i18n
│ │ ├── babel.cfg
│ │ ├── es
│ │ │ └── LC_MESSAGES
│ │ │ │ └── webgrid.mo
│ │ └── webgrid.pot
│ ├── static
│ │ ├── delete.png
│ │ ├── b_lastpage.png
│ │ ├── b_nextpage.png
│ │ ├── b_prevpage.png
│ │ ├── b_firstpage.png
│ │ ├── bd_firstpage.png
│ │ ├── bd_lastpage.png
│ │ ├── bd_nextpage.png
│ │ ├── bd_prevpage.png
│ │ ├── th_arrow_down.png
│ │ ├── th_arrow_up.png
│ │ ├── multiple-select.png
│ │ ├── application_form_edit.png
│ │ ├── gettext.min.js
│ │ ├── multiple-select.css
│ │ ├── i18n
│ │ │ └── es
│ │ │ │ └── LC_MESSAGES
│ │ │ │ └── webgrid.json
│ │ └── webgrid.css
│ ├── templates
│ │ ├── header_sorting.html
│ │ ├── header_filtering.html
│ │ ├── grid_table.html
│ │ ├── header_paging.html
│ │ ├── grid.html
│ │ ├── grid_header.html
│ │ └── grid_footer.html
│ ├── utils.py
│ ├── blazeweb.py
│ ├── validators.py
│ └── types.py
└── webgrid_tasks_lib.py
├── ci
└── .gitignore
├── .coveragerc
├── env-config.yaml
├── .readthedocs.yaml
├── hatch.toml
├── .github
├── actions
│ ├── uv-setup
│ │ └── action.yaml
│ └── nox-run
│ │ └── action.yaml
└── workflows
│ └── nox.yaml
├── .gitignore
├── .copier-answers-py.yaml
├── tasks
├── odbc-driver-install
├── gh-nox-sessions
└── bump
├── compose.yaml
├── .editorconfig
├── .pre-commit-config.yaml
├── mise.toml
├── license.txt
├── appveyor.yml
├── ruff.toml
├── pyproject.toml
├── noxfile.py
└── readme.md
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/pyp.ini:
--------------------------------------------------------------------------------
1 | [pyp]
2 | source_dir = webgrid
3 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/model/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/tasks/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/webgrid/version.py:
--------------------------------------------------------------------------------
1 | VERSION = '0.20250807.2'
2 |
--------------------------------------------------------------------------------
/ci/.gitignore:
--------------------------------------------------------------------------------
1 | artifacts
2 | test-reports
3 | coverage
4 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/config/__init__.py:
--------------------------------------------------------------------------------
1 | VERSION = '0.1'
2 |
--------------------------------------------------------------------------------
/src/webgrid/i18n/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: **.py]
2 | [jinja2: templates/**.html]
3 | [javascript: **.js]
4 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/templates/nonstandard_header.css:
--------------------------------------------------------------------------------
1 | #something {
2 | width: 50px;
3 | }
4 |
--------------------------------------------------------------------------------
/docs/source/_static/css/custom.css:
--------------------------------------------------------------------------------
1 | dl.py.class, dl.py.function {
2 | margin-bottom: 2em;
3 | }
4 |
--------------------------------------------------------------------------------
/src/webgrid/static/delete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/delete.png
--------------------------------------------------------------------------------
/src/webgrid/static/b_lastpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/b_lastpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/b_nextpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/b_nextpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/b_prevpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/b_prevpage.png
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | --extra-index-url https://package-index.level12.net
2 |
3 | sphinx
4 | pytz
5 | -e .[develop]
6 |
--------------------------------------------------------------------------------
/src/webgrid/static/b_firstpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/b_firstpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/bd_firstpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/bd_firstpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/bd_lastpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/bd_lastpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/bd_nextpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/bd_nextpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/bd_prevpage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/bd_prevpage.png
--------------------------------------------------------------------------------
/src/webgrid/static/th_arrow_down.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/th_arrow_down.png
--------------------------------------------------------------------------------
/src/webgrid/static/th_arrow_up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/th_arrow_up.png
--------------------------------------------------------------------------------
/src/webgrid/static/multiple-select.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/multiple-select.png
--------------------------------------------------------------------------------
/docs/source/testing/index.rst:
--------------------------------------------------------------------------------
1 | Testing
2 | =======
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | test-helpers
8 | test-usage
9 |
--------------------------------------------------------------------------------
/src/webgrid/i18n/es/LC_MESSAGES/webgrid.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/i18n/es/LC_MESSAGES/webgrid.mo
--------------------------------------------------------------------------------
/src/webgrid/static/application_form_edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/src/webgrid/static/application_form_edit.png
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/i18n/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: **.py]
2 | [jinja2: templates/**.html]
3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_
4 |
--------------------------------------------------------------------------------
/docs/source/renderers/index.rst:
--------------------------------------------------------------------------------
1 | Renderers
2 | =========
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | base-renderer
8 | built-in-renderers
9 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/i18n/es/LC_MESSAGES/webgrid_ta.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/tests/webgrid_ta/i18n/es/LC_MESSAGES/webgrid_ta.mo
--------------------------------------------------------------------------------
/docs/source/testing/test-helpers.rst:
--------------------------------------------------------------------------------
1 | Test Helpers
2 | ============
3 |
4 | .. automodule:: webgrid.testing
5 | :members:
6 | :no-undoc-members:
7 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/i18n/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: **.py]
2 | [jinja2: templates/**.html]
3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_
4 | [javascript: **.js]
5 |
--------------------------------------------------------------------------------
/docs/source/grid/grid.rst:
--------------------------------------------------------------------------------
1 | .. _base-grid:
2 |
3 | Grid Class
4 | ==========
5 |
6 | .. autoclass:: webgrid.BaseGrid
7 | :members:
8 | :inherited-members:
9 |
--------------------------------------------------------------------------------
/docs/source/renderers/base-renderer.rst:
--------------------------------------------------------------------------------
1 | Base Renderer
2 | =============
3 |
4 | .. autoclass:: webgrid.renderers.Renderer
5 | :members:
6 | :inherited-members:
7 |
--------------------------------------------------------------------------------
/docs/source/filters/index.rst:
--------------------------------------------------------------------------------
1 | Filters
2 | =======
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | base-filter
8 | built-in-filters
9 | custom-filters
10 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/templates/nonstandard_header.html:
--------------------------------------------------------------------------------
1 | {{ include_css('nonstandard_header.css') }}
2 | {{ getcontent('webgrid:grid_header.html', renderer=renderer)|safe }}
3 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/i18n/es/LC_MESSAGES/webgrid_blazeweb_ta.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/level12/webgrid/HEAD/tests/webgrid_blazeweb_ta/i18n/es/LC_MESSAGES/webgrid_blazeweb_ta.mo
--------------------------------------------------------------------------------
/docs/source/columns/index.rst:
--------------------------------------------------------------------------------
1 | Columns
2 | =======
3 |
4 | .. toctree::
5 | :maxdepth: 1
6 |
7 | base-column
8 | built-in-columns
9 | column-usage
10 | custom-columns
11 |
--------------------------------------------------------------------------------
/docs/source/columns/base-column.rst:
--------------------------------------------------------------------------------
1 | Base Column and ColumnGroup
2 | ===========================
3 |
4 | .. autoclass:: webgrid.Column
5 | :members:
6 |
7 | .. autoclass:: webgrid.ColumnGroup
8 | :members:
9 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | # .coveragerc to control coverage.py
2 | [run]
3 | branch = True
4 | omit =
5 | src/webgrid/version.py
6 |
7 | source =
8 | src/webgrid
9 | tests/webgrid_tests
10 |
11 |
12 | [html]
13 | directory = tmp/coverage-html
14 |
--------------------------------------------------------------------------------
/env-config.yaml:
--------------------------------------------------------------------------------
1 | profile:
2 | pypi:
3 | HATCH_INDEX_USER: '__token__'
4 | HATCH_INDEX_AUTH: 'op://my/private/pypi.org/api-token'
5 | test-pypi:
6 | HATCH_INDEX_USER: '__token__'
7 | HATCH_INDEX_AUTH: 'op://my/private/test.pypi.org/api-token'
8 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: "ubuntu-24.04"
5 | tools:
6 | python: "3.13"
7 | commands:
8 | - pip install uv
9 | - uv run --only-group nox -- nox -s docs
10 | - mkdir -p $READTHEDOCS_OUTPUT/html
11 | - cp -av docs/build/html/ $READTHEDOCS_OUTPUT
12 |
--------------------------------------------------------------------------------
/hatch.toml:
--------------------------------------------------------------------------------
1 | ## Build
2 | [build]
3 | directory = 'tmp/dist'
4 | dev-mode-dirs = ['src']
5 |
6 | [build.targets.wheel]
7 | packages = ['src/webgrid']
8 |
9 |
10 | ## Env: default
11 | [envs.default]
12 | installer = "uv"
13 |
14 |
15 | ## Version
16 | [version]
17 | source = 'regex_commit'
18 | path = 'src/webgrid/version.py'
19 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/templates/manage_people.html:
--------------------------------------------------------------------------------
1 | {% set jquery_ui_includes = ['base', 'multiselect'] %}
2 | {% extends 'templating:admin/layout.html' %}
3 | {% block title %}{{ _('Manage People') }}{% endblock%}
4 | {% block body %}
5 |
{{ _('Manage People') }}
6 | {{ people_grid.html()|content }}
7 | {% endblock body%}
8 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/config/site_settings.py_tpl:
--------------------------------------------------------------------------------
1 | from settings import Test
2 |
3 | class TestMSSQL(Test):
4 | """ default profile when running tests """
5 | def init(self):
6 | # call parent init to setup default settings
7 | Test.init(self)
8 | self.db.url = 'mssql://user:pass@host.example.com:1435/dbname'
9 |
--------------------------------------------------------------------------------
/docs/source/filters/base-filter.rst:
--------------------------------------------------------------------------------
1 | Base Filters
2 | ============
3 |
4 | .. autoclass:: webgrid.filters.Operator
5 | :members:
6 |
7 | .. autoclass:: webgrid.filters.FilterBase
8 | :members:
9 |
10 | .. autoclass:: webgrid.filters.OptionsFilterBase
11 | :members:
12 |
13 | .. autoclass:: webgrid.filters.OptionsIntFilterBase
14 | :members:
15 |
--------------------------------------------------------------------------------
/src/webgrid/templates/header_sorting.html:
--------------------------------------------------------------------------------
1 | {%- if _ is not defined -%}
2 | {% macro _(message) -%}
3 | {{ message }}
4 | {%- endmacro %}
5 | {%- endif -%}
6 |
7 | {% if grid.sorter_on %}
8 |
9 | {{ _('Sort By') }}:
10 | {{ renderer.sorting_select1() }}
11 | {{ renderer.sorting_select2() }}
12 | {{ renderer.sorting_select3() }}
13 |
14 | {% endif %}
15 |
--------------------------------------------------------------------------------
/src/webgrid/templates/header_filtering.html:
--------------------------------------------------------------------------------
1 | {%- if _ is not defined -%}
2 | {% macro _(message) -%}
3 | {{ message }}
4 | {%- endmacro %}
5 | {%- endif -%}
6 |
7 | {% if renderer.grid.filtered_cols -%}
8 |
9 | {{renderer.filtering_session_key()}}
10 |
11 | {{ renderer.filtering_fields() }}
12 |
13 |
14 | {%- endif %}
15 |
--------------------------------------------------------------------------------
/docs/source/renderers/built-in-renderers.rst:
--------------------------------------------------------------------------------
1 | Built-in Renderers
2 | ==================
3 |
4 | .. autoclass:: webgrid.renderers.HTML
5 | :members:
6 | :inherited-members:
7 |
8 | .. autoclass:: webgrid.renderers.JSON
9 | :members:
10 | :inherited-members:
11 |
12 | .. autoclass:: webgrid.renderers.XLSX
13 | :members:
14 | :inherited-members:
15 |
16 | .. autoclass:: webgrid.renderers.CSV
17 | :members:
18 | :inherited-members:
19 |
--------------------------------------------------------------------------------
/.github/actions/uv-setup/action.yaml:
--------------------------------------------------------------------------------
1 | name: "uv setup"
2 | description: "Install Python & uv"
3 |
4 | runs:
5 | using: composite
6 | steps:
7 | # NOTE: use GH's action to install Python b/c mise docs say it will be faster due to caching
8 | - name: "Set up Python"
9 | uses: actions/setup-python@v5
10 | with:
11 | python-version-file: "pyproject.toml"
12 |
13 | - name: Install uv
14 | uses: astral-sh/setup-uv@v6
15 |
--------------------------------------------------------------------------------
/src/webgrid/templates/grid_table.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {%- if renderer.has_groups() %}
4 |
5 | {{ renderer.table_group_headings().lstrip() }}
6 |
7 | {%- endif %}
8 |
9 | {{ renderer.table_column_headings().lstrip() }}
10 |
11 |
12 |
13 | {{ renderer.table_rows().lstrip() }}
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python
2 | *.pyc
3 | *.egg-info
4 | .venv
5 |
6 | # Tox/Nox/Tests/Coverage
7 | /.nox
8 | /.tox
9 | /ci/coverage-html
10 | *.coverage
11 | /coverage.xml
12 | *.pytests.xml
13 | .pytest_cache
14 |
15 |
16 | # Editor Clutter
17 | .vscode
18 | .idea
19 |
20 |
21 | # Build related
22 | dist
23 | /build
24 | npm-debug.log
25 | .*-cache
26 | node_modules
27 |
28 |
29 | # Local dev files
30 | /mise.local.toml
31 | /tmp
32 |
33 | # Terraform
34 | *.tfstate*
35 | tf.plan
36 | .terraform
37 |
38 |
39 | # Project specific
40 |
--------------------------------------------------------------------------------
/.copier-answers-py.yaml:
--------------------------------------------------------------------------------
1 | # Changes here will be overwritten by Copier
2 | #
3 | # Updating `_src` could be helpful, see notes at:
4 | # https://github.com/level12/coppy/wiki#creating-a-project
5 | #
6 | # Otherwise, NEVER EDIT MANUALLY
7 | _commit: v1.20250622.1
8 | _src_path: gh:level12/coppy
9 | author_email: devteam@level12.io
10 | author_name: Level 12
11 | gh_org: level12
12 | gh_repo: webgrid
13 | hatch_version_tag_sign: true
14 | project_name: WebGrid
15 | py_module: webgrid
16 | python_version: '3.10'
17 | script_name: ''
18 | use_circleci: false
19 | use_gh_nox: true
20 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/application.py:
--------------------------------------------------------------------------------
1 | from blazeweb.application import WSGIApp
2 | from blazeweb.middleware import full_wsgi_stack
3 | from blazeweb.scripting import application_entry
4 | from sqlalchemybwc.lib.middleware import SQLAlchemyApp
5 |
6 | import webgrid_blazeweb_ta.config.settings as settingsmod
7 |
8 |
9 | def make_wsgi(profile='Dev'):
10 | app = WSGIApp(settingsmod, profile)
11 |
12 | app = SQLAlchemyApp(app)
13 |
14 | return full_wsgi_stack(app)
15 |
16 |
17 | def script_entry():
18 | application_entry(make_wsgi)
19 |
20 |
21 | if __name__ == '__main__':
22 | script_entry()
23 |
--------------------------------------------------------------------------------
/src/webgrid/templates/header_paging.html:
--------------------------------------------------------------------------------
1 | {%- if _ is not defined -%}
2 | {% macro _(message) -%}
3 | {{ message }}
4 | {%- endmacro %}
5 | {%- endif -%}
6 |
7 |
8 | {{ _('Records') }}:
9 |
10 | {{ grid.record_count }}
11 |
12 |
13 | {% if grid.pager_on %}
14 | {{ _('Page') }}:
15 |
16 | {{ renderer.paging_select() }}
17 |
18 |
19 | {{ _('Per Page') }}:
20 |
21 | {{ renderer.paging_input() }}
22 |
23 | {% endif %}
24 |
25 |
--------------------------------------------------------------------------------
/src/webgrid/templates/grid.html:
--------------------------------------------------------------------------------
1 |
5 |
6 | {% if not grid.hide_controls_box %}
7 | {{ renderer.header()|wg_safe }}
8 | {% endif %}
9 | {% if grid.record_count %}
10 | {{ renderer.table()|wg_safe }}
11 | {% else %}
12 | {{ renderer.no_records()|safe }}
13 | {% endif %}
14 | {% if not grid.hide_controls_box %}
15 | {{ renderer.footer()|wg_safe }}
16 | {% endif %}
17 |
18 |
--------------------------------------------------------------------------------
/docs/source/columns/built-in-columns.rst:
--------------------------------------------------------------------------------
1 | Built-in Specialized Columns
2 | ============================
3 |
4 | .. autoclass:: webgrid.LinkColumnBase
5 | :members:
6 |
7 | .. autoclass:: webgrid.BoolColumn
8 | :members:
9 |
10 | .. autoclass:: webgrid.YesNoColumn
11 | :members:
12 |
13 | .. autoclass:: webgrid.DateColumnBase
14 | :members:
15 |
16 | .. autoclass:: webgrid.DateColumn
17 | :members:
18 |
19 | .. autoclass:: webgrid.DateTimeColumn
20 | :members:
21 |
22 | .. autoclass:: webgrid.TimeColumn
23 | :members:
24 |
25 | .. autoclass:: webgrid.NumericColumn
26 | :members:
27 |
28 | .. autoclass:: webgrid.EnumColumn
29 | :members:
30 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/source/grid/managers.rst:
--------------------------------------------------------------------------------
1 | Framework Managers
2 | ==================
3 |
4 | One design goal of the base grid class is that it be essentially framework-agnostic. That is,
5 | the grid, by itself, should not care if it is being run in Flask, BlazeWeb, or another web
6 | app framework. As long as it has a connection to the framework that provides required items
7 | with a consistent interface, the grid should interact with the framework through that connection.
8 |
9 | Wrapped features available through the manager:
10 |
11 | - SQLAlchemy connection and queries
12 | - Request
13 | - Session storage
14 | - Flash messages
15 | - File export in response
16 |
17 | .. autoclass:: webgrid.flask.WebGrid
18 | :members:
19 |
--------------------------------------------------------------------------------
/docs/source/filters/built-in-filters.rst:
--------------------------------------------------------------------------------
1 | Built-in Filters
2 | ================
3 |
4 | .. autoclass:: webgrid.filters.TextFilter
5 | :members:
6 |
7 | .. autoclass:: webgrid.filters.IntFilter
8 | :members:
9 |
10 | .. autoclass:: webgrid.filters.AggregateIntFilter
11 | :members:
12 |
13 | .. autoclass:: webgrid.filters.NumberFilter
14 | :members:
15 |
16 | .. autoclass:: webgrid.filters.AggregateNumberFilter
17 | :members:
18 |
19 | .. autoclass:: webgrid.filters.DateFilter
20 | :members:
21 |
22 | .. autoclass:: webgrid.filters.DateTimeFilter
23 | :members:
24 |
25 | .. autoclass:: webgrid.filters.TimeFilter
26 | :members:
27 |
28 | .. autoclass:: webgrid.filters.YesNoFilter
29 | :members:
30 |
31 | .. autoclass:: webgrid.filters.OptionsEnumFilter
32 | :members:
33 |
--------------------------------------------------------------------------------
/tasks/odbc-driver-install:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # https://learn.microsoft.com/en-us/sql/connect/odbc/linux-mac/installing-the-microsoft-odbc-driver-for-sql-server
3 | if ! [[ "18.04 20.04 22.04 24.04 24.10" == *"$(grep VERSION_ID /etc/os-release | cut -d '"' -f 2)"* ]];
4 | then
5 | echo "Ubuntu $(grep VERSION_ID /etc/os-release | cut -d '"' -f 2) is not currently supported.";
6 | exit;
7 | fi
8 |
9 | # Download the package to configure the Microsoft repo
10 | curl -sSL -O https://packages.microsoft.com/config/ubuntu/$(grep VERSION_ID /etc/os-release | cut -d '"' -f 2)/packages-microsoft-prod.deb
11 | # Install the package
12 | sudo dpkg -i packages-microsoft-prod.deb
13 | # Delete the file
14 | rm packages-microsoft-prod.deb
15 |
16 | # Install the driver & tools (e.g. bcp and sqlcmd)
17 | sudo apt-get update
18 | sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18
19 |
--------------------------------------------------------------------------------
/compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | pg:
3 | image: postgres:17-alpine
4 | container_name: webgrid-pg
5 | ports:
6 | - '${DC_POSTGRES_HOST:-127.0.0.1}:${DC_POSTGRES_PORT:-5432}:5432'
7 | environment:
8 | # Ok for local dev, UNSAFE in most other applications. Don't blindly copy & paste
9 | # without considering implications.
10 | POSTGRES_HOST_AUTH_METHOD: trust
11 | mssql:
12 | image: mcr.microsoft.com/mssql/server:2019-latest
13 | container_name: webgrid-mssql
14 | environment:
15 | ACCEPT_EULA: Y
16 | SA_PASSWORD: Docker-sa-password
17 | ports:
18 | - '${DC_MSSQL_HOST:-127.0.0.1}:${DC_MSSQL_PORT:-1433}:1433'
19 | healthcheck:
20 | test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-C", "-S", "127.0.0.1", "-U", "sa", "-P", "Docker-sa-password", "-Q", "select 1"]
21 | interval: 10s
22 | timeout: 15s
23 | retries: 5
24 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/views.py:
--------------------------------------------------------------------------------
1 | from blazeweb.views import View
2 |
3 | from webgrid import NumericColumn
4 | from webgrid_blazeweb_ta.extensions import lazy_gettext as _
5 | from webgrid_blazeweb_ta.model.orm import Person
6 | from webgrid_blazeweb_ta.tests.grids import PeopleGrid as PGBase
7 |
8 |
9 | class CurrencyCol(NumericColumn):
10 | def format_data(self, data):
11 | return data if int(data) % 2 else data * -1
12 |
13 |
14 | class PeopleGrid(PGBase):
15 | CurrencyCol(_('Currency'), Person.numericcol, format_as='percent', places=5)
16 | CurrencyCol(_('C2'), Person.numericcol.label('n2'), format_as='accounting')
17 |
18 |
19 | class ManagePeople(View):
20 | def default(self):
21 | pg = PeopleGrid()
22 | pg.apply_qs_args()
23 | if pg.export_to:
24 | pg.export_as_response()
25 | self.assign('people_grid', pg)
26 | self.render_template()
27 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/templates/groups.html:
--------------------------------------------------------------------------------
1 | {% extends "bootstrap/base.html" %}
2 | {% block title %}{{ 'Grid with Column Groups' }}{% endblock %}
3 |
4 | {% block styles %}
5 | {{ super() }}
6 |
7 |
8 | {% endblock %}
9 |
10 | {% block content %}
11 |
12 |
{{ 'Grid with Column Groups' }}
13 | {{ grid.html()|safe}}
14 |
15 | {% endblock %}
16 |
17 | {% block scripts %}
18 | {{super()}}
19 |
20 |
21 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/.github/actions/nox-run/action.yaml:
--------------------------------------------------------------------------------
1 | name: "Nox Run"
2 |
3 | inputs:
4 | nox-session:
5 | description: "Name of the nox session to execute"
6 | required: true
7 |
8 | runs:
9 | using: composite
10 | steps:
11 | - uses: ./.github/actions/uv-setup
12 |
13 | # MSSQL sessions need ODBC driver in the runner
14 | - name: Install MS ODBC driver (Ubuntu)
15 | if: contains(inputs.nox-session, 'pytest_mssql')
16 | run: tasks/odbc-driver-install
17 | shell: bash
18 |
19 | - name: Run nox session
20 | run: uv run --only-group nox -- nox -s "${{ inputs.nox-session }}"
21 | env:
22 | UV_LINK_MODE: copy
23 | shell: bash
24 |
25 | - name: upload coverage artifact
26 | if: contains(inputs.nox-session, 'pytest')
27 | uses: actions/upload-artifact@v4
28 | with:
29 | name: coverage-${{ inputs.nox-session }}
30 | path: ci/coverage/${{ inputs.nox-session }}.xml
31 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/src/webgrid_tasks_lib.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Iterable
2 | from os import environ
3 | import subprocess
4 |
5 |
6 | def sub_run(
7 | *args,
8 | capture=False,
9 | returns: None | Iterable[int] = None,
10 | **kwargs,
11 | ) -> subprocess.CompletedProcess:
12 | kwargs.setdefault('check', not bool(returns))
13 | capture = kwargs.setdefault('capture_output', capture)
14 | args = args + kwargs.pop('args', ())
15 | env = kwargs.pop('env', None)
16 | if env:
17 | kwargs['env'] = environ | env
18 | if capture:
19 | kwargs.setdefault('text', True)
20 |
21 | try:
22 | result = subprocess.run(args, **kwargs)
23 | if returns and result.returncode not in returns:
24 | raise subprocess.CalledProcessError(result.returncode, args[0])
25 | return result
26 | except subprocess.CalledProcessError as e:
27 | if capture:
28 | print(e.stderr)
29 | raise
30 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This is the "top-most" .editorconfig for this project.
2 | # Like a `.gitignore` file, you can place additional
3 | # .editorconfig files in specific directories, if you need
4 | # local settings. However, this directive specifies that
5 | # no "global" settings will be used (settings from higher-
6 | # up in the directory hierarchy, wherever that may be)
7 | root = true
8 |
9 | # Set the default whitespace settings for all files
10 | [*]
11 |
12 | # Use UNIX-style line endings
13 | end_of_line = lf
14 |
15 | # 4-space indents
16 | indent_size = 4
17 | indent_style = space
18 |
19 | # end all files with a newline
20 | insert_final_newline = true
21 |
22 | # trim whitespace from the ends of lines
23 | trim_trailing_whitespace = true
24 |
25 |
26 | [*.py]
27 | # ensure Python source files are utf-8
28 | charset = utf-8
29 |
30 |
31 | [*.{yml,yaml}]
32 | # Set two-space indents for YAML files
33 | indent_size = 2
34 |
35 |
36 | [Makefile]
37 | # Makefiles *must* use tabs!
38 | indent_style = tab
39 |
--------------------------------------------------------------------------------
/tasks/gh-nox-sessions:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # mise description='Nox session groups for GH CI'
3 | import json
4 | from os import environ
5 |
6 | from webgrid_tasks_lib import sub_run
7 |
8 |
9 | # GitHub outputs are harder if there are newlines, so don't do any formatting in CI
10 | is_ci = environ.get('CI')
11 | sort_keys = not is_ci
12 | indent = None if is_ci else 4
13 |
14 |
15 | def main():
16 | result = sub_run('nox', '--list-sessions', '--json', capture=True)
17 | sess_names = [rec['session'] for rec in json.loads(result.stdout)]
18 | ci_sessions = {}
19 | ci_sessions['pg'] = [name for name in sess_names if "db='pg'" in name]
20 | ci_sessions['mssql'] = [name for name in sess_names if 'mssql' in name]
21 | ci_sessions['other'] = [
22 | name for name in sess_names if name not in (*ci_sessions['pg'], *ci_sessions['mssql'])
23 | ]
24 | print(json.dumps(ci_sessions, indent=indent or None, sort_keys=sort_keys))
25 |
26 |
27 | if __name__ == '__main__':
28 | main()
29 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/i18n/webgrid_blazeweb_ta.pot:
--------------------------------------------------------------------------------
1 | # Translations template for WebGrid.
2 | # Copyright (C) 2018 ORGANIZATION
3 | # This file is distributed under the same license as the WebGrid project.
4 | # FIRST AUTHOR , 2018.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: WebGrid 0.1.36\n"
10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11 | "POT-Creation-Date: 2018-08-09 19:02-0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=utf-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Generated-By: Babel 2.6.0\n"
19 |
20 | #: webgrid_blazeweb_ta/views.py:15
21 | msgid "Currency"
22 | msgstr ""
23 |
24 | #: webgrid_blazeweb_ta/views.py:16
25 | msgid "C2"
26 | msgstr ""
27 |
28 | #: webgrid_blazeweb_ta/templates/manage_people.html:3
29 | #: webgrid_blazeweb_ta/templates/manage_people.html:5
30 | msgid "Manage People"
31 | msgstr ""
32 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to WebGrid
2 | ======================
3 |
4 | WebGrid is a datagrid library for Flask and other Python web frameworks designed to work with
5 | SQLAlchemy ORM entities and queries.
6 |
7 | With a grid configured from one or more entities, WebGrid provides these features for reporting:
8 |
9 | - Automated SQL query construction based on specified columns and query join/filter/sort options
10 | - Renderers to various targets/formats
11 |
12 | - HTML output paired with JS (jQuery) for dynamic features
13 | - Excel (XLSX)
14 | - CSV
15 |
16 | - User-controlled data filters
17 |
18 | - Per-column selection of filter operator and value(s)
19 | - Generic single-entry search
20 |
21 | - Session storage/retrieval of selected filter options, sorting, and paging
22 |
23 | **Table of Contents**
24 |
25 | .. toctree::
26 | :maxdepth: 2
27 |
28 | getting-started
29 | grid/grid
30 | grid/managers
31 | grid/args-loaders
32 | grid/types
33 | columns/index
34 | filters/index
35 | renderers/index
36 | testing/index
37 | gotchas
38 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/i18n/es/LC_MESSAGES/webgrid_blazeweb_ta.po:
--------------------------------------------------------------------------------
1 | # Spanish translations for WebGrid.
2 | # Copyright (C) 2018 ORGANIZATION
3 | # This file is distributed under the same license as the WebGrid project.
4 | # FIRST AUTHOR , 2018.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: WebGrid 0.1.36\n"
9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10 | "POT-Creation-Date: 2018-08-09 19:02-0400\n"
11 | "PO-Revision-Date: 2018-08-09 19:02-0400\n"
12 | "Last-Translator: FULL NAME \n"
13 | "Language: es\n"
14 | "Language-Team: es \n"
15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=utf-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Generated-By: Babel 2.6.0\n"
20 |
21 | #: webgrid_blazeweb_ta/views.py:15
22 | msgid "Currency"
23 | msgstr "Moneda"
24 |
25 | #: webgrid_blazeweb_ta/views.py:16
26 | msgid "C2"
27 | msgstr "C2"
28 |
29 | #: webgrid_blazeweb_ta/templates/manage_people.html:3
30 | #: webgrid_blazeweb_ta/templates/manage_people.html:5
31 | msgid "Manage People"
32 | msgstr "Gestionar Personas"
33 |
--------------------------------------------------------------------------------
/docs/source/grid/types.rst:
--------------------------------------------------------------------------------
1 | Types
2 | =====
3 |
4 | Types are defined for grid JSON input/output. These can be mirrored on the consumer
5 | side for API integrity (e.g. in TypeScript).
6 |
7 | Typically, in API usage, the consumer app will be building/maintaining a ``GridSettings``
8 | object to send to the API, and accepting a ``Grid`` in response.
9 |
10 | Settings Types
11 | ##############
12 |
13 | .. autoclass:: webgrid.types.Filter
14 | :members:
15 |
16 | .. autoclass:: webgrid.types.Paging
17 | :members:
18 |
19 | .. autoclass:: webgrid.types.Sort
20 | :members:
21 |
22 | .. autoclass:: webgrid.types.GridSettings
23 | :members:
24 |
25 | Grid Types
26 | ##########
27 |
28 | .. autoclass:: webgrid.types.ColumnGroup
29 | :members:
30 |
31 | .. autoclass:: webgrid.types.FilterOperator
32 | :members:
33 |
34 | .. autoclass:: webgrid.types.FilterOption
35 | :members:
36 |
37 | .. autoclass:: webgrid.types.FilterSpec
38 | :members:
39 |
40 | .. autoclass:: webgrid.types.GridSpec
41 | :members:
42 |
43 | .. autoclass:: webgrid.types.GridState
44 | :members:
45 |
46 | .. autoclass:: webgrid.types.Grid
47 | :members:
48 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/extensions.py:
--------------------------------------------------------------------------------
1 | MORPHI_PACKAGE_NAME = 'webgrid_ta'
2 |
3 | # begin morphi boilerplate
4 | try:
5 | import morphi
6 | except ImportError:
7 | morphi = None
8 |
9 | if morphi:
10 | from morphi.messages import Manager
11 | from morphi.registry import default_registry
12 |
13 | translation_manager = Manager(package_name=MORPHI_PACKAGE_NAME)
14 | default_registry.subscribe(translation_manager)
15 |
16 | gettext = translation_manager.gettext
17 | lazy_gettext = translation_manager.lazy_gettext
18 | lazy_ngettext = translation_manager.lazy_ngettext
19 | ngettext = translation_manager.ngettext
20 |
21 | else:
22 | translation_manager = None
23 |
24 | def gettext(message, **variables):
25 | if variables:
26 | return message.format(**variables)
27 |
28 | return message
29 |
30 | def ngettext(singular, plural, num, **variables):
31 | variables.setdefault('num', num)
32 |
33 | if num == 1:
34 | return gettext(singular, **variables)
35 |
36 | return gettext(plural, **variables)
37 |
38 | lazy_gettext = gettext
39 | lazy_ngettext = ngettext
40 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/conftest.py:
--------------------------------------------------------------------------------
1 | from os import environ
2 | import sys
3 |
4 |
5 | # Default URLs works for Docker compose and CI
6 | db_kind = environ.get('WEBTEST_DB', 'pg')
7 |
8 | if db_kind == 'pg':
9 | db_port = environ.get('DC_POSTGRES_PORT', '5432')
10 | default_url = f'postgresql+psycopg://postgres@127.0.0.1:{db_port}/postgres'
11 | elif db_kind == 'mssql':
12 | # https://learn.microsoft.com/en-us/sql/connect/odbc/linux-mac/installing-the-microsoft-odbc-driver-for-sql-server
13 | db_port = environ.get('DC_MSSQL_PORT', '1433')
14 | default_url = f'mssql+pyodbc://SA:Docker-sa-password@127.0.0.1:{db_port}/tempdb?driver=ODBC+Driver+18+for+SQL+Server&trustservercertificate=yes'
15 | else:
16 | assert db_kind == 'sqlite'
17 | default_url = 'sqlite:///'
18 |
19 | db_url = environ.get('SQLALCHEMY_DATABASE_URI', default_url)
20 |
21 | print('Webgrid sys.path', '\n'.join(sys.path))
22 |
23 |
24 | def pytest_configure(config):
25 | from webgrid_ta.app import create_app
26 |
27 | app = create_app(config='Test', database_url=db_url)
28 | app.app_context().push()
29 |
30 | from webgrid_ta.model import load_db
31 |
32 | load_db()
33 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/extensions.py:
--------------------------------------------------------------------------------
1 | MORPHI_PACKAGE_NAME = 'webgrid_blazeweb_ta'
2 |
3 | # begin morphi boilerplate
4 | try:
5 | import morphi
6 | except ImportError:
7 | morphi = None
8 |
9 | if morphi:
10 | from morphi.messages import Manager
11 | from morphi.registry import default_registry
12 |
13 | translation_manager = Manager(package_name=MORPHI_PACKAGE_NAME)
14 | default_registry.subscribe(translation_manager)
15 |
16 | gettext = translation_manager.gettext
17 | lazy_gettext = translation_manager.lazy_gettext
18 | lazy_ngettext = translation_manager.lazy_ngettext
19 | ngettext = translation_manager.ngettext
20 |
21 | else:
22 | translation_manager = None
23 |
24 | def gettext(message, **variables):
25 | if variables:
26 | return message.format(**variables)
27 |
28 | return message
29 |
30 | def ngettext(singular, plural, num, **variables):
31 | variables.setdefault('num', num)
32 |
33 | if num == 1:
34 | return gettext(singular, **variables)
35 |
36 | return gettext(plural, **variables)
37 |
38 | lazy_gettext = gettext
39 | lazy_ngettext = ngettext
40 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/views.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, render_template
2 |
3 | from webgrid_ta.extensions import gettext as _
4 |
5 |
6 | main = Blueprint('main', __name__)
7 |
8 |
9 | @main.route('/')
10 | def index():
11 | from webgrid import NumericColumn
12 | from webgrid_ta.grids import PeopleGrid as PGBase
13 | from webgrid_ta.model.entities import Person
14 |
15 | class CurrencyCol(NumericColumn):
16 | def format_data(self, data):
17 | if data is None:
18 | return data
19 | return data if int(data) % 2 else data * -1
20 |
21 | class PeopleGrid(PGBase):
22 | CurrencyCol(_('Currency'), Person.numericcol, format_as='percent', places=5)
23 | CurrencyCol(_('C2'), Person.numericcol.label('n2'), format_as='accounting')
24 |
25 | pg = PeopleGrid(class_='datagrid')
26 | pg.apply_qs_args()
27 | if pg.export_to:
28 | pg.export_as_response()
29 | return render_template('index.html', people_grid=pg)
30 |
31 |
32 | @main.route('/groups')
33 | def grid_with_groups():
34 | from webgrid_ta.grids import StopwatchGrid
35 |
36 | grid = StopwatchGrid()
37 | return render_template('groups.html', grid=grid)
38 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/tasks/init_db.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from decimal import Decimal as D
3 |
4 | from blazeweb.tasks import attributes
5 | from sqlalchemybwc import db
6 |
7 | from webgrid_blazeweb_ta.model.orm import Email, Person, Status
8 |
9 |
10 | @attributes('~dev')
11 | def action_40_base_data():
12 | stat_open = Status.add_iu(label='open')
13 | stat_pending = Status.add_iu(label='pending')
14 | stat_closed = Status.add_iu(label='closed', flag_closed=1)
15 |
16 | for x in range(1, 50):
17 | p = Person()
18 | p.firstname = f'fn{x:03d}'
19 | p.lastname = f'ln{x:03d}'
20 | p.sortorder = x
21 | p.numericcol = D('29.26') * x / D('.9')
22 | if x < 90:
23 | p.createdts = dt.datetime.now()
24 | db.sess.add(p)
25 | p.emails.append(Email(email=f'email{x:03d}@example.com'))
26 | p.emails.append(Email(email=f'email{x:03d}@gmail.com'))
27 | if x % 4 == 1:
28 | p.status = stat_open
29 | elif x % 4 == 2:
30 | p.status = stat_pending
31 | elif x % 4 == 0:
32 | p.status = None
33 | else:
34 | p.status = stat_closed
35 |
36 | db.sess.commit()
37 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | repos:
3 | - repo: https://github.com/pre-commit/pre-commit-hooks
4 | rev: v5.0.0
5 | hooks:
6 | - id: check-merge-conflict
7 | - id: check-ast
8 | types: [ python ]
9 | - id: debug-statements
10 | types: [ python ]
11 | - id: trailing-whitespace
12 | - id: end-of-file-fixer
13 | - id: check-added-large-files
14 | - id: check-yaml
15 | - repo: https://github.com/charliermarsh/ruff-pre-commit
16 | rev: v0.12.7
17 | hooks:
18 | # i.e. `ruff check`
19 | - id: ruff
20 | # i.e. `ruff format --check`
21 | - id: ruff-format
22 | # Due to the Ruff config we use (see comment in pyproject.yaml), it's possible that the
23 | # formatter creates linting failures. By only doing a check here, it forces the dev to run
24 | # the formatter before pre-commit runs (presumably on save through their editor) and then
25 | # the linting check above would always catch a problem created by the formatter.
26 | args: [ --check ]
27 | - repo: https://github.com/level12/pre-commit-hooks
28 | rev: v0.20250226.1
29 | hooks:
30 | - id: check-ruff-versions
31 | - repo: https://github.com/astral-sh/uv-pre-commit
32 | rev: 0.8.4
33 | hooks:
34 | - id: uv-lock
35 |
--------------------------------------------------------------------------------
/src/webgrid/templates/grid_header.html:
--------------------------------------------------------------------------------
1 | {%- if _ is not defined -%}
2 | {% macro _(message) -%}
3 | {{ message }}
4 | {%- endmacro %}
5 | {%- endif -%}
6 | {%- set form_attrs = renderer.header_form_attrs(class='header') -%}
7 |
37 |
--------------------------------------------------------------------------------
/mise.toml:
--------------------------------------------------------------------------------
1 | [env]
2 | DC_MSSQL_PORT = '12001'
3 | DC_POSTGRES_PORT = '12000'
4 | PROJECT_SLUG = '{{ config_root | basename | slugify }}'
5 |
6 | _.python.venv.path = '{% if env.UV_PROJECT_ENVIRONMENT %}{{ env.UV_PROJECT_ENVIRONMENT }}{% else %}.venv{% endif %}'
7 | _.python.venv.create = true
8 |
9 | [tools]
10 | python = ['3.10', '3.11', '3.12', '3.13']
11 |
12 |
13 | [task_config]
14 | includes = [
15 | 'tasks',
16 | ]
17 |
18 |
19 | ################ TASKS #################
20 | [tasks.pytest-cov]
21 | description = 'Full pytest run with html coverage report'
22 | # .coveragerc sets directory to ./tmp/coverage-html
23 | run = 'pytest --cov --cov-report=html --no-cov-on-fail'
24 |
25 |
26 | [tasks.upgrade-deps]
27 | description = 'Upgrade uv and pre-commit dependencies'
28 | run = [
29 | 'uv sync --upgrade',
30 | 'pre-commit autoupdate',
31 | ]
32 |
33 | [tasks.mssql-docker-check]
34 | description = 'Check the mssql service from inside the docker container'
35 | run = '''
36 | 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"
37 | '''
38 |
39 | [tasks.mssql-host-check]
40 | description = 'Check the mssql service from our host'
41 | # Task odbc-driver-install installs sqlcmd
42 | run = '''
43 | /opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P Docker-sa-password -Q "select 'connected' as status"
44 | '''
45 |
46 |
47 | [tasks.publish-test]
48 | description = 'Publish to test.pypi.org'
49 | run = [
50 | 'hatch build --clean',
51 | 'hatch publish -r test tmp/dist/',
52 | ]
53 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/data/basic_table.html:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/manage.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import urllib
3 |
4 | import click
5 | import flask
6 |
7 | from webgrid_ta.app import create_app
8 | from webgrid_ta.extensions import lazy_gettext as _
9 | import webgrid_ta.model as model
10 | from webgrid_ta.model.helpers import clear_db
11 |
12 |
13 | log = logging.getLogger(__name__)
14 |
15 | app = None
16 |
17 |
18 | @click.group()
19 | def main():
20 | """Run the Webgrid Test App CLI."""
21 | app = create_app('Dev')
22 | flask.ctx.AppContext(app).push()
23 |
24 |
25 | @main.command('create-db')
26 | @click.option('--clear', default=False, is_flag=True, help=_('DROP all DB objects first'))
27 | def database_init(clear):
28 | if clear:
29 | clear_db()
30 | print(_('- db cleared'))
31 |
32 | model.load_db()
33 | print(_('- db loaded'))
34 |
35 |
36 | @main.command('list-routes')
37 | def list_routes():
38 | output = []
39 | for rule in flask.current_app.url_map.iter_rules():
40 | methods = ','.join(rule.methods)
41 | line = urllib.parse.unquote(f'{rule.endpoint:50s} {methods:20s} {rule}')
42 | output.append(line)
43 |
44 | for line in sorted(output):
45 | print(line)
46 |
47 |
48 | main.add_command(flask.cli.run_command)
49 |
50 |
51 | @main.command('verify-translations')
52 | def verify_translations():
53 | from pathlib import Path
54 |
55 | from morphi.messages.validation import check_translations
56 |
57 | root_path = Path(__file__).resolve().parent.parent.parent
58 | check_translations(
59 | root_path,
60 | 'webgrid',
61 | )
62 |
63 |
64 | if __name__ == '__main__':
65 | main()
66 |
--------------------------------------------------------------------------------
/license.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014 by Randy Syring and contributors.
2 |
3 | Some rights reserved.
4 |
5 | Redistribution and use in source and binary forms of the software as well
6 | as documentation, with or without modification, are permitted provided
7 | that the following conditions are met:
8 |
9 | * Redistributions of source code must retain the above copyright
10 | notice, this list of conditions and the following disclaimer.
11 |
12 | * Redistributions in binary form must reproduce the above
13 | copyright notice, this list of conditions and the following
14 | disclaimer in the documentation and/or other materials provided
15 | with the distribution.
16 |
17 | * The names of the contributors may not be used to endorse or
18 | promote products derived from this software without specific
19 | prior written permission.
20 |
21 | THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND
22 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT
23 | NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
25 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
26 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
27 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
28 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
29 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
30 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
31 | SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
32 | DAMAGE.
33 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/data/basic_table.html:
--------------------------------------------------------------------------------
1 |
40 |
--------------------------------------------------------------------------------
/appveyor.yml:
--------------------------------------------------------------------------------
1 | # If a branch has a PR, don't build it separately. Avoids queueing two appveyor runs for the same
2 | # commit.
3 | skip_branch_with_pr: true
4 |
5 | environment:
6 | global:
7 | # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the
8 | # /E:ON and /V:ON options are not enabled in the batch script intepreter
9 | # See: http://stackoverflow.com/a/13751649/163740
10 | CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\appveyor\\run_with_env.cmd"
11 |
12 | matrix:
13 | - PYTHON: "C:\\Python36"
14 | PYTHON_ARCH: "32"
15 | TOXENV: py36-base
16 |
17 | - PYTHON: "C:\\Python37"
18 | PYTHON_ARCH: "32"
19 | TOXENV: py37-{base,i18n}
20 |
21 |
22 | install:
23 | # Prepend newly installed Python to the PATH of this build (this cannot be
24 | # done from inside the powershell script as it would require to restart
25 | # the parent CMD process).
26 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
27 |
28 | # Check that we have the expected version and architecture for Python
29 | - python --version
30 | - python -c "import struct; print(struct.calcsize('P') * 8)"
31 | - pip --version
32 |
33 | # Install tox from the wheelhouse
34 | - pip install tox wheel codecov
35 |
36 | # Not a C# project, build stuff at the test step instead.
37 | build: false
38 |
39 | test_script:
40 | - tox
41 |
42 | after_test:
43 | # If tests are successful, create a whl package for the project.
44 | - python setup.py bdist_wheel
45 | - ps: "ls dist"
46 |
47 | on_success:
48 | - codecov --token=f52ea144-6e93-4cda-b927-1f578a6e814c
49 |
50 | artifacts:
51 | # Archive the generated wheel package in the ci.appveyor.com build report.
52 | - path: dist\*
53 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/helpers.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from blazeutils.testing import assert_equal_txt
4 | import flask
5 | from flask_webtest import SessionScope
6 | import sqlalchemy as sa
7 | from werkzeug.datastructures import MultiDict
8 | import wrapt
9 |
10 | from webgrid import Column
11 | from webgrid_ta.model import db
12 |
13 |
14 | cdir = Path(__file__).parent
15 |
16 | db_sess_scope = SessionScope(db)
17 |
18 |
19 | class ModelBase:
20 | @classmethod
21 | def setup_class(cls):
22 | db_sess_scope.push()
23 |
24 | @classmethod
25 | def teardown_class(cls):
26 | db_sess_scope.pop()
27 |
28 |
29 | def eq_html(html, filename):
30 | with cdir.joinpath('data', filename).open('rb') as fh:
31 | file_html = fh.read().decode('ascii')
32 | assert_equal_txt(html, file_html)
33 |
34 |
35 | def _inrequest(*req_args, **req_kwargs):
36 | @wrapt.decorator
37 | def wrapper(wrapped, instance, args, kwargs):
38 | with flask.current_app.test_request_context(*req_args, **req_kwargs):
39 | # replaces request.args wth MultiDict so it is mutable
40 | flask.request.args = MultiDict(flask.request.args)
41 | return wrapped(*args, **kwargs)
42 |
43 | return wrapper
44 |
45 |
46 | def render_in_grid(grid_cls, render_in):
47 | """Class factory which extends an existing grid class
48 | to add a column that is rendered everywhere except "render_in"
49 | """
50 | other_render_types = set(Column._render_in)
51 | other_render_types.remove(render_in)
52 |
53 | class RenderInGrid(grid_cls):
54 | Column('Exclude', sa.literal('Exclude'), render_in=tuple(other_render_types))
55 |
56 | return RenderInGrid
57 |
--------------------------------------------------------------------------------
/docs/source/testing/test-usage.rst:
--------------------------------------------------------------------------------
1 | Test Usage
2 | ==========
3 |
4 | What follows is a brief example of setting up filter/sort/content tests using `GridBase`::
5 |
6 | class TestTemporalGrid(webgrid.testing.GridBase):
7 | grid_cls = TemporalGrid
8 |
9 | sort_tests = (
10 | ('createdts', 'persons.createdts'),
11 | ('due_date', 'persons.due_date'),
12 | ('start_time', 'persons.start_time'),
13 | )
14 |
15 | @property
16 | def filters(self):
17 | # This could be assigned as a class attribute, or made into a method
18 | return (
19 | ('createdts', 'eq', dt.datetime(2018, 1, 1, 5, 30),
20 | "WHERE persons.createdts = '2018-01-01 05:30:00.000000'"),
21 | ('due_date', 'eq', dt.date(2018, 1, 1), "WHERE persons.due_date = '2018-01-01'"),
22 | ('start_time', 'eq', dt.time(1, 30).strftime('%I:%M %p'),
23 | "WHERE persons.start_time = CAST('01:30:00.000000' AS TIME)"),
24 | )
25 |
26 | def setup_method(self, _):
27 | Person.delete_cascaded()
28 | Person.testing_create(
29 | createdts=dt.datetime(2018, 1, 1, 5, 30),
30 | due_date=dt.date(2019, 5, 31),
31 | start_time=dt.time(1, 30),
32 | )
33 |
34 | def test_expected_rows(self):
35 | # Passing a tuple of tuples, since headers can be more than one row (i.e. grouped columns)
36 | self.expect_table_header((('Created', 'Due Date', 'Start Time'), ))
37 |
38 | self.expect_table_contents((('01/01/2018 05:30 AM', '05/31/2019', '01:30 AM'), ))
39 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/data/people_table.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | First Name
5 | Full Name
6 | Active
7 | Emails
8 | Status
9 | Created
10 | Due Date
11 | Number
12 |
13 |
14 |
15 |
16 | fn004
17 | fn004 ln004
18 | Yes
19 | email004@example.com, email004@gmail.com
20 |
21 | 02/22/2012 10:04 AM
22 | 02/04/2012
23 | 2.13
24 |
25 |
26 | fn002
27 | fn002 ln002
28 | Yes
29 | email002@example.com, email002@gmail.com
30 | pending
31 |
32 |
33 | 2.13
34 |
35 |
36 | fn001
37 | fn001 ln001
38 | Yes
39 | email001@example.com, email001@gmail.com
40 | in process
41 | 02/22/2012 10:01 AM
42 | 02/01/2012
43 | 2.13
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/app.py:
--------------------------------------------------------------------------------
1 | import warnings
2 |
3 | import flask
4 | from flask import Flask
5 | from flask_bootstrap import Bootstrap
6 |
7 | from webgrid.flask import WebGrid
8 | from webgrid_ta.extensions import translation_manager
9 |
10 |
11 | try:
12 | from morphi.helpers.jinja import configure_jinja_environment
13 | except ImportError:
14 | configure_jinja_environment = lambda *args, **kwargs: None
15 |
16 | try:
17 | from morphi.registry import default_registry
18 | except ImportError:
19 | from blazeutils.datastructures import BlankObject
20 |
21 | default_registry = BlankObject()
22 |
23 |
24 | # ignore warning about Decimal lossy conversion with SQLite from SA
25 | warnings.filterwarnings('ignore', '.*support Decimal objects natively.*')
26 |
27 | webgrid = WebGrid()
28 |
29 |
30 | def create_app(config='Dev', database_url=None):
31 | app = Flask(__name__)
32 |
33 | app.config['SQLALCHEMY_DATABASE_URI'] = database_url or 'sqlite:///'
34 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
35 | # app.config['SQLALCHEMY_ECHO'] = True
36 | # app.config['DEBUG'] = True
37 | if config == 'Test':
38 | app.config['TEST'] = True
39 | app.secret_key = 'only-testing'
40 |
41 | from webgrid_ta.model import db
42 |
43 | db.init_app(app)
44 | webgrid.init_db(db)
45 | default_registry.locales = app.config.get('DEFAULT_LOCALE', 'en')
46 | configure_jinja_environment(app.jinja_env, translation_manager)
47 | Bootstrap(app)
48 | webgrid.init_app(app)
49 |
50 | from webgrid_ta.views import main
51 |
52 | app.register_blueprint(main)
53 |
54 | @app.before_request
55 | def set_language():
56 | default_registry.locales = str(flask.request.accept_languages)
57 | configure_jinja_environment(app.jinja_env, translation_manager)
58 |
59 | return app
60 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/model/__init__.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from decimal import Decimal as D
3 |
4 | from flask_sqlalchemy import SQLAlchemy
5 |
6 |
7 | db = SQLAlchemy()
8 |
9 |
10 | def load_db():
11 | from webgrid_ta.model.entities import Email, Person, Status, Stopwatch
12 |
13 | db.create_all()
14 |
15 | stat_open = Status.add_iu(label='open')
16 | stat_pending = Status.add_iu(label='pending')
17 | stat_closed = Status.add_iu(label='closed', flag_closed=1)
18 |
19 | for x in range(1, 50):
20 | p = Person()
21 | p.firstname = f'fn{x:03d}'
22 | p.lastname = f'ln{x:03d}'
23 | p.sortorder = x
24 | p.numericcol = D('29.26') * x / D('.9')
25 | if x < 90:
26 | p.createdts = dt.datetime.now()
27 | db.session.add(p)
28 | p.emails.append(Email(email=f'email{x:03d}@example.com'))
29 | p.emails.append(Email(email=f'email{x:03d}@gmail.com'))
30 | if x % 4 == 1:
31 | p.status = stat_open
32 | elif x % 4 == 2:
33 | p.status = stat_pending
34 | elif x % 4 == 0:
35 | p.status = None
36 | else:
37 | p.status = stat_closed
38 |
39 | for x in range(1, 10):
40 | s = Stopwatch()
41 | s.label = f'Watch {x}'
42 | s.category = 'Sports'
43 | base_date = dt.datetime(year=2019, month=1, day=1)
44 | s.start_time_lap1 = base_date + dt.timedelta(hours=x)
45 | s.stop_time_lap1 = base_date + dt.timedelta(hours=x + 1)
46 | s.start_time_lap2 = base_date + dt.timedelta(hours=x + 2)
47 | s.stop_time_lap2 = base_date + dt.timedelta(hours=x + 3)
48 | s.start_time_lap3 = base_date + dt.timedelta(hours=x + 4)
49 | s.stop_time_lap3 = base_date + dt.timedelta(hours=x + 5)
50 | db.session.add(s)
51 |
52 | db.session.commit()
53 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | """
2 | This conftest mostly for handling warnings. Use the other conftest.py files for app/test config.
3 |
4 | - Filters for warnings that are triggered during import go at the top level.
5 | - Filters for warnings thrown during test runs goes in pytest_configure() below.
6 |
7 | Having two conftest.py files is necessary because the warning configuration needs to happen before
8 | the application's tests and/or code have a chance to import other libraries which may trigger
9 | warnings. So this file remains a filesystem level above the "real" conftest.py which does all the
10 | imports.
11 | """
12 |
13 | import warnings
14 |
15 |
16 | # Treat any warning issued in a test as an exception so we are forced to explicitly handle or
17 | # ignore it.
18 | warnings.filterwarnings('error')
19 | # Examples:
20 | # warnings.filterwarnings(
21 | # 'ignore',
22 | # "'cgi' is deprecated and slated for removal in Python 3.13",
23 | # category=DeprecationWarning,
24 | # module='webob.compat',
25 | # )
26 | # warnings.filterwarnings(
27 | # 'ignore',
28 | # "'crypt' is deprecated and slated for removal in Python 3.13",
29 | # category=DeprecationWarning,
30 | # module='passlib.utils',
31 | # )
32 | ###########
33 | # REMINDER: when adding an ignore, add an issue to track it
34 | ###########
35 |
36 |
37 | def pytest_configure(config):
38 | """
39 | You may be able to do all your ignores above. If you find some warnings need to be ignored
40 | in pytest, you can do that with something like:
41 |
42 | config.addinivalue_line(
43 | 'filterwarnings',
44 | # Note the lines that follow are implicitly concatinated, no "," at the end
45 | 'ignore'
46 | ':pythonjsonlogger.jsonlogger has been moved to pythonjsonlogger.json'
47 | ':DeprecationWarning'
48 | ':wtforms.meta',
49 | )
50 | """
51 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/templates/index.html:
--------------------------------------------------------------------------------
1 | {% extends "bootstrap/base.html" %}
2 | {%- if _ is not defined -%}
3 | {% macro _(message) -%}
4 | {{ message }}
5 | {%- endmacro %}
6 | {%- endif -%}
7 |
8 | {% block title %}{{ _('Manage People Grid') }}{% endblock %}
9 |
10 |
11 | {% block styles %}
12 | {{ super() }}
13 |
14 |
15 | {% endblock %}
16 |
17 | {% block content %}
18 |
19 |
{{ _('Manage People') }}
20 | {{ people_grid.html()|safe}}
21 |
22 | {% endblock %}
23 |
24 | {% block scripts %}
25 | {{super()}}
26 |
27 |
48 |
49 |
50 | {% endblock %}
51 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/config/settings.py:
--------------------------------------------------------------------------------
1 | from os import path
2 | import warnings
3 |
4 | from blazeweb.config import DefaultSettings
5 |
6 |
7 | basedir = path.dirname(path.dirname(__file__)) # noqa: PTH120
8 | app_package = path.basename(basedir) # noqa: PTH119
9 |
10 |
11 | class Default(DefaultSettings):
12 | def init(self):
13 | self.dirs.base = basedir
14 | self.app_package = app_package
15 | DefaultSettings.init(self)
16 |
17 | self.init_routing()
18 |
19 | self.add_component(app_package, 'sqlalchemy', 'sqlalchemybwc')
20 | self.add_component(app_package, 'webgrid', 'webgrid')
21 | self.add_component(app_package, 'templating', 'templatingbwc')
22 |
23 | self.name.full = 'WebGrid'
24 | self.name.short = 'WebGrid'
25 |
26 | # ignore the really big SA warning about lossy numeric types & sqlite
27 | warnings.filterwarnings('ignore', '.*support Decimal objects natively.*')
28 |
29 | def init_routing(self):
30 | self.add_route('/people/manage', endpoint='ManagePeople')
31 |
32 |
33 | class Dev(Default):
34 | def init(self):
35 | Default.init(self)
36 | self.apply_dev_settings()
37 |
38 | self.db.url = 'sqlite://'
39 |
40 | # uncomment this if you want to use a database you can inspect
41 | # from os import path
42 | # self.db.url = 'sqlite:///%s' % path.join(self.dirs.data, 'test_application.db')
43 |
44 |
45 | class Test(Default):
46 | def init(self):
47 | Default.init(self)
48 | self.apply_test_settings()
49 |
50 | self.db.url = 'sqlite://'
51 |
52 | # uncomment this if you want to use a database you can inspect
53 | # from os import path
54 | # self.db.url = 'sqlite:///%s' % path.join(self.dirs.data, 'test_application.db')
55 |
56 |
57 | try:
58 | from site_settings import * # noqa
59 | except ImportError as e:
60 | if "No module named 'site_settings'" not in str(e):
61 | raise
62 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/data/people_table.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | First Name
5 | Full Name
6 | Active
7 | Emails
8 | Status
9 | Created
10 | Due Date
11 | Number
12 | Account Type
13 |
14 |
15 |
16 |
17 | fn004
18 | fn004 ln004
19 | Yes
20 | email004@example.com, email004@gmail.com
21 |
22 | 02/22/2012 10:04 AM
23 | 02/04/2012
24 | 2.13
25 |
26 |
27 |
28 | fn002
29 | fn002 ln002
30 | Yes
31 | email002@example.com, email002@gmail.com
32 | pending
33 |
34 |
35 | 2.13
36 | Employee
37 |
38 |
39 | fn001
40 | fn001 ln001
41 | Yes
42 | email001@example.com, email001@gmail.com
43 | in process
44 | 02/22/2012 10:01 AM
45 | 02/01/2012
46 | 2.13
47 | Admin
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/webgrid/utils.py:
--------------------------------------------------------------------------------
1 | def current_url(
2 | manager,
3 | root_only=False,
4 | host_only=False,
5 | strip_querystring=False,
6 | strip_host=False,
7 | https=None,
8 | ):
9 | """
10 | Returns strings based on the current URL. Assume a request with path:
11 |
12 | /news/list?param=foo
13 |
14 | to an application mounted at:
15 |
16 | http://localhost:8080/script
17 |
18 | Then:
19 | :param root_only: set `True` if you only want the root URL.
20 | http://localhost:8080/script/
21 | :param host_only: set `True` if you only want the scheme, host, & port.
22 | http://localhost:8080/
23 | :param strip_querystring: set to `True` if you don't want the querystring.
24 | http://localhost:8080/script/news/list
25 | :param strip_host: set to `True` you want to remove the scheme, host, & port:
26 | /script/news/list?param=foo
27 | :param https: None = use schem of current environ; True = force https
28 | scheme; False = force http scheme. Has no effect if strip_host = True.
29 | :param environ: the WSGI environment to get the current URL from. If not
30 | given, the environement from the current request will be used. This
31 | is mostly for use in our unit tests and probably wouldn't have
32 | much application in normal use.
33 | """
34 | retval = ''
35 |
36 | ro = manager.request()
37 |
38 | if root_only:
39 | retval = ro.url_root
40 | elif host_only:
41 | retval = ro.host_url
42 | else:
43 | retval = ro.base_url if strip_querystring else ro.url
44 | if strip_host:
45 | retval = retval.replace(ro.host_url.rstrip('/'), '', 1)
46 | if not strip_host and https is not None:
47 | if https and retval.startswith('http://'):
48 | retval = retval.replace('http://', 'https://', 1)
49 | elif not https and retval.startswith('https://'):
50 | retval = retval.replace('https://', 'http://', 1)
51 |
52 | return retval
53 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/helpers.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from blazeutils.testing import assert_equal_txt
4 | import sqlalchemy.orm
5 |
6 |
7 | cdir = Path(__file__).parent
8 |
9 |
10 | def query_to_str(statement, bind=None):
11 | """
12 | returns a string of a sqlalchemy.orm.Query with parameters bound
13 |
14 | WARNING: this is dangerous and ONLY for testing, executing the results
15 | of this function can result in an SQL Injection attack.
16 | """
17 | if isinstance(statement, sqlalchemy.orm.Query):
18 | if bind is None:
19 | bind = statement.session.get_bind()
20 | statement = statement.statement
21 | elif bind is None:
22 | bind = statement.bind
23 |
24 | if bind is None:
25 | raise Exception(
26 | 'bind param (engine or connection object) required when using with an unbound statement', # noqa: E501
27 | )
28 |
29 | dialect = bind.dialect
30 | compiler = statement._compiler(dialect)
31 |
32 | class LiteralCompiler(compiler.__class__):
33 | def visit_bindparam(
34 | self,
35 | bindparam,
36 | within_columns_clause=False,
37 | literal_binds=False,
38 | **kwargs,
39 | ):
40 | return super().render_literal_bindparam(
41 | bindparam,
42 | within_columns_clause=within_columns_clause,
43 | literal_binds=literal_binds,
44 | **kwargs,
45 | )
46 |
47 | compiler = LiteralCompiler(dialect, statement)
48 | return 'TESTING ONLY BIND: ' + compiler.process(statement)
49 |
50 |
51 | def eq_html(html, filename):
52 | with cdir.joinpath('data', filename).open('rb') as fh:
53 | file_html = fh.read()
54 | assert_equal_txt(html, file_html)
55 |
56 |
57 | def assert_in_query(obj, test_for):
58 | query = obj.build_query() if hasattr(obj, 'build_query') else obj
59 | query_str = query_to_str(query)
60 | assert test_for in query_str, query_str
61 |
62 |
63 | def assert_not_in_query(obj, test_for):
64 | query = obj.build_query() if hasattr(obj, 'build_query') else obj
65 | query_str = query_to_str(query)
66 | assert test_for not in query_str, query_str
67 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/tests/grids.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from webgrid import Column, DateColumn, DateTimeColumn, LinkColumnBase, NumericColumn, YesNoColumn
4 | from webgrid.blazeweb import Grid
5 | from webgrid.filters import DateTimeFilter, Operator, OptionsFilterBase, TextFilter, ops
6 | from webgrid.renderers import CSV, XLS, XLSX
7 | from webgrid_blazeweb_ta.model.orm import Person, Status
8 |
9 |
10 | class FirstNameColumn(LinkColumnBase):
11 | def create_url(self, record):
12 | return f'/person-edit/{record.id}'
13 |
14 |
15 | class FullNameColumn(LinkColumnBase):
16 | def extract_data(self, record):
17 | return f'{record.firstname} {record.lastname}'
18 |
19 | def create_url(self, record):
20 | return f'/person-edit/{record.id}'
21 |
22 |
23 | class EmailsColumn(Column):
24 | def extract_data(self, recordset):
25 | return ', '.join([e.email for e in recordset.Person.emails])
26 |
27 |
28 | class StatusFilter(OptionsFilterBase):
29 | operators = (
30 | Operator('o', 'open', None),
31 | ops.is_,
32 | ops.not_is,
33 | Operator('c', 'closed', None),
34 | ops.empty,
35 | ops.not_empty,
36 | )
37 | options_from = Status.pairs
38 |
39 |
40 | class PeopleGrid(Grid):
41 | session_on = True
42 | allowed_export_targets: ClassVar = {'csv': CSV, 'xls': XLS, 'xlsx': XLSX}
43 | FirstNameColumn('First Name', Person.firstname, TextFilter)
44 | FullNameColumn('Full Name')
45 | YesNoColumn('Active', Person.inactive, reverse=True)
46 | EmailsColumn('Emails')
47 | Column('Status', Status.label.label('status'), StatusFilter(Status.id))
48 | DateTimeColumn('Created', Person.createdts, DateTimeFilter)
49 | DateColumn('Due Date', 'due_date')
50 | NumericColumn('Number', Person.numericcol, has_subtotal=True)
51 |
52 | def query_prep(self, query, has_sort, has_filters):
53 | query = (
54 | query.add_columns(Person.id, Person.lastname, Person.due_date)
55 | .add_entity(Person)
56 | .outerjoin(Person.status)
57 | )
58 |
59 | # default sort
60 | if not has_sort:
61 | query = query.order_by(Person.id)
62 |
63 | return query
64 |
--------------------------------------------------------------------------------
/src/webgrid/templates/grid_footer.html:
--------------------------------------------------------------------------------
1 | {%- if _ is not defined -%}
2 | {% macro _(message) -%}
3 | {{ message }}
4 | {%- endmacro %}
5 | {%- endif -%}
6 |
7 |
50 |
--------------------------------------------------------------------------------
/docs/source/filters/custom-filters.rst:
--------------------------------------------------------------------------------
1 | .. _custom-filters:
2 |
3 | Custom Filters
4 | ==============
5 |
6 | The basic requirements for a custom filter are to supply the operators, the query modifier
7 | for applying the filter, and a search expression for single-search. A few examples are
8 | here.
9 |
10 | Simple custom filter::
11 |
12 | class ActivityStatusFilter(FilterBase):
13 | # operators are declared as Operator(, , )
14 | operators = (
15 | Operator('all', 'all', None),
16 | Operator('pend', 'pending', None),
17 | Operator('comp', 'completed', None),
18 | )
19 |
20 | def get_search_expr(self):
21 | status_col = sa.sql.case(
22 | [(Activity.flag_completed == sa.true(), 'completed')],
23 | else_='pending'
24 | )
25 | # Could use ilike here, depending on the target DBMS
26 | return lambda value: status_col.like('%{}%'.format(value))
27 |
28 | def apply(self, query):
29 | if self.op == 'all':
30 | return query
31 | if self.op == 'comp':
32 | return query.filter(Activity.flag_completed == sa.true())
33 | if self.op == 'pend':
34 | return query.filter(Activity.flag_completed == sa.false())
35 | return super().apply(self, query)
36 |
37 |
38 | Options filter for INT foreign key lookup::
39 |
40 | class VendorFilter(OptionsIntFilterBase):
41 | def options_from(self):
42 | # Expected to return a list of tuples (id, label).
43 | # In this case, we're retrieving options from the database.
44 | return db.session.query(Vendor.id, Vendor.label).select_from(
45 | Vendor
46 | ).filter(
47 | Vendor.active_flag == sa.true()
48 | ).order_by(
49 | Vendor.label
50 | ).all()
51 |
52 |
53 | Aggregate filters, i.e. those using the HAVING clause instead of WHERE, must be marked with the
54 | `is_aggregate` flag. Single-search via expressions will only address aggregate filters if all
55 | search filters are aggregate. Using an aggregate filter will require a GROUP BY clause be set.
56 |
57 | class AggregateTextFilter(TextFilter):
58 | is_aggregate = True
59 |
--------------------------------------------------------------------------------
/docs/source/columns/custom-columns.rst:
--------------------------------------------------------------------------------
1 | .. _custom-columns:
2 |
3 | Custom Columns
4 | ==============
5 |
6 | The basic WebGrid Column is flexible enough to handle a great deal of data. With the other
7 | supplied built-in columns for specific data types (boolean, date, float/decimal, int, etc.),
8 | the most common scenarios are covered. However, any of these supplied column classes may
9 | be extended for application-specific scenarios.
10 |
11 | Below are some examples of common customizations on grid columns.
12 |
13 |
14 | Rendered value::
15 |
16 | class AgeColumn(Column):
17 | def extract_data(self, record):
18 | # All rendered targets will show this output instead of the actual data value
19 | if record.age < 18:
20 | return 'Under 18'
21 | return 'Over 18'
22 |
23 |
24 | Render specialized for single target::
25 |
26 | class AgeColumn(Column):
27 | def render_html(self, record, hah):
28 | # Only the HTML output will show this instead of the actual data value.
29 | if record.age < 18:
30 | # Add a CSS class to this cell for further styling.
31 | hah.class_ = 'under-18'
32 | return 'Under 18'
33 | return 'Over 18'
34 |
35 |
36 | Sorting algorithm::
37 |
38 | class ShipmentReceived(Column):
39 | def apply_sort(self, query, flag_desc):
40 | # Always sort prioritized shipments first
41 | if flag_desc:
42 | return query.order_by(
43 | priority_col.asc(),
44 | self.expr.desc(),
45 | )
46 | return query.order_by(
47 | priority_col.asc(),
48 | self.expr.asc(),
49 | )
50 |
51 |
52 | XLSX formula::
53 |
54 | class ConditionalFormulaColumn(Column):
55 | xlsx_formula = '=IF(AND(K{0}<>"",C{0}<>""),(K{0}-C{0})*24,"")'
56 |
57 | def render_xlsx(self, record, rownum=0):
58 | return self.xlsx_formula.format(rownum)
59 |
60 |
61 | Value links to another view::
62 |
63 | class ProjectColumn(LinkColumnBase):
64 | def create_url(self, record):
65 | return flask.url_for(
66 | 'admin.project-view',
67 | objid=record.id,
68 | )
69 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/i18n/webgrid_ta.pot:
--------------------------------------------------------------------------------
1 | # Translations template for WebGrid.
2 | # Copyright (C) 2018 ORGANIZATION
3 | # This file is distributed under the same license as the WebGrid project.
4 | # FIRST AUTHOR , 2018.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: WebGrid 0.1.37\n"
10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11 | "POT-Creation-Date: 2018-08-09 19:32-0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=utf-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Generated-By: Babel 2.6.0\n"
19 |
20 | #: webgrid_ta/grids.py:24
21 | msgid "{record.firstname} {record.lastname}"
22 | msgstr ""
23 |
24 | #: webgrid_ta/grids.py:37
25 | msgid "open"
26 | msgstr ""
27 |
28 | #: webgrid_ta/grids.py:40
29 | msgid "closed"
30 | msgstr ""
31 |
32 | #: webgrid_ta/grids.py:50 webgrid_ta/grids.py:76
33 | msgid "First Name"
34 | msgstr ""
35 |
36 | #: webgrid_ta/grids.py:51
37 | msgid "Full Name"
38 | msgstr ""
39 |
40 | #: webgrid_ta/grids.py:52
41 | msgid "Active"
42 | msgstr ""
43 |
44 | #: webgrid_ta/grids.py:53
45 | msgid "Emails"
46 | msgstr ""
47 |
48 | #: webgrid_ta/grids.py:54
49 | msgid "Status"
50 | msgstr ""
51 |
52 | #: webgrid_ta/grids.py:55 webgrid_ta/grids.py:83 webgrid_ta/grids.py:96
53 | msgid "Created"
54 | msgstr ""
55 |
56 | #: webgrid_ta/grids.py:56
57 | msgid "Due Date"
58 | msgstr ""
59 |
60 | #: webgrid_ta/grids.py:57
61 | msgid "Sort Order"
62 | msgstr ""
63 |
64 | #: webgrid_ta/grids.py:58
65 | msgid "State"
66 | msgstr ""
67 |
68 | #: webgrid_ta/grids.py:59
69 | msgid "Number"
70 | msgstr ""
71 |
72 | #: webgrid_ta/manage.py:22
73 | msgid "DROP all DB objects first"
74 | msgstr ""
75 |
76 | #: webgrid_ta/manage.py:28
77 | msgid "- db cleared"
78 | msgstr ""
79 |
80 | #: webgrid_ta/manage.py:31
81 | msgid "- db loaded"
82 | msgstr ""
83 |
84 | #: webgrid_ta/manage.py:36
85 | msgid "flask configuration to use"
86 | msgstr ""
87 |
88 | #: webgrid_ta/views.py:21
89 | msgid "Currency"
90 | msgstr ""
91 |
92 | #: webgrid_ta/views.py:22
93 | msgid "C2"
94 | msgstr ""
95 |
96 | #: webgrid_ta/templates/index.html:3
97 | msgid "Manage People Grid"
98 | msgstr ""
99 |
100 | #: webgrid_ta/templates/index.html:14
101 | msgid "Manage People"
102 | msgstr ""
103 |
--------------------------------------------------------------------------------
/tests/webgrid_blazeweb_ta/model/orm.py:
--------------------------------------------------------------------------------
1 | from blazeutils.strings import randchars
2 | import sqlalchemy as sa
3 | import sqlalchemy.orm as saorm
4 | from sqlalchemybwc import db
5 | from sqlalchemybwc.lib.declarative import DefaultMixin, declarative_base
6 |
7 |
8 | Base = declarative_base()
9 |
10 |
11 | class Person(Base, DefaultMixin):
12 | __tablename__ = 'persons'
13 |
14 | id = sa.Column(sa.Integer, primary_key=True)
15 | firstname = sa.Column(sa.String(50))
16 | lastname = sa.Column('last_name', sa.String(50))
17 | inactive = sa.Column(sa.SmallInteger)
18 | state = sa.Column(sa.String(50))
19 | status_id = sa.Column(sa.Integer, sa.ForeignKey('statuses.id'))
20 | address = sa.Column(sa.Integer)
21 | createdts = sa.Column(sa.DateTime)
22 | sortorder = sa.Column(sa.Integer)
23 | floatcol = sa.Column(sa.Float)
24 | numericcol = sa.Column(sa.Numeric)
25 | boolcol = sa.Column(sa.Boolean)
26 | due_date = sa.Column(sa.Date)
27 | legacycol1 = sa.Column('LegacyColumn1', sa.String(50), key='legacycolumn')
28 | legacycol2 = sa.Column('LegacyColumn2', sa.String(50))
29 |
30 | status = saorm.relationship('Status')
31 |
32 | def __repr__(self):
33 | return f''
34 |
35 | @classmethod
36 | def testing_create(cls, firstname=None):
37 | firstname = firstname or randchars()
38 | return cls.add(firstname=firstname)
39 |
40 | @classmethod
41 | def delete_cascaded(cls):
42 | Email.delete_all()
43 | cls.delete_all()
44 |
45 |
46 | class Email(Base, DefaultMixin):
47 | __tablename__ = 'emails'
48 |
49 | id = sa.Column(sa.Integer, primary_key=True)
50 | person_id = sa.Column(sa.Integer, sa.ForeignKey(Person.id), nullable=False)
51 | email = sa.Column(sa.String(50), nullable=False)
52 |
53 | person = saorm.relationship(Person, backref='emails')
54 |
55 |
56 | class Status(Base, DefaultMixin):
57 | __tablename__ = 'statuses'
58 |
59 | id = sa.Column(sa.Integer, primary_key=True)
60 | label = sa.Column(sa.String(50), nullable=False, unique=True)
61 | flag_closed = sa.Column(sa.Integer, default=0)
62 |
63 | @classmethod
64 | def pairs(cls):
65 | return db.sess.query(cls.id, cls.label).order_by(cls.label)
66 |
67 | @classmethod
68 | def delete_cascaded(cls):
69 | Person.delete_cascaded()
70 | cls.delete_all()
71 |
72 | @classmethod
73 | def testing_create(cls, label=None):
74 | label = label or randchars()
75 | return cls.add(label=label)
76 |
--------------------------------------------------------------------------------
/src/webgrid/blazeweb.py:
--------------------------------------------------------------------------------
1 | from blazeweb.content import getcontent
2 | from blazeweb.globals import ag, rg, user
3 | from blazeweb.routing import abs_static_url
4 | from blazeweb.templating.jinja import content_filter
5 | from blazeweb.utils import abort
6 | from blazeweb.wrappers import StreamResponse
7 | from jinja2.exceptions import TemplateNotFound
8 | from sqlalchemybwc import db as sabwc_db
9 |
10 | from webgrid import BaseGrid
11 | from webgrid.extensions import FrameworkManager, gettext
12 | from webgrid.renderers import render_html_attributes
13 |
14 |
15 | class WebGrid(FrameworkManager):
16 | def __init__(self, db=None, component='webgrid'):
17 | super().__init__(db=db or sabwc_db)
18 | self.component = component
19 |
20 | def init(self):
21 | ag.tplengine.env.filters['wg_safe'] = content_filter
22 | ag.tplengine.env.filters['wg_attributes'] = render_html_attributes
23 | ag.tplengine.env.filters['wg_gettext'] = gettext
24 |
25 | def sa_query(self, *args, **kwargs):
26 | return self.db.sess.query(*args, **kwargs)
27 |
28 | def request_form_args(self):
29 | """Return POST request args."""
30 | return rg.request.form
31 |
32 | def request_json(self):
33 | """Return json body of request."""
34 | return rg.request.json
35 |
36 | def request_url_args(self):
37 | """Return GET request args."""
38 | return rg.request.args
39 |
40 | def csrf_token(self):
41 | raise NotImplementedError
42 |
43 | def web_session(self):
44 | return user
45 |
46 | def persist_web_session(self):
47 | pass
48 |
49 | def flash_message(self, category, message):
50 | user.add_message(category, message)
51 |
52 | def request(self):
53 | return rg.request
54 |
55 | def static_url(self, url_tail):
56 | return abs_static_url(f'component/webgrid/{url_tail}')
57 |
58 | def file_as_response(self, data_stream, file_name, mime_type):
59 | rp = StreamResponse(data_stream)
60 | rp.headers['Content-Type'] = mime_type
61 | if file_name is not None:
62 | rp.headers['Content-Disposition'] = f'attachment; filename={file_name}'
63 | abort(rp)
64 |
65 | def render_template(self, endpoint, **kwargs):
66 | try:
67 | return getcontent(endpoint, **kwargs)
68 | except TemplateNotFound:
69 | if ':' in endpoint:
70 | raise
71 | return getcontent(f'{self.component}:{endpoint}', **kwargs)
72 |
73 |
74 | wg_blaze_manager = WebGrid()
75 |
76 |
77 | class Grid(BaseGrid):
78 | manager = wg_blaze_manager
79 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | # import os
14 | # import sys
15 | # sys.path.insert(0, os.path.abspath('.'))
16 |
17 | import datetime as dt
18 |
19 | import webgrid.version
20 |
21 |
22 | # -- Project information -----------------------------------------------------
23 |
24 | project = 'WebGrid'
25 | copyright = f'{dt.datetime.utcnow().year} Level 12'
26 | release = webgrid.version.VERSION
27 |
28 |
29 | # -- General configuration ---------------------------------------------------
30 |
31 | # Add any Sphinx extension module names here, as strings. They can be
32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
33 | # ones.
34 | extensions = [
35 | 'sphinx.ext.intersphinx',
36 | 'sphinx.ext.autodoc',
37 | 'sphinx.ext.viewcode',
38 | ]
39 |
40 | # Add any paths that contain templates here, relative to this directory.
41 | templates_path = ['_templates']
42 |
43 | # List of patterns, relative to source directory, that match files and
44 | # directories to ignore when looking for source files.
45 | # This pattern also affects html_static_path and html_extra_path.
46 | exclude_patterns = []
47 |
48 | master_doc = 'index'
49 |
50 |
51 | # -- Options for HTML output -------------------------------------------------
52 |
53 | # The theme to use for HTML and HTML Help pages. See the documentation for
54 | # a list of builtin themes.
55 | #
56 | html_theme = 'alabaster'
57 |
58 | html_theme_options = {
59 | 'github_user': 'level12',
60 | 'github_repo': 'webgrid',
61 | 'github_banner': False,
62 | 'github_button': True,
63 | 'codecov_button': True,
64 | 'extra_nav_links': {
65 | 'Level 12': 'https://www.level12.io',
66 | 'File an Issue': 'https://github.com/level12/webgrid/issues/new',
67 | },
68 | 'show_powered_by': True,
69 | }
70 |
71 | # Add any paths that contain custom static files (such as style sheets) here,
72 | # relative to this directory. They are copied after the builtin static files,
73 | # so a file named "default.css" will overwrite the builtin "default.css".
74 | html_static_path = ['_static']
75 | html_css_files = ['css/custom.css']
76 |
--------------------------------------------------------------------------------
/tasks/bump:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # mise description="Bump version"
3 | """
4 | Originates from https://github.com/level12/coppy
5 |
6 | Consider updating the source file in that repo with enhancements or bug fixes needed.
7 | """
8 |
9 | import datetime as dt
10 |
11 | import click
12 |
13 | from webgrid_tasks_lib import sub_run
14 |
15 |
16 | def current_version():
17 | result = sub_run('hatch', 'version', capture=True)
18 | return result.stdout.strip()
19 |
20 |
21 | def date_version(current: str):
22 | major, _, patch, *_ = current.split('.')
23 | version = f'{major}.' + dt.date.today().strftime(r'%Y%m%d')
24 |
25 | patch = int(patch) + 1 if current.startswith(version) else 1
26 |
27 | return f'{version}.{patch}'
28 |
29 |
30 | @click.command()
31 | @click.argument('kind', type=click.Choice(('micro', 'minor', 'major', 'date')), default='date')
32 | @click.option('--show', is_flag=True, help="Only show next version, don't bump (date only)")
33 | @click.option('--current', help='Simulate current version (date only)')
34 | @click.option('--push/--no-push', help='Push after bump', default=True)
35 | @click.pass_context
36 | def main(ctx: click.Context, kind: str | None, show: bool, current: str | None, push: bool):
37 | """
38 | Bump the version and (by default) git push including tags.
39 |
40 | Date based versioning is the default. Examples:
41 |
42 | v0.20231231.1
43 | v0.20231231.2
44 | v0.20240101.1
45 |
46 | A normal bump will increment the minor or micro slot. Use a major bump when making breaking
47 | changes in a library, e.g.:
48 |
49 | mise run bump major
50 | Old: 0.20240515.1
51 | New: 1.0.0
52 |
53 | Major, minor, and micro bumps are just passed through to `hatch version` and provided for
54 | completeness. If using date based versioning only `date` and `major` need to be used.
55 |
56 | Assumes your project is using hatch-regex-commit or similar so that a commit & tag are created
57 | automatically after every version bump.
58 | """
59 | if show and kind != 'date':
60 | ctx.fail('--show is only valid with date versioning')
61 | if current and kind != 'date':
62 | ctx.fail('--current is only valid with date versioning')
63 |
64 | if kind == 'date':
65 | current = current or current_version()
66 | version = date_version(current)
67 | if show:
68 | print('Current:', current)
69 | print('Next:', version)
70 | return
71 | else:
72 | version = kind
73 |
74 | sub_run('hatch', 'version', version)
75 | if push:
76 | sub_run('git', 'push', '--follow-tags')
77 |
78 |
79 | if __name__ == '__main__':
80 | main()
81 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/i18n/es/LC_MESSAGES/webgrid_ta.po:
--------------------------------------------------------------------------------
1 | # Spanish translations for WebGrid.
2 | # Copyright (C) 2018 ORGANIZATION
3 | # This file is distributed under the same license as the WebGrid project.
4 | # FIRST AUTHOR , 2018.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: WebGrid 0.1.36\n"
9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10 | "POT-Creation-Date: 2018-08-09 19:32-0400\n"
11 | "PO-Revision-Date: 2018-08-07 19:32-0400\n"
12 | "Last-Translator: FULL NAME \n"
13 | "Language: es\n"
14 | "Language-Team: es \n"
15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=utf-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Generated-By: Babel 2.6.0\n"
20 |
21 | #: webgrid_ta/grids.py:24
22 | msgid "{record.firstname} {record.lastname}"
23 | msgstr "{record.firstname} {record.lastname}"
24 |
25 | #: webgrid_ta/grids.py:37
26 | msgid "open"
27 | msgstr "abierto"
28 |
29 | #: webgrid_ta/grids.py:40
30 | msgid "closed"
31 | msgstr "cerrado"
32 |
33 | #: webgrid_ta/grids.py:50 webgrid_ta/grids.py:76
34 | msgid "First Name"
35 | msgstr "Nombre de Pila"
36 |
37 | #: webgrid_ta/grids.py:51
38 | msgid "Full Name"
39 | msgstr "Nombre Completo"
40 |
41 | #: webgrid_ta/grids.py:52
42 | msgid "Active"
43 | msgstr "Activo"
44 |
45 | #: webgrid_ta/grids.py:53
46 | msgid "Emails"
47 | msgstr "Correos electrónicos"
48 |
49 | #: webgrid_ta/grids.py:54
50 | msgid "Status"
51 | msgstr "Estado"
52 |
53 | #: webgrid_ta/grids.py:55 webgrid_ta/grids.py:83 webgrid_ta/grids.py:96
54 | msgid "Created"
55 | msgstr "Creado"
56 |
57 | #: webgrid_ta/grids.py:56
58 | msgid "Due Date"
59 | msgstr "Fecha de Vencimiento"
60 |
61 | #: webgrid_ta/grids.py:57
62 | msgid "Sort Order"
63 | msgstr "Orden de Clasificación"
64 |
65 | #: webgrid_ta/grids.py:58
66 | msgid "State"
67 | msgstr "Estado"
68 |
69 | #: webgrid_ta/grids.py:59
70 | msgid "Number"
71 | msgstr "Número"
72 |
73 | #: webgrid_ta/manage.py:22
74 | msgid "DROP all DB objects first"
75 | msgstr "DROP todos los objetos DB primero"
76 |
77 | #: webgrid_ta/manage.py:28
78 | msgid "- db cleared"
79 | msgstr "- db limpia"
80 |
81 | #: webgrid_ta/manage.py:31
82 | msgid "- db loaded"
83 | msgstr "- db cargado"
84 |
85 | #: webgrid_ta/manage.py:36
86 | msgid "flask configuration to use"
87 | msgstr "configuración flask para usar"
88 |
89 | #: webgrid_ta/views.py:21
90 | msgid "Currency"
91 | msgstr "Moneda"
92 |
93 | #: webgrid_ta/views.py:22
94 | msgid "C2"
95 | msgstr "C2"
96 |
97 | #: webgrid_ta/templates/index.html:3
98 | msgid "Manage People Grid"
99 | msgstr "Administrar Personas Grid"
100 |
101 | #: webgrid_ta/templates/index.html:14
102 | msgid "Manage People"
103 | msgstr "Gestionar Personas"
104 |
--------------------------------------------------------------------------------
/ruff.toml:
--------------------------------------------------------------------------------
1 | line-length = 100
2 | target-version = 'py310'
3 | output-format = 'concise'
4 |
5 | [format]
6 | line-ending = 'lf'
7 | quote-style = 'single'
8 |
9 |
10 | [lint]
11 | fixable = [
12 | 'C4',
13 | 'COM',
14 | 'I',
15 | 'ISC',
16 | 'PIE',
17 | 'Q',
18 | 'UP',
19 |
20 | 'E711', # Comparison to `None` should be `cond is None`
21 | 'E712', # Comparison to `True` should be `cond is True` or `if cond:`
22 | 'E713', # Test for membership should be `not in`
23 | 'E714', # Test for object identity should be `is not`
24 | 'F901', # `raise NotImplemented` should be `raise NotImplementedError`
25 | 'RUF100', # Unused blanket noqa directive
26 | 'W291', # Trailing whitespace
27 | 'W293', # Blank line contains whitespace
28 | ]
29 | select = [
30 | 'E', # ruff default: pycodestyle errors
31 | 'W', # pycodestyle warnings
32 | 'F', # ruff default: pyflakes
33 | 'I', # isort
34 | 'Q', # flake8-quotes
35 | 'UP', # pyupgrade
36 | 'YTT', # flake8-2020
37 | 'B', # flake8-bandit
38 | 'A', # flake8-builtins
39 | 'C4', # flake8-comprehensions
40 | 'T10', # flake8-debugger
41 | 'DJ', # flake8-django
42 | 'EXE', # flake8-executable
43 | 'PIE', # flake8-pie
44 | 'COM', # flake-8 commas
45 | 'RUF', # ruff specific
46 | 'SIM', # flake8-simplify
47 | 'ISC', # https://pypi.org/project/flake8-implicit-str-concat/
48 | 'PTH', # flake8-use-pathlib
49 | # 'DTZ', # flake8-datetimez
50 |
51 | ]
52 | ignore = [
53 | 'A003', # Class attribute is shadowing a Python builtin
54 | 'E731', # Do not assign a `lambda` expression, use a `def`
55 | 'UP038', # Deprecated by Ruff, so ignore
56 |
57 | # Rules that conflict with the formatter. See:
58 | #
59 | # * https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
60 | # * https://github.com/level12/coppy/issues/13
61 | #
62 | # These are all redundant when the formatter is being used
63 | 'W191', # tab-indentation
64 | 'E111', # indentation-with-invalid-multiple
65 | 'E114', # indentation-with-invalid-multiple-comment
66 | 'E117', # over-indented
67 | 'D206', # indent-with-spaces
68 | 'D300', # triple-single-quotes
69 | 'Q000', # bad-quotes-inline-string
70 | 'Q001', # bad-quotes-multiline-string
71 | 'Q002', # bad-quotes-docstring
72 | 'Q003', # avoidable-escaped-quote
73 | ]
74 |
75 |
76 | [lint.per-file-ignores]
77 | 'tasks/*.py' = ['EXE003']
78 |
79 |
80 | [lint.flake8-builtins]
81 | ignorelist = ['id', 'help', 'compile', 'filter', 'copyright']
82 |
83 |
84 | [lint.flake8-quotes]
85 | # Prefer using different quote to escaping strings
86 | avoid-escape = true
87 | inline-quotes = 'single'
88 |
89 |
90 | [lint.isort]
91 | lines-after-imports = 2
92 | force-sort-within-sections = true
93 | known-first-party = ['webgrid_tasks_lib', 'webgrid_tests', 'webgrid_ta', 'webgrid_blazeweb_ta']
94 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = [
3 | 'hatchling',
4 | 'hatch-regex-commit',
5 | ]
6 | build-backend = 'hatchling.build'
7 |
8 |
9 | [project]
10 | name = 'WebGrid'
11 | description = 'A library for rendering HTML tables and Excel files from SQLAlchemy models.'
12 | authors = [
13 | {name = 'Level 12', email = 'devteam@level12.io'},
14 | ]
15 | requires-python = '>=3.10'
16 | dynamic = ['version']
17 | readme = 'readme.md'
18 | license.file = 'license.txt'
19 | urls.homepage = 'https://github.com/level12/webgrid'
20 | classifiers = [
21 | 'Development Status :: 4 - Beta',
22 | 'Intended Audience :: Developers',
23 | 'License :: OSI Approved :: BSD License',
24 | 'Operating System :: OS Independent',
25 | ]
26 |
27 | # Dependencies
28 | dependencies = [
29 | 'BlazeUtils>=0.6.0',
30 | 'SQLAlchemy>=1.4.20',
31 | 'jinja2',
32 | 'python-dateutil',
33 | 'Werkzeug',
34 | ]
35 |
36 |
37 | [project.optional-dependencies]
38 | i18n = ['morphi']
39 |
40 |
41 | [dependency-groups]
42 | dev = [
43 | {include-group = 'tests'},
44 | {include-group = 'pre-commit'},
45 | {include-group = 'nox'},
46 | {include-group = 'docs'},
47 | 'click',
48 | 'hatch',
49 | 'ruff',
50 | ]
51 |
52 |
53 | # Groups that follow are used indvidually by nox
54 | docs = [
55 | 'pytz',
56 | 'sphinx',
57 | ]
58 |
59 | # pyodbc is broken out by itself on the assumption that most devs are fine with testing sqlite or
60 | # postgresql locally but probably won't go through the hassle of setting up the mssql driver and
61 | # so don't need pyodbc installed either.
62 | mssql = [
63 | # NOTE: you will also need the driver, which is an OS level install.
64 | # See: tasks/odbc-driver-install
65 | 'pyodbc',
66 | ]
67 |
68 | pre-commit = [
69 | 'pre-commit',
70 | 'pre-commit-uv',
71 | ]
72 |
73 | tests = [
74 | 'arrow>=1.3.0',
75 | 'flask>=3.0.3',
76 | 'flask-bootstrap>=3.3.7.1',
77 | 'flask-sqlalchemy>=3.1.1',
78 | 'flask-webtest>=0.1.6',
79 | 'flask-wtf>=1.2.2',
80 | 'openpyxl>=3.1.5',
81 | 'psycopg[binary]>=3.2.9',
82 | 'pyquery>=2.0.1',
83 | 'pytest',
84 | 'pytest-cov',
85 | 'sqlalchemy-utils>=0.41.2',
86 | 'xlsxwriter>=3.2.5',
87 | ]
88 |
89 | # Used by CI
90 | nox = [
91 | 'nox',
92 | ]
93 |
94 | # Used by CI
95 | release = [
96 | 'hatch',
97 | ]
98 |
99 |
100 | ############## TOOLS #####################
101 |
102 | [tool.babel.extract_messages]
103 | input_dirs = ['src/webgrid']
104 | mapping_file = 'src/webgrid/i18n/babel.cfg'
105 | output_file = 'src/webgrid/i18n/webgrid.pot'
106 |
107 | [tool.babel.init_catalog]
108 | domain = 'webgrid'
109 | input_file = 'src/webgrid/i18n/webgrid.pot'
110 | output_dir = 'src/webgrid/i18n'
111 |
112 | [tool.babel.update_catalog]
113 | domain = 'webgrid'
114 | input_file = 'src/webgrid/i18n/webgrid.pot'
115 | output_dir = 'src/webgrid/i18n'
116 |
117 | [tool.babel.compile_catalog]
118 | domain = 'webgrid'
119 | directory = 'src/webgrid/i18n'
120 |
121 | [tool.babel.compile_json]
122 | domain = 'webgrid'
123 | directory = 'src/webgrid/i18n'
124 | output_dir = 'src/webgrid/static/i18n'
125 |
--------------------------------------------------------------------------------
/src/webgrid/static/gettext.min.js:
--------------------------------------------------------------------------------
1 | /*! gettext.js - Guillaume Potier - MIT Licensed */
2 | /* v0.5.3, obtained 2018-07-30 via https://raw.githubusercontent.com/guillaumepotier/gettext.js/master/dist/gettext.min.js */
3 | !function(t,r){var e=function(t){t=t||{},this.__version="0.5.3";var e={domain:"messages",locale:document.documentElement.getAttribute("lang")||"en",plural_func:function(t){return{nplurals:2,plural:1!=t?1:0}},ctxt_delimiter:String.fromCharCode(4)},n={isObject:function(t){var r=typeof t;return"function"===r||"object"===r&&!!t},isArray:function(t){return"[object Array]"===toString.call(t)}},a={},l=t.locale||e.locale,u=t.domain||e.domain,o={},s={},i=t.ctxt_delimiter||e.ctxt_delimiter;t.messages&&(o[u]={},o[u][l]=t.messages),t.plural_forms&&(s[l]=t.plural_forms);var p=function(t){var r=arguments;return t.replace(/%(\d+)/g,function(t,e){return r[e]})},c=function(t){var r=new RegExp("^\\s*nplurals\\s*=\\s*[0-9]+\\s*;\\s*plural\\s*=\\s*(?:\\s|[-\\?\\|&=!<>+*/%:;n0-9_()])+");if(!r.test(t))throw new Error(p('The plural form "%1" is not valid',t));return new Function("n","var plural, nplurals; "+t+" return { nplurals: nplurals, plural: (plural === true ? 1 : (plural ? plural : 0)) };")},f=function(t,r,e){if(1===t.length)return p.apply(this,[t[0]].concat(Array.prototype.slice.call(arguments,3)));var n;return e.plural_func?n=e.plural_func(r):a[l]?n=a[l](r):(a[l]=c(s[l]),n=a[l](r)),("undefined"==typeof n.plural||n.plural>n.nplurals||t.length<=n.plural)&&(n.plural=0),p.apply(this,[t[n.plural],r].concat(Array.prototype.slice.call(arguments,3)))};return{strfmt:p,__:function(){return this.gettext.apply(this,arguments)},_n:function(){return this.ngettext.apply(this,arguments)},_p:function(){return this.pgettext.apply(this,arguments)},setMessages:function(t,r,e,a){if(!t||!r||!e)throw new Error("You must provide a domain, a locale and messages");if("string"!=typeof t||"string"!=typeof r||!n.isObject(e))throw new Error("Invalid arguments");return a&&(s[r]=a),o[t]||(o[t]={}),o[t][r]=e,this},loadJSON:function(t,r){if(n.isObject(t)||(t=JSON.parse(t)),!t[""]||!t[""].language||!t[""]["plural-forms"])throw new Error('Wrong JSON, it must have an empty key ("") with "language" and "plural-forms" information');var a=t[""];return delete t[""],this.setMessages(r||e.domain,a.language,t,a["plural-forms"])},setLocale:function(t){return l=t,this},getLocale:function(){return l},textdomain:function(t){return t?(u=t,this):u},gettext:function(t){return this.dcnpgettext.apply(this,[r,r,t,r,r].concat(Array.prototype.slice.call(arguments,1)))},ngettext:function(t,e,n){return this.dcnpgettext.apply(this,[r,r,t,e,n].concat(Array.prototype.slice.call(arguments,3)))},pgettext:function(t,e){return this.dcnpgettext.apply(this,[r,t,e,r,r].concat(Array.prototype.slice.call(arguments,2)))},dcnpgettext:function(t,r,n,a,s){if(t=t||u,"string"!=typeof n)throw new Error(this.strfmt('Msgid "%1" is not a valid translatable string',n));var p,c={},g=r?r+i+n:n,m=o[t]&&o[t][l]&&o[t][l][g];return m=a?m&&"string"!=typeof o[t][l][g]:m&&"string"==typeof o[t][l][g],m?p=o[t][l][g]:(p=n,c.plural_func=e.plural_func),a?f.apply(this,[m?p:[n,a],s,c].concat(Array.prototype.slice.call(arguments,5))):f.apply(this,[[p],s,c].concat(Array.prototype.slice.call(arguments,5)))}}};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=e),exports.i18n=e):"function"==typeof define&&define.amd?define(function(){return e}):t.i18n=e}(this);
4 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/test_types.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import pytest
4 |
5 | import webgrid.types as types
6 |
7 |
8 | class TestGridSettings:
9 | def ok_values(self, **kwargs):
10 | return {
11 | 'search_expr': 'foo',
12 | 'filters': {
13 | 'test': {'op': 'eq', 'value1': 'toast', 'value2': 'taft'},
14 | 'test2': {'op': 'in', 'value1': 'tarp', 'value2': None},
15 | },
16 | 'paging': {'pager_on': True, 'on_page': 2, 'per_page': 20},
17 | 'sort': [{'key': 'bar', 'flag_desc': False}, {'key': 'baz', 'flag_desc': True}],
18 | **kwargs,
19 | }
20 |
21 | def test_from_dict(self):
22 | data = self.ok_values()
23 | assert types.GridSettings.from_dict(data) == types.GridSettings(
24 | search_expr='foo',
25 | filters={
26 | 'test': types.Filter(op='eq', value1='toast', value2='taft'),
27 | 'test2': types.Filter(op='in', value1='tarp'),
28 | },
29 | paging=types.Paging(pager_on=True, on_page=2, per_page=20),
30 | sort=[types.Sort(key='bar', flag_desc=False), types.Sort(key='baz', flag_desc=True)],
31 | export_to=None,
32 | )
33 |
34 | def test_from_dict_missing_keys(self):
35 | assert types.GridSettings.from_dict({}) == types.GridSettings(
36 | search_expr=None,
37 | filters={},
38 | paging=types.Paging(pager_on=False, on_page=None, per_page=None),
39 | sort=[],
40 | export_to=None,
41 | )
42 |
43 | def test_from_dict_invalid_values(self):
44 | data = self.ok_values(paging={'per_page': 'foo'})
45 | with pytest.raises(
46 | types.ValidationError,
47 | match='Received per_page=foo; should be of type int',
48 | ):
49 | types.GridSettings.from_dict(data)
50 |
51 | @pytest.mark.parametrize(
52 | 'subobject,input_data',
53 | (
54 | ('Sort', {'sort': [{'key': 'foo', 'flag_desc': False, 'bar': 'baz'}]}),
55 | ('Paging', {'paging': {'bar': 'baz'}}),
56 | ('Filter', {'filters': {'test': {'op': 'eq', 'value1': 'foo', 'bar': 'baz'}}}),
57 | ),
58 | )
59 | def test_from_dict_extra_values(self, subobject, input_data):
60 | data = self.ok_values(**input_data)
61 | with pytest.raises(
62 | types.ValidationError,
63 | match=re.compile(
64 | f"{subobject}:.*__init__\\(\\) got an unexpected keyword argument 'bar'",
65 | ),
66 | ):
67 | types.GridSettings.from_dict(data)
68 |
69 | def test_from_dict_missing_values(self):
70 | data = self.ok_values(sort=[{'flag_desc': False}])
71 | with pytest.raises(
72 | types.ValidationError,
73 | match=re.compile(r"Sort:.*__init__\(\) missing 1 required positional argument: 'key'"),
74 | ):
75 | types.GridSettings.from_dict(data)
76 |
77 | def test_to_args(self):
78 | data = self.ok_values()
79 | assert types.GridSettings.from_dict(data).to_args() == {
80 | 'search': 'foo',
81 | 'onpage': 2,
82 | 'perpage': 20,
83 | 'op(test)': 'eq',
84 | 'v1(test)': 'toast',
85 | 'v2(test)': 'taft',
86 | 'op(test2)': 'in',
87 | 'v1(test2)': 'tarp',
88 | 'sort1': 'bar',
89 | 'sort2': '-baz',
90 | 'export_to': None,
91 | }
92 |
--------------------------------------------------------------------------------
/src/webgrid/validators.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | import decimal
3 |
4 | from blazeutils import tolist
5 |
6 | from .extensions import gettext as _
7 |
8 |
9 | class ValueInvalid(Exception):
10 | def __init__(self, msg, value, instance):
11 | self.msg = msg
12 | self.value = value
13 | self.instance = instance
14 |
15 | def __str__(self):
16 | return self.msg
17 |
18 |
19 | class Validator(ABC):
20 | @abstractmethod
21 | def process(self, value):
22 | pass
23 |
24 |
25 | class StringValidator(Validator):
26 | def process(self, value):
27 | if value is None or value == '':
28 | return None
29 | if not isinstance(value, str):
30 | return str(value)
31 | return value
32 |
33 |
34 | class RequiredValidator(Validator):
35 | def process(self, value):
36 | if value is None or value == '':
37 | raise ValueInvalid(_('Please enter a value.'), value, self)
38 | return value
39 |
40 |
41 | class IntValidator(Validator):
42 | def process(self, value):
43 | if value is None or value == '':
44 | return None
45 | try:
46 | return int(value)
47 | except (ValueError, TypeError) as e:
48 | raise ValueInvalid(_('Please enter an integer value.'), value, self) from e
49 |
50 |
51 | class FloatValidator(Validator):
52 | def process(self, value):
53 | if value is None or value == '':
54 | return None
55 | try:
56 | return float(value)
57 | except (ValueError, TypeError) as e:
58 | raise ValueInvalid(_('Please enter a number.'), value, self) from e
59 |
60 |
61 | class DecimalValidator(Validator):
62 | def process(self, value):
63 | if value is None or value == '':
64 | return None
65 | try:
66 | return decimal.Decimal(value)
67 | except decimal.InvalidOperation as e:
68 | raise ValueInvalid(_('Please enter a number.'), value, self) from e
69 |
70 |
71 | class RangeValidator(Validator):
72 | def __init__(self, min=None, max=None): # noqa: A002
73 | if min is None and max is None:
74 | raise Exception(_('must specify either min or max for range validation'))
75 | self.min = min
76 | self.max = max
77 |
78 | def process(self, value):
79 | if self.min is not None and value is not None and value < self.min:
80 | raise ValueInvalid(
81 | _('Value must be greater than or equal to {}.').format(self.min),
82 | value,
83 | self,
84 | )
85 | if self.max is not None and value is not None and value > self.max:
86 | raise ValueInvalid(
87 | _('Value must be less than or equal to {}.').format(self.max),
88 | value,
89 | self,
90 | )
91 | return value
92 |
93 |
94 | class OneOfValidator(Validator):
95 | def __init__(self, allowed_values):
96 | self.allowed_values = tuple(tolist(allowed_values))
97 |
98 | def process(self, value):
99 | if value is None or value == '':
100 | return None
101 | if value not in self.allowed_values:
102 | raise ValueInvalid(
103 | _('Value must be one of {}.').format(self.allowed_values),
104 | value,
105 | self,
106 | )
107 | return value
108 |
109 |
110 | class CustomValidator(Validator):
111 | def __init__(self, processor=None):
112 | if not callable(processor):
113 | raise Exception(_('Processor should be callable and take a value argument'))
114 | self.processor = processor
115 |
116 | def process(self, value):
117 | return self.processor(value)
118 |
--------------------------------------------------------------------------------
/noxfile.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from nox import Session, options, parametrize, session
4 |
5 |
6 | package_path = Path.cwd()
7 | tests_dpath = package_path / 'tests'
8 | docs_dpath = package_path / 'docs'
9 |
10 | py_all = ['3.10', '3.11', '3.12', '3.13']
11 | py_single = py_all[-1:]
12 | py_311 = ['3.11']
13 |
14 | options.default_venv_backend = 'uv'
15 |
16 |
17 | def pytest_run(session: Session, *args, **env):
18 | session.run(
19 | 'pytest',
20 | '-ra',
21 | '--tb=native',
22 | '--strict-markers',
23 | '--cov',
24 | '--cov-config=.coveragerc',
25 | f'--cov-report=xml:{package_path}/ci/coverage/{session.name}.xml',
26 | '--no-cov-on-fail',
27 | 'tests/webgrid_tests',
28 | *args,
29 | *session.posargs,
30 | env=env,
31 | )
32 |
33 |
34 | def uv_sync(session: Session, *groups, project, extra=None):
35 | project_args = () if project else ('--no-install-project',)
36 | group_args = [arg for group in groups for arg in ('--group', group)]
37 | extra_args = ('--extra', extra) if extra else ()
38 | run_args = (
39 | 'uv',
40 | 'sync',
41 | '--active',
42 | '--no-default-groups',
43 | *project_args,
44 | *group_args,
45 | *extra_args,
46 | )
47 | session.run(*run_args)
48 |
49 |
50 | @session(py=py_all)
51 | @parametrize('db', ['pg', 'sqlite'])
52 | def pytest(session: Session, db: str):
53 | uv_sync(session, 'tests', project=True)
54 | pytest_run(session, WEBTEST_DB=db)
55 |
56 |
57 | @session(py=py_single)
58 | def pytest_mssql(session: Session):
59 | uv_sync(session, 'tests', 'mssql', project=True)
60 | pytest_run(session, WEBTEST_DB='mssql')
61 |
62 |
63 | @session(py=py_single)
64 | def pytest_i18n(session: Session):
65 | uv_sync(session, 'tests', project=True, extra='i18n')
66 | pytest_run(session, WEBTEST_DB='sqlite')
67 |
68 |
69 | @session(py=py_single)
70 | def wheel(session: Session):
71 | """
72 | Package the wheel, install in the venv, and then run the tests for one version of Python.
73 | Helps ensure nothing is wrong with how we package the wheel.
74 | """
75 | uv_sync(session, 'tests', project=False)
76 |
77 | session.install('hatch', 'check-wheel-contents')
78 | version = session.run('hatch', 'version', silent=True, stderr=None).strip()
79 | wheel_fpath = package_path / 'tmp' / 'dist' / f'webgrid-{version}-py3-none-any.whl'
80 |
81 | if wheel_fpath.exists():
82 | wheel_fpath.unlink()
83 |
84 | session.run('hatch', 'build', '--clean')
85 | session.run('check-wheel-contents', wheel_fpath)
86 | session.run('uv', 'pip', 'install', wheel_fpath)
87 |
88 | out = session.run('python', '-c', 'import webgrid; print(webgrid.__file__)', silent=True)
89 | assert 'site-packages/webgrid/__init__.py' in out
90 |
91 | pytest_run(session, WEBTEST_DB='sqlite')
92 |
93 |
94 | @session(py=py_single)
95 | def precommit(session: Session):
96 | uv_sync(session, 'pre-commit', project=False)
97 | session.run(
98 | 'pre-commit',
99 | 'run',
100 | '--all-files',
101 | )
102 |
103 |
104 | # Python 3.11 is required due to: https://github.com/level12/morphi/issues/11
105 | @session(python=py_311)
106 | def translations(session: Session):
107 | uv_sync(session, 'tests', project=True, extra='i18n')
108 | # This is currently failing due to missing translations
109 | # https://github.com/level12/webgrid/issues/194
110 | session.run(
111 | 'python',
112 | 'tests/webgrid_ta/manage.py',
113 | 'verify-translations',
114 | env={'PYTHONPATH': tests_dpath},
115 | )
116 |
117 |
118 | @session(py=py_single)
119 | def docs(session: Session):
120 | uv_sync(session, 'tests', 'docs', project=True)
121 | session.run('make', '-C', docs_dpath, 'html', external=True)
122 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # WebGrid
2 | [](https://github.com/level12/webgrid/actions/workflows/nox.yaml)
3 | [](https://codecov.io/gh/level12/webgrid)
4 | [](https://ci.appveyor.com/project/level12/webgrid)
5 |
6 |
7 | ## Introduction
8 |
9 | WebGrid is a datagrid library for Flask and other Python web frameworks designed to work with
10 | SQLAlchemy ORM entities and queries.
11 |
12 | With a grid configured from one or more entities, WebGrid provides these features for reporting:
13 |
14 | - Automated SQL query construction based on specified columns and query join/filter/sort options
15 | - Renderers to various targets/formats
16 |
17 | - HTML output paired with JS (jQuery) for dynamic features
18 | - Excel (XLSX)
19 | - CSV
20 |
21 | - User-controlled data filters
22 |
23 | - Per-column selection of filter operator and value(s)
24 | - Generic single-entry search
25 |
26 | - Session storage/retrieval of selected filter options, sorting, and paging
27 |
28 |
29 | ## Installation
30 |
31 | Install via pip or uv:
32 |
33 | ```bash
34 | # Just the package
35 | pip install webgrid
36 | uv pip install webgrid
37 |
38 | # or, preferably in a uv project:
39 | uv add webgrid
40 | ```
41 |
42 | Some basic internationalization features are available via extra requirements:
43 |
44 | ```bash
45 | pip install webgrid[i18n]
46 | uv pip install webgrid[i18n]
47 |
48 | # or, preferably in a uv project:
49 | uv add webgrid --extra i18n
50 | ```
51 |
52 |
53 | ## Getting Started
54 |
55 | For a quick start, see the [Getting Started guide](https://webgrid.readthedocs.io/en/stable/getting-started.html) in the docs.
56 |
57 |
58 | ## Links
59 |
60 | * [Documentation](https://webgrid.readthedocs.io/en/stable/index.html)
61 | * [Releases](https://pypi.org/project/WebGrid/)
62 | * [Code](https://github.com/level12/webgrid)
63 | * [Issue tracker](https://github.com/level12/webgrid/issues)
64 | * [Questions & comments](https://github.com/level12/webgrid/discussions)
65 |
66 |
67 | ## Dev
68 |
69 | ### Copier Template
70 |
71 | Project structure and tooling mostly derives from the [Coppy](https://github.com/level12/coppy),
72 | see its documentation for context and additional instructions.
73 |
74 | This project can be updated from the upstream repo, see
75 | [Updating a Project](https://github.com/level12/coppy?tab=readme-ov-file#updating-a-project).
76 |
77 |
78 | ### Project Setup
79 |
80 | From zero to hero (passing tests that is):
81 |
82 | 1. Ensure [host dependencies](https://github.com/level12/coppy/wiki/Mise) are installed
83 |
84 | 2. Start docker service dependencies (if needed):
85 |
86 | ```
87 | ❯ docker compose config --services
88 | mssql
89 | pg
90 |
91 | ❯ docker compose up -d ...
92 | ```
93 |
94 | 3. Sync [project](https://docs.astral.sh/uv/concepts/projects/) virtualenv w/ lock file:
95 |
96 | `uv sync`
97 |
98 | 4. Configure pre-commit:
99 |
100 | `pre-commit install`
101 |
102 | 5. Install mssql driver if intending to run mssql tests
103 |
104 | `mise odbc-driver-install`
105 |
106 | 6. View sessions then run sessions:
107 |
108 | ```
109 | ❯ nox --list
110 |
111 | # all sessions
112 | ❯ nox
113 |
114 | # selected sessions
115 | ❯ nox -e ...
116 | ```
117 |
118 |
119 | ### Versions
120 |
121 | Versions are date based. A `bump` action exists to help manage versions:
122 |
123 | ```shell
124 | # Show current version
125 | mise bump --show
126 |
127 | # Bump version based on date, tag, and push:
128 | mise bump
129 |
130 | # See other options
131 | mise bump -- --help
132 | ```
133 |
134 |
135 | ### PyPI Publishing
136 |
137 | PyPI publishing is automated in the `nox.yaml` GitHub action:
138 |
139 | - "v" tags will publish to pypi.org (production)
140 | - Anything else that triggers the Nox GH action will publish to test.pypi.org
141 |
142 | Auth for test.pypi.org is separate from production so users who should be able to manage the PyPI
143 | project need to be given access in both systems.
144 |
145 |
146 | ### Documentation
147 |
148 | The [RTD project](https://app.readthedocs.org/projects/webgrid/) will automatically build on pushes
149 | to master.
150 |
--------------------------------------------------------------------------------
/docs/source/grid/args-loaders.rst:
--------------------------------------------------------------------------------
1 | .. _args-loaders:
2 |
3 | Arguments Loaders
4 | =================
5 |
6 | Grid arguments are run-time configuration for a grid instance. This includes filter
7 | operator/values, sort terms, search, paging, session key, etc.
8 |
9 | Arguments may be provided to the grid directly, or else it pulls them from the assigned
10 | framework manager. The most common use case will use the manager.
11 |
12 |
13 | Managed arguments
14 | -----------------
15 |
16 | The grid manager uses "args loaders" (subclasses of ``ArgsLoader``) to supply grid
17 | configuration. These loaders each represent a source of configuration. For instance, a
18 | loader can pull args from the GET query string, a POSTed form, etc.
19 |
20 | The first loader on the list gets a blank MultiDict as input. Then, results from each loader
21 | are chained to the next one on the list. Each loader may accept or override the values from
22 | the previous output. The last loader gets the final word on configuration sent to the grid.
23 |
24 | The default setup provides request URL arguments to the first loader, and then
25 | applies session information as needed. Some cases where you might want to do something
26 | different from the default:
27 | - The grid has options filters with a large number of options to select
28 | - The grid has a lot of complexity that would be cleaner as POSTs rather than GETs
29 |
30 | To use managed arguments with the default loaders, simply call ``apply_qs_args``
31 | or ``build`` to have the grid load these for use in queries and rendering::
32 |
33 | class PeopleGrid(Grid):
34 | Column('Name', entities.Person.name)
35 | Column('Age', entities.Person.age)
36 | Column('Location', entities.Person.city)
37 |
38 | grid = PeopleGrid()
39 | grid.apply_qs_args()
40 |
41 | Customizing the loader list on a managed grid requires setting the ``args_loaders`` iterable
42 | on the manager. This can be set as a class attribute or provided in the manager's constructor.
43 |
44 | As a class attribute::
45 |
46 | from webgrid import BaseGrid
47 | from webgrid.extensions import RequestArgsLoader, RequestFormLoader, WebSessionArgsLoader
48 | from webgrid.flask import WebGrid
49 |
50 | class GridManager(WebGrid):
51 | args_loaders = (
52 | RequestArgsLoader, # part of the default, takes args from URL query string
53 | RequestFormLoader, # use args present in the POSTed form
54 | WebSessionArgsLoader, # part of the default, but lower priority from the form POST
55 | )
56 |
57 | class Grid(BaseGrid):
58 | manager = GridManager()
59 |
60 | Using the manager's constructor to customize the loader list::
61 |
62 | from webgrid import BaseGrid
63 | from webgrid.extensions import RequestArgsLoader, RequestFormLoader, WebSessionArgsLoader
64 | from webgrid.flask import WebGrid
65 |
66 | class Grid(BaseGrid):
67 | manager = WebGrid(
68 | args_loaders = (
69 | RequestArgsLoader, # part of the default, takes args from URL query string
70 | RequestFormLoader, # use args present in the POSTed form
71 | WebSessionArgsLoader, # part of the default, but lower priority from the form POST
72 | )
73 | )
74 |
75 |
76 | .. autoclass:: webgrid.extensions.ArgsLoader
77 | :members:
78 |
79 | .. autoclass:: webgrid.extensions.RequestArgsLoader
80 | :members:
81 |
82 | .. autoclass:: webgrid.extensions.RequestFormLoader
83 | :members:
84 |
85 | .. autoclass:: webgrid.extensions.RequestJsonLoader
86 | :members:
87 |
88 | .. autoclass:: webgrid.extensions.WebSessionArgsLoader
89 | :members:
90 |
91 |
92 | Supplying arguments directly
93 | ----------------------------
94 |
95 | Arguments may be provided directly to `apply_qs_args` or `build` as a MultiDict. If arguments
96 | are supplied in this fashion, other sources are ignored::
97 |
98 | from werkzeug.datastructures import MultiDict
99 |
100 | class PeopleGrid(Grid):
101 | Column('Name', entities.Person.name)
102 | Column('Age', entities.Person.age)
103 | Column('Location', entities.Person.city)
104 |
105 | grid = PeopleGrid()
106 | grid.apply_qs_args(grid_args=MultiDict([
107 | ('op(name)', 'contains'),
108 | ('v1(name)', 'bill'),
109 | ]))
110 |
--------------------------------------------------------------------------------
/src/webgrid/types.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 | from typing import Any
3 |
4 |
5 | class ValidationError(Exception):
6 | pass
7 |
8 |
9 | class FieldValidationError(ValidationError):
10 | def __init__(self, field, value, type_):
11 | message = f'Received {field}={value}; should be of type {type_}'
12 | super().__init__(message)
13 |
14 |
15 | @dataclass
16 | class Filter:
17 | op: str
18 | value1: str | list[str]
19 | value2: str | None = None
20 |
21 |
22 | @dataclass
23 | class Paging:
24 | pager_on: bool = False
25 | per_page: int | None = None
26 | on_page: int | None = None
27 |
28 | def __post_init__(self):
29 | if self.per_page is not None and not isinstance(self.per_page, int):
30 | raise FieldValidationError('per_page', self.per_page, 'int')
31 | if self.on_page is not None and not isinstance(self.on_page, int):
32 | raise FieldValidationError('on_page', self.on_page, 'int')
33 |
34 |
35 | @dataclass
36 | class Sort:
37 | key: str
38 | flag_desc: bool
39 |
40 |
41 | @dataclass
42 | class FilterOperator:
43 | key: str
44 | label: str
45 | field_type: str | None
46 | hint: str | None = None
47 |
48 |
49 | @dataclass
50 | class FilterOption:
51 | key: str
52 | value: str
53 |
54 |
55 | @dataclass
56 | class FilterSpec:
57 | operators: list[FilterOperator]
58 | primary_op: FilterOperator | None
59 |
60 |
61 | @dataclass
62 | class OptionsFilterSpec(FilterSpec):
63 | options: list[FilterOption]
64 |
65 |
66 | @dataclass
67 | class ColumnGroup:
68 | label: str
69 | columns: list[str]
70 |
71 |
72 | @dataclass
73 | class GridTotals:
74 | page: dict[str, Any] | None = None
75 | grand: dict[str, Any] | None = None
76 |
77 |
78 | @dataclass
79 | class GridSettings:
80 | search_expr: str | None = None
81 | filters: dict[str, Filter] = field(default_factory=dict)
82 | paging: Paging = field(default_factory=Paging)
83 | sort: list[Sort] = field(default_factory=list)
84 | export_to: str | None = None
85 |
86 | @classmethod
87 | def from_dict(cls, data: dict[str, Any]) -> 'GridSettings':
88 | """Create from deserialized json"""
89 | try:
90 | filters = {key: Filter(**filter_) for key, filter_ in data.get('filters', {}).items()}
91 | except TypeError as e:
92 | raise ValidationError(f'Filter: {e}') from e
93 |
94 | try:
95 | paging = Paging(**data.get('paging', {}))
96 | except TypeError as e:
97 | raise ValidationError(f'Paging: {e}') from e
98 |
99 | try:
100 | sort = [Sort(**sort) for sort in data.get('sort', [])]
101 | except TypeError as e:
102 | raise ValidationError(f'Sort: {e}') from e
103 |
104 | return cls(
105 | search_expr=data.get('search_expr'),
106 | filters=filters,
107 | paging=paging,
108 | sort=sort,
109 | export_to=data.get('export_to'),
110 | )
111 |
112 | def to_args(self) -> dict[str, Any]:
113 | """Convert grid parameters to request args format"""
114 | args = {
115 | 'search': self.search_expr,
116 | 'onpage': self.paging.on_page,
117 | 'perpage': self.paging.per_page,
118 | 'export_to': self.export_to,
119 | }
120 |
121 | for key, filter_ in self.filters.items():
122 | args[f'op({key})'] = filter_.op
123 | args[f'v1({key})'] = filter_.value1
124 | if filter_.value2:
125 | args[f'v2({key})'] = filter_.value2
126 |
127 | for i, s in enumerate(self.sort, 1):
128 | prefix = '-' if s.flag_desc else ''
129 | args[f'sort{i}'] = f'{prefix}{s.key}'
130 |
131 | return args
132 |
133 |
134 | @dataclass
135 | class GridSpec:
136 | columns: list[dict[str, str]]
137 | column_groups: list[ColumnGroup]
138 | column_types: list[dict[str, str]]
139 | export_targets: list[str]
140 | enable_search: bool
141 | enable_sort: bool
142 | sortable_columns: list[str]
143 | filters: dict[str, FilterSpec] = field(default_factory=dict)
144 |
145 |
146 | @dataclass
147 | class GridState:
148 | page_count: int
149 | record_count: int
150 | warnings: list[str]
151 |
152 |
153 | @dataclass
154 | class Grid:
155 | settings: GridSettings
156 | spec: GridSpec
157 | state: GridState
158 | records: list[dict[str, Any]]
159 | totals: GridTotals
160 | errors: list[str]
161 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/data/stopwatch_table.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Lap 1
6 |
7 | Lap 2
8 | Lap 3
9 |
10 |
11 | ID
12 | Label
13 | Start Time
14 | Stop Time
15 | Category
16 | Start Time
17 | Stop Time
18 | Start Time
19 | Stop Time
20 |
21 |
22 |
23 |
24 | 1
25 | Watch 1
26 | 01/01/2019 01:00 AM
27 | 01/01/2019 02:00 AM
28 | Sports
29 | 01/01/2019 03:00 AM
30 | 01/01/2019 04:00 AM
31 | 01/01/2019 05:00 AM
32 | 01/01/2019 06:00 AM
33 |
34 |
35 | 2
36 | Watch 2
37 | 01/01/2019 02:00 AM
38 | 01/01/2019 03:00 AM
39 | Sports
40 | 01/01/2019 04:00 AM
41 | 01/01/2019 05:00 AM
42 | 01/01/2019 06:00 AM
43 | 01/01/2019 07:00 AM
44 |
45 |
46 | 3
47 | Watch 3
48 | 01/01/2019 03:00 AM
49 | 01/01/2019 04:00 AM
50 | Sports
51 | 01/01/2019 05:00 AM
52 | 01/01/2019 06:00 AM
53 | 01/01/2019 07:00 AM
54 | 01/01/2019 08:00 AM
55 |
56 |
57 | 4
58 | Watch 4
59 | 01/01/2019 04:00 AM
60 | 01/01/2019 05:00 AM
61 | Sports
62 | 01/01/2019 06:00 AM
63 | 01/01/2019 07:00 AM
64 | 01/01/2019 08:00 AM
65 | 01/01/2019 09:00 AM
66 |
67 |
68 | 5
69 | Watch 5
70 | 01/01/2019 05:00 AM
71 | 01/01/2019 06:00 AM
72 | Sports
73 | 01/01/2019 07:00 AM
74 | 01/01/2019 08:00 AM
75 | 01/01/2019 09:00 AM
76 | 01/01/2019 10:00 AM
77 |
78 |
79 | 6
80 | Watch 6
81 | 01/01/2019 06:00 AM
82 | 01/01/2019 07:00 AM
83 | Sports
84 | 01/01/2019 08:00 AM
85 | 01/01/2019 09:00 AM
86 | 01/01/2019 10:00 AM
87 | 01/01/2019 11:00 AM
88 |
89 |
90 | 7
91 | Watch 7
92 | 01/01/2019 07:00 AM
93 | 01/01/2019 08:00 AM
94 | Sports
95 | 01/01/2019 09:00 AM
96 | 01/01/2019 10:00 AM
97 | 01/01/2019 11:00 AM
98 | 01/01/2019 12:00 PM
99 |
100 |
101 | 8
102 | Watch 8
103 | 01/01/2019 08:00 AM
104 | 01/01/2019 09:00 AM
105 | Sports
106 | 01/01/2019 10:00 AM
107 | 01/01/2019 11:00 AM
108 | 01/01/2019 12:00 PM
109 | 01/01/2019 01:00 PM
110 |
111 |
112 | 9
113 | Watch 9
114 | 01/01/2019 09:00 AM
115 | 01/01/2019 10:00 AM
116 | Sports
117 | 01/01/2019 11:00 AM
118 | 01/01/2019 12:00 PM
119 | 01/01/2019 01:00 PM
120 | 01/01/2019 02:00 PM
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/model/entities.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 | import arrow
4 | from blazeutils.strings import randchars
5 | import sqlalchemy as sa
6 | import sqlalchemy.orm as saorm
7 | from sqlalchemy_utils import ArrowType
8 |
9 | from ..model import db
10 | from .helpers import DefaultMixin
11 |
12 |
13 | class Radio(db.Model, DefaultMixin):
14 | __tablename__ = 'sabwp_radios'
15 |
16 | make = sa.Column(sa.Unicode(255), nullable=False)
17 | model = sa.Column(sa.Unicode(255), nullable=False)
18 | year = sa.Column(sa.Integer, nullable=False)
19 |
20 |
21 | class Car(db.Model, DefaultMixin):
22 | __tablename__ = 'sabwp_cars'
23 |
24 | make = sa.Column(sa.Unicode(255), nullable=False)
25 | model = sa.Column(sa.Unicode(255), nullable=False)
26 | year = sa.Column(sa.Integer, nullable=False)
27 |
28 | radio_id = sa.Column(sa.Integer, sa.ForeignKey(Radio.id), nullable=False)
29 | radio = saorm.relationship(Radio, lazy=False)
30 |
31 | def __repr__(self):
32 | return f''
33 |
34 |
35 | class AccountType(enum.Enum):
36 | admin = 'Admin'
37 | manager = 'Manager'
38 | employee = 'Employee'
39 |
40 |
41 | class Person(db.Model, DefaultMixin):
42 | __tablename__ = 'persons'
43 |
44 | id = sa.Column(sa.Integer, primary_key=True)
45 | firstname = sa.Column(sa.String(50))
46 | lastname = sa.Column('last_name', sa.String(50))
47 | inactive = sa.Column(sa.SmallInteger)
48 | state = sa.Column(sa.String(50))
49 | status_id = sa.Column(sa.Integer, sa.ForeignKey('statuses.id'))
50 | address = sa.Column(sa.Integer)
51 | createdts = sa.Column(sa.DateTime)
52 | sortorder = sa.Column(sa.Integer)
53 | floatcol = sa.Column(sa.Float)
54 | # must specify precision here, as mssql defaults to (18, 0)
55 | numericcol = sa.Column(sa.Numeric(9, 2))
56 | boolcol = sa.Column(sa.Boolean)
57 | due_date = sa.Column(sa.Date)
58 | start_time = sa.Column(sa.Time)
59 | legacycol1 = sa.Column('LegacyColumn1', sa.String(50), key='legacycolumn')
60 | legacycol2 = sa.Column('LegacyColumn2', sa.String(50))
61 | account_type = sa.Column(sa.Enum(AccountType, name='person_account_type'))
62 |
63 | status = saorm.relationship('Status')
64 |
65 | def __repr__(self):
66 | return f''
67 |
68 | @classmethod
69 | def testing_create(cls, firstname=None, **kwargs):
70 | firstname = firstname or randchars()
71 | return cls.add(firstname=firstname, **kwargs)
72 |
73 | @classmethod
74 | def delete_cascaded(cls):
75 | Email.delete_all()
76 | cls.delete_all()
77 |
78 |
79 | class ArrowRecord(db.Model, DefaultMixin):
80 | __tablename__ = 'arrow_records'
81 | created_utc = sa.Column(ArrowType, default=arrow.now)
82 |
83 | @classmethod
84 | def testing_create(cls, **kwargs):
85 | return cls.add(**kwargs)
86 |
87 |
88 | class Email(db.Model, DefaultMixin):
89 | __tablename__ = 'emails'
90 |
91 | id = sa.Column(sa.Integer, primary_key=True)
92 | person_id = sa.Column(sa.Integer, sa.ForeignKey(Person.id), nullable=False)
93 | email = sa.Column(sa.String(50), nullable=False)
94 |
95 | person = saorm.relationship(Person, backref='emails')
96 |
97 |
98 | class Status(db.Model, DefaultMixin):
99 | __tablename__ = 'statuses'
100 |
101 | id = sa.Column(sa.Integer, primary_key=True)
102 | label = sa.Column(sa.String(50), nullable=False, unique=True)
103 | flag_closed = sa.Column(sa.Integer, default=0)
104 |
105 | @classmethod
106 | def pairs(cls):
107 | return db.session.query(cls.id, cls.label).order_by(cls.label)
108 |
109 | @classmethod
110 | def delete_cascaded(cls):
111 | Person.delete_cascaded()
112 | cls.delete_all()
113 |
114 | @classmethod
115 | def testing_create(cls, label=None):
116 | label = label or randchars()
117 | return cls.add(label=label)
118 |
119 |
120 | class Stopwatch(db.Model, DefaultMixin):
121 | __tablename__ = 'stopwatches'
122 |
123 | id = sa.Column(sa.Integer, primary_key=True)
124 | label = sa.Column(sa.String(20))
125 | category = sa.Column(sa.String(20))
126 | start_time_lap1 = sa.Column(sa.DateTime)
127 | stop_time_lap1 = sa.Column(sa.DateTime)
128 | start_time_lap2 = sa.Column(sa.DateTime)
129 | stop_time_lap2 = sa.Column(sa.DateTime)
130 | start_time_lap3 = sa.Column(sa.DateTime)
131 | stop_time_lap3 = sa.Column(sa.DateTime)
132 |
133 |
134 | if db.engine.dialect.name == 'postgresql':
135 |
136 | class ArrayTable(db.Model, DefaultMixin):
137 | __tablename__ = 'array_table'
138 |
139 | id = sa.Column(sa.Integer, primary_key=True)
140 | account_type = sa.Column(
141 | sa.dialects.postgresql.ARRAY(sa.Enum(AccountType, name='person_account_type')),
142 | )
143 |
--------------------------------------------------------------------------------
/docs/source/columns/column-usage.rst:
--------------------------------------------------------------------------------
1 | .. _column-usage:
2 |
3 | General Column Usage
4 | ====================
5 |
6 | Columns make up the grid's definition, as columns specify the data, layout, and formatting
7 | of the grid table. In WebGrid, a column knows how to render itself to any output target and how
8 | to apply sorting. In addition, the column is responsible for configuration of subtotals,
9 | filtering, etc.
10 |
11 | The most basic usage of the column is to specify a heading label and the SQLAlchemy expression
12 | to be used. With this usage, sorting will be available in the grid/column headers, and the column
13 | will be rendered on all targets::
14 |
15 | class PeopleGrid(Grid):
16 | Column('Name', entities.Person.name)
17 |
18 |
19 | The grid will have a keyed lookup for the column as it is defined. In the above case, the grid
20 | will pull the key from the SQLAlchemy expression, so the column may be referred to in surrounding
21 | code as::
22 |
23 | grid.column('name')
24 |
25 |
26 | Filtering
27 | ---------
28 |
29 | When defining a column for a grid, a filter may be specified as part of the spec::
30 |
31 | class PeopleGrid(Grid):
32 | Column('Name', entities.Person.name, TextFilter)
33 |
34 |
35 | In the above, filtering options will be available for the `name` column. Because `TextFilter`
36 | supports the single-search UI, the column will also be automatically searched with that feature.
37 |
38 | While the most common usage of filters simply provides the filter class for the column definition,
39 | a filter instance may be provided instead. Filter instances are useful when the column being
40 | filtered differs from the column being displayed::
41 |
42 | class PeopleGrid(Grid):
43 | query_joins = ([entities.Person.location], )
44 |
45 | class LocationFilter(OptionsIntFilterBase):
46 | options_from = db.session.query(
47 | entities.Location.id, entities.Location.label
48 | ).all()
49 |
50 | Column('Name', entities.Person.name, TextFilter)
51 | Column('Location', entities.Location.name, LocationFilter(entities.Location.id))
52 |
53 | A number of things are happening there:
54 |
55 | - The grid is joining two entities
56 | - A custom filter is provided for selecting locations from a list (see :ref:`custom-filters`)
57 | - The location column renders the name, but filters based on the location ID
58 |
59 |
60 | Sorting
61 | -------
62 |
63 | Some columns are display-only or filter-only and do not make sense as sorting options. For these,
64 | use the `can_sort` option (default is True)::
65 |
66 | class PeopleGrid(Grid):
67 | Column('Name', entities.Person.name, can_sort=False)
68 |
69 | More advanced sort customization is available for column subclasses. See :ref:`custom-columns`
70 | for more information.
71 |
72 |
73 | Visibility
74 | ----------
75 |
76 | WebGrid allows columns to be "turned off" for the table area (i.e. sort/filter only)::
77 |
78 | class PeopleGrid(Grid):
79 | Column('Name', entities.Person.name, visible=False)
80 |
81 | Also, a column may be designated as being present for specific renderers. This can be helpful
82 | when a width-restricted format (like HTML) needs to leave out columns that are useful in more
83 | extensive exports::
84 |
85 | class PeopleGrid(Grid):
86 | Column('Name', entities.Person.name, render_in=('xlsx', 'csv'))
87 |
88 |
89 | Subtotals
90 | ---------
91 |
92 | Useful for numeric columns in particlar, subtotals options may be specified to provide a way
93 | for the grid query to aggregate a column's data. Grids then have the option to turn on
94 | subtotals for display at the page or grand level (or both).
95 |
96 | The most basic subtotal specification is simply turning it on for a column, which will use the
97 | SUM function::
98 |
99 | class PeopleGrid(Grid):
100 | Column('Name', entities.Person.name, has_subtotal=True)
101 |
102 | The same result may be achieved with one of the string options recognized::
103 |
104 | class PeopleGrid(Grid):
105 | Column('Name', entities.Person.name, has_subtotal='sum')
106 |
107 | The other string option recognized applies an average on the data::
108 |
109 | class PeopleGrid(Grid):
110 | Column('Name', entities.Person.name, has_subtotal='avg')
111 |
112 | For greater customization, a callable may be provided that takes the aggregated expression
113 | and returns the function expression to use in the SQL query::
114 |
115 | class PeopleGrid(Grid):
116 | Column('Name', entities.Person.name,
117 | has_subtotal=lambda col: sa.sql.func.count(col))
118 |
119 | Finally, a string may be provided for output on the totals row(s) instead of aggregated data::
120 |
121 | class PeopleGrid(Grid):
122 | Column('Name', entities.Person.name, has_subtotal="What's in a name?")
123 |
--------------------------------------------------------------------------------
/src/webgrid/static/multiple-select.css:
--------------------------------------------------------------------------------
1 | /**
2 | * @author zhixin wen
3 | */
4 |
5 | .ms-parent {
6 | display: inline-block;
7 | position: relative;
8 | vertical-align: middle;
9 | }
10 |
11 | .ms-choice {
12 | display: block;
13 | height: 26px;
14 | padding: 0;
15 | overflow: hidden;
16 | cursor: pointer;
17 | border: 1px solid #aaa;
18 | text-align: left;
19 | white-space: nowrap;
20 | line-height: 26px;
21 | color: #444;
22 | text-decoration: none;
23 | -webkit-border-radius: 4px;
24 | -moz-border-radius: 4px;
25 | border-radius: 4px;
26 | background-color: #fff;
27 | }
28 |
29 | .ms-choice.disabled {
30 | background-color: #f4f4f4;
31 | background-image: none;
32 | border: 1px solid #ddd;
33 | cursor: default;
34 | }
35 |
36 | .ms-choice > span {
37 | position: absolute;
38 | top: 0;
39 | left: 0;
40 | right: 20px;
41 | white-space: nowrap;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | display: block;
45 | padding-left: 8px;
46 | }
47 |
48 | .ms-choice > span.placeholder {
49 | color: #999;
50 | }
51 |
52 | .ms-choice > div {
53 | position: absolute;
54 | top: 0;
55 | right: 0;
56 | width: 20px;
57 | height: 25px;
58 | background: url('multiple-select.png') right top no-repeat;
59 | }
60 |
61 | .ms-choice > div.open {
62 | background: url('multiple-select.png') left top no-repeat;
63 | }
64 |
65 | .ms-drop {
66 | overflow: hidden;
67 | display: none;
68 | margin-top: -1px;
69 | padding: 0;
70 | position: absolute;
71 | z-index: 1000;
72 | background: #fff;
73 | color: #000;
74 | border: 1px solid #aaa;
75 | -webkit-border-radius: 4px;
76 | -moz-border-radius: 4px;
77 | border-radius: 4px;
78 | }
79 |
80 | .ms-drop.bottom {
81 | top: 100%;
82 | -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
83 | -moz-box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
84 | box-shadow: 0 4px 5px rgba(0, 0, 0, .15);
85 | }
86 |
87 | .ms-drop.top {
88 | bottom: 100%;
89 | -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
90 | -moz-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
91 | box-shadow: 0 -4px 5px rgba(0, 0, 0, .15);
92 | }
93 |
94 | .ms-search {
95 | display: inline-block;
96 | margin: 0;
97 | min-height: 26px;
98 | padding: 4px;
99 | position: relative;
100 | white-space: nowrap;
101 | width: 100%;
102 | z-index: 10000;
103 | }
104 |
105 | .ms-search input {
106 | width: 100%;
107 | height: auto !important;
108 | min-height: 24px;
109 | padding: 0 20px 0 5px;
110 | margin: 0;
111 | outline: 0;
112 | font-family: sans-serif;
113 | font-size: 1em;
114 | border: 1px solid #aaa;
115 | -webkit-border-radius: 0;
116 | -moz-border-radius: 0;
117 | border-radius: 0;
118 | -webkit-box-shadow: none;
119 | -moz-box-shadow: none;
120 | box-shadow: none;
121 | background: #fff url('multiple-select.png') no-repeat 100% -22px;
122 | background: url('multiple-select.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, white), color-stop(0.99, #eeeeee));
123 | background: url('multiple-select.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, white 85%, #eeeeee 99%);
124 | background: url('multiple-select.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, white 85%, #eeeeee 99%);
125 | background: url('multiple-select.png') no-repeat 100% -22px, -o-linear-gradient(bottom, white 85%, #eeeeee 99%);
126 | background: url('multiple-select.png') no-repeat 100% -22px, -ms-linear-gradient(top, #ffffff 85%, #eeeeee 99%);
127 | background: url('multiple-select.png') no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
128 | }
129 |
130 | .ms-search, .ms-search input {
131 | -webkit-box-sizing: border-box;
132 | -khtml-box-sizing: border-box;
133 | -moz-box-sizing: border-box;
134 | -ms-box-sizing: border-box;
135 | box-sizing: border-box;
136 | }
137 |
138 | .ms-drop ul {
139 | overflow: auto;
140 | margin: 0;
141 | padding: 5px 8px;
142 | }
143 |
144 | .ms-drop ul > li {
145 | list-style: none;
146 | display: list-item;
147 | background-image: none;
148 | position: static;
149 | }
150 |
151 | .ms-drop ul > li .disabled {
152 | opacity: .35;
153 | filter: Alpha(Opacity=35);
154 | }
155 |
156 | .ms-drop ul > li.multiple {
157 | display: block;
158 | float: left;
159 | }
160 |
161 | .ms-drop ul > li.group {
162 | clear: both;
163 | }
164 |
165 | .ms-drop ul > li.multiple label {
166 | width: 100%;
167 | display: block;
168 | white-space: nowrap;
169 | overflow: hidden;
170 | text-overflow: ellipsis;
171 | }
172 |
173 | .ms-drop ul > li label.optgroup {
174 | font-weight: bold;
175 | }
176 |
177 | .ms-drop input[type="checkbox"] {
178 | vertical-align: middle;
179 | }
180 |
181 | .ms-drop .ms-no-results {
182 | display: none;
183 | }
184 |
--------------------------------------------------------------------------------
/.github/workflows/nox.yaml:
--------------------------------------------------------------------------------
1 | name: Nox
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - 'v*.*.*'
9 | pull_request:
10 | workflow_dispatch:
11 |
12 |
13 | # Limit this workflow to a single run at a time per-branch to avoid wasting worker resources
14 | concurrency:
15 | group: ${{ github.workflow }}-${{ github.ref }}
16 | cancel-in-progress: true
17 |
18 |
19 | jobs:
20 | generate-matrix:
21 | runs-on: ubuntu-24.04
22 |
23 | outputs:
24 | nox-sessions: ${{ steps.nox-sessions.outputs.sessions }}
25 |
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 |
30 | - uses: ./.github/actions/uv-setup
31 |
32 | - id: nox-sessions
33 | run: |
34 | sessions=$(uv run --only-group nox -- tasks/gh-nox-sessions)
35 | echo "sessions=$sessions" >> $GITHUB_OUTPUT
36 | env:
37 | PYTHONPATH: './src'
38 |
39 | nox-other:
40 | needs: generate-matrix
41 | runs-on: ubuntu-24.04
42 |
43 | strategy:
44 | fail-fast: false
45 | matrix:
46 | session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).other }}
47 |
48 | steps:
49 | - name: Checkout
50 | uses: actions/checkout@v4
51 |
52 | - uses: ./.github/actions/nox-run
53 | with:
54 | nox-session: ${{ matrix.session }}
55 |
56 |
57 | nox-pg:
58 | needs: generate-matrix
59 | runs-on: ubuntu-24.04
60 |
61 | strategy:
62 | fail-fast: false
63 | matrix:
64 | session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).pg }}
65 |
66 | services:
67 | postgres:
68 | image: postgres:17
69 | env:
70 | POSTGRES_HOST_AUTH_METHOD: trust
71 | ports:
72 | - 5432:5432
73 | options: >-
74 | --health-cmd="pg_isready -U postgres"
75 | --health-interval=3s
76 | --health-timeout=3s
77 | --health-retries=15
78 |
79 | steps:
80 | - name: Checkout
81 | uses: actions/checkout@v4
82 |
83 | - uses: ./.github/actions/nox-run
84 | with:
85 | nox-session: ${{ matrix.session }}
86 |
87 |
88 | nox-mssql:
89 | needs: generate-matrix
90 | runs-on: ubuntu-24.04
91 |
92 | strategy:
93 | fail-fast: false
94 | matrix:
95 | session: ${{ fromJson(needs.generate-matrix.outputs.nox-sessions).mssql }}
96 |
97 | services:
98 | mssql:
99 | image: mcr.microsoft.com/mssql/server:2019-latest
100 | env:
101 | ACCEPT_EULA: Y
102 | SA_PASSWORD: Docker-sa-password
103 | ports:
104 | - 1433:1433
105 | options: >-
106 | --health-cmd="/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U SA -P Docker-sa-password -Q \"select 'ok'\""
107 | --health-interval=3s
108 | --health-timeout=3s
109 | --health-retries=15
110 |
111 | steps:
112 | - name: Checkout
113 | uses: actions/checkout@v4
114 |
115 | - uses: ./.github/actions/nox-run
116 | with:
117 | nox-session: ${{ matrix.session }}
118 |
119 |
120 | codecov:
121 | needs: [nox-other, nox-pg, nox-mssql]
122 | runs-on: ubuntu-latest
123 |
124 | permissions:
125 | id-token: write # For codecov OIDC
126 |
127 | steps:
128 | # Codecov action says we have to have done a checkout
129 | - name: Checkout
130 | uses: actions/checkout@v4
131 |
132 | - uses: actions/download-artifact@v5
133 | with:
134 | path: ci/github-coverage
135 | merge-multiple: true
136 |
137 | - name: Coverage files
138 | run: ls -R ci/
139 |
140 | - uses: codecov/codecov-action@v5
141 | with:
142 | use_oidc: true
143 | files: ci/github-coverage/*.xml
144 |
145 | pypi-publish:
146 | needs: [nox-other, nox-pg, nox-mssql]
147 | runs-on: ubuntu-latest
148 |
149 | env:
150 | upload-url: ${{ startsWith(github.ref, 'refs/tags/v') && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }}
151 |
152 | permissions:
153 | # required for pypa/gh-action-pypi-publish
154 | id-token: write
155 |
156 | steps:
157 | - name: Checkout
158 | uses: actions/checkout@v4
159 |
160 | - uses: ./.github/actions/uv-setup
161 |
162 | - name: Hatch build
163 | run: |
164 | uv run --only-group release -- hatch --version
165 | uv run --only-group release -- hatch build
166 |
167 | - name: Uploading to
168 | run: echo ${{ env.upload-url }}
169 |
170 | - name: Publish package distributions to PyPI
171 | uses: pypa/gh-action-pypi-publish@release/v1
172 | with:
173 | packages-dir: tmp/dist
174 | repository-url: ${{ env.upload-url }}
175 | # If it's not a version tag, we only care that the publish step runs ok. We don't
176 | # (currently) care that the artifact uploaded to the test repo will keep matching the
177 | # source code in the PR. Without this, we'd have to come up with a way to modify the
178 | # version for each CI publish, which is unneeded complexity.
179 | skip-existing: ${{ !startsWith(github.ref, 'refs/tags/v') }}
180 |
--------------------------------------------------------------------------------
/docs/source/getting-started.rst:
--------------------------------------------------------------------------------
1 | Getting Started
2 | ===============
3 |
4 | .. contents::
5 | :local:
6 |
7 | .. _gs-install:
8 |
9 | Installation
10 | ------------
11 |
12 | Install using `pip`::
13 |
14 | pip install webgrid
15 |
16 | Some basic internationalization features are available via extra requirements::
17 |
18 | pip install webgrid[i18n]
19 |
20 |
21 | .. _gs-manager:
22 |
23 | Manager
24 | -------
25 |
26 | Because WebGrid is generally framework-agnostic, a number of features are segmented into
27 | a grid framework manager. These include items like request object, session, serving files,
28 | etc.::
29 |
30 | class Grid(webgrid.BaseGrid):
31 | manager = webgrid.flask.WebGrid()
32 |
33 | The above may be specified once in the application and used as a base class for all grids.
34 |
35 | Depending on the framework, setting up the manager also requires some additional steps to
36 | integrate with the application.
37 |
38 | Flask Integration
39 | ^^^^^^^^^^^^^^^^^
40 |
41 | In Flask, two integrations are necessary:
42 |
43 | - Set up the connection to SQLAlchemy
44 | - Integrate WebGrid with the Flask application
45 |
46 | For a Flask app using Flask-SQLAlchemy for database connection/session management, the grids
47 | may use the same object that the rest of the app uses for query/data access. As an
48 | extension, the grid manager will register a blueprint on the Flask app to serve static
49 | files.
50 |
51 | The following is an example of a minimal Flask app that is then integrated with WebGrid::
52 |
53 | from flask import Flask
54 | from flask_sqlalchemy import SQLAlchemy
55 | import webgrid
56 |
57 | class Grid(webgrid.BaseGrid):
58 | """Common base grid class for the application."""
59 | manager = webgrid.flask.WebGrid()
60 |
61 | # Minimal Flask app setup
62 | app = Flask(__name__)
63 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
64 | db = SQLAlchemy(app)
65 |
66 | # Integrate WebGrid as an extension
67 | Grid.manager.init_db(db)
68 | Grid.manager.init_app(app)
69 |
70 |
71 | .. _gs-basic-grid:
72 |
73 | Basic Grid
74 | ----------
75 |
76 | Once the application's main `Grid` class has been defined with the appropriate manager, app
77 | grids may be created::
78 |
79 | class PeopleGrid(Grid):
80 | Column('Name', entities.Person.name)
81 | Column('Age', entities.Person.age)
82 | Column('Location', entities.Person.city)
83 |
84 | The options available for setting up grids is truly massive. For starters, some good places
85 | to begin would be:
86 |
87 | - :ref:`column-usage` for ways to configure the column layout
88 | - :ref:`base-grid` for grid options and query setup
89 |
90 |
91 | .. _gs-templates:
92 |
93 | Templates
94 | ---------
95 |
96 | If WebGrid is being used in an HTML context, some template inclusions must be made to support the
97 | header features, default styling, etc. The following will assume a Flask app with a Jinja template
98 | environment; customize to the needed framework and application.
99 |
100 | Note, with the Flask grid manager, static assets are available through the blueprint the manager
101 | adds to the application.
102 |
103 | CSS
104 | ^^^
105 |
106 | Two CSS sheets are required and may be included as follows::
107 |
108 |
109 |
110 |
111 | JS
112 | ^^
113 |
114 | WebGrid requires jQuery to be available. In addition, two JS assets are needed::
115 |
116 |
117 |
118 |
119 | Rendering
120 | ^^^^^^^^^
121 |
122 | Once the templates have all of the required assets included, rendering the grids themselves is
123 | fairly basic::
124 |
125 | {{ grid.html() | safe}}
126 |
127 | The `safe` filter is important for Jinja environments where auto-escape is enabled, which is the
128 | recommended configuration. The grid renderer output contains HTML markup and so must be directly
129 | inserted.
130 |
131 |
132 | .. _gs-i18n:
133 |
134 | Internationalization
135 | --------------------
136 |
137 | WebGrid supports `Babel`-style internationalization of text strings through the `morphi` library.
138 | To use this feature, specify the extra requirements on install::
139 |
140 | pip install webgrid[i18n]
141 |
142 | Currently, English (default) and Spanish are the supported languages in the UI.
143 |
144 | Helpful links
145 | =============
146 |
147 | * https://www.gnu.org/software/gettext/manual/html_node/Mark-Keywords.html
148 | * https://www.gnu.org/software/gettext/manual/html_node/Preparing-Strings.html
149 |
150 |
151 | Message management
152 | ==================
153 |
154 | The ``setup.cfg`` file is configured to handle the standard message extraction commands. For ease of development
155 | and ensuring that all marked strings have translations, a tox environment is defined for testing i18n. This will
156 | run commands to update and compile the catalogs, and specify any strings which need to be added.
157 |
158 | The desired workflow here is to run tox, update strings in the PO files as necessary, run tox again
159 | (until it passes), and then commit the changes to the catalog files.
160 |
161 | .. code::
162 |
163 | tox -e i18n
164 |
--------------------------------------------------------------------------------
/src/webgrid/static/i18n/es/LC_MESSAGES/webgrid.json:
--------------------------------------------------------------------------------
1 | {
2 | "": {
3 | "language": "es",
4 | "plural-forms": "nplurals=2; plural=(n != 1);"
5 | },
6 | " Export to ": " Exportar a ",
7 | "\"{arg}\" grid argument invalid, ignoring": "\"{arg}\" argumento de la grilla no v\u00e1lido, ignorando",
8 | "-- All --": "-- Todas --",
9 | "01-Jan": "01-Enero",
10 | "02-Feb": "02-Feb",
11 | "03-Mar": "03-Marzo",
12 | "04-Apr": "04-Abr",
13 | "05-May": "05-Mayo",
14 | "06-Jun": "06-Jun",
15 | "07-Jul": "07-Jul",
16 | "08-Aug": "08-Agosto",
17 | "09-Sep": "09-Set",
18 | "10-Oct": "10-Oct",
19 | "11-Nov": "11-Nov",
20 | "12-Dec": "12-Dic",
21 | "Add Filter:": "Agregar Filtro:",
22 | "All": "Todas",
23 | "All selected": "Todos seleccionados",
24 | "Apply": "Aplicar",
25 | "False": "Falso",
26 | "Grand Totals": "Totales Generales",
27 | "No": "No",
28 | "No records to display": "No hay registros que mostrar",
29 | "Page": "P\u00e1gina",
30 | "Page Totals": "Total de P\u00e1ginas",
31 | "Per Page": "Por P\u00e1gina",
32 | "Please enter a number.": "Por favor, introduzca un n\u00famero.",
33 | "Please enter a value.": "Por favor, introduzca un valor.",
34 | "Please enter an integer value.": "Introduzca un valor entero.",
35 | "Processor should be callable and take a value argument": "El procesador debe ser invocable y tomar un argumento de valor.",
36 | "Records": "Archivos",
37 | "Search": "Buscar",
38 | "Select all": "Seleccionar todo",
39 | "Sort By": "Ordenar Por",
40 | "True": "Cierto",
41 | "Value must be greater than or equal to {}.": "El valor debe ser mayor o igual a {}.",
42 | "Value must be less than or equal to {}.": "El valor debe ser menor o igual a {}.",
43 | "Value must be one of {}.": "El valor debe ser uno de {}.",
44 | "Yes": "S\u00ed",
45 | "after ": "despu\u00e9s ",
46 | "all": "todas",
47 | "any date": "cualquier fecha",
48 | "before ": "antes de ",
49 | "beginning ": "comenzando",
50 | "between": "entre",
51 | "can't sort on invalid key \"{key}\"": "no se puede ordenar en clave no v\u00e1lida \"{key}\"",
52 | "can't use value_modifier='auto' when option keys are {key_type}": "no se puede usar value_modifier='auto' cuando las teclas de opci\u00f3n son {key_type}",
53 | "contains": "contiene",
54 | "date filter given is out of range": "filtro de fecha dado est\u00e1 fuera de rango",
55 | "date not specified": "fecha no especificada",
56 | "days ago": "hace d\u00edas",
57 | "doesn't contain": "no contiene",
58 | "empty": "vac\u00edo",
59 | "excluding ": "excluyendo ",
60 | "expected filter to be a SQLAlchemy column-like object, but it did not have a \"key\" or \"name\" attribute": "se esperaba que el filtro fuera un objeto tipo columna SQLAlchemy, pero no ten\u00eda un atributo \"key\" o \"name\"",
61 | "expected group to be a subclass of ColumnGroup": "grupo esperado para ser una subclase de ColumnGroup",
62 | "first": "primero",
63 | "greater than or equal": "mayor que o igual",
64 | "in days": "en d\u00edas",
65 | "in less than days": "en menos de d\u00edas",
66 | "in more than days": "en m\u00e1s de d\u00edas",
67 | "in the future": "en el futuro",
68 | "in the past": "en el pasado",
69 | "invalid": "inv\u00e1lido",
70 | "invalid date": "inv\u00e1lido",
71 | "invalid time": "inv\u00e1lido",
72 | "is": "es",
73 | "is not": "no es",
74 | "key \"{key}\" not found in record": "clave \"{key}\" no encontrada en el registro",
75 | "last": "\u00faltimo",
76 | "last month": "el mes pasado",
77 | "last week": "la semana pasada",
78 | "less than days ago": "hace menos de un d\u00eda",
79 | "less than or equal": "menor o igual",
80 | "more than days ago": "hace m\u00e1s de un d\u00eda",
81 | "must specify either min or max for range validation": "debe especificar m\u00ednimo o m\u00e1ximo para la validaci\u00f3n del rango",
82 | "next": "siguiente",
83 | "no": "no",
84 | "not between": "no entre",
85 | "not empty": "no vac\u00edo",
86 | "of {page_count}": "de {page_count}",
87 | "previous": "anterior",
88 | "reset": "reiniciar",
89 | "select month": "seleccione mes",
90 | "the filter was a class type, but no column-like object is available from \"key\" to pass in as as the first argument": "el filtro era un tipo de clase, pero no hay ning\u00fan objeto similar a una columna en \"key\" para pasar como primer argumento",
91 | "this month": "este mes",
92 | "this week": "esta semana",
93 | "this year": "este a\u00f1o",
94 | "today": "hoy",
95 | "unrecognized operator: {op}": "operador no reconocido: {op}",
96 | "up to ": "arriba a ",
97 | "value_modifier argument set to \"auto\", but the options set is empty and the type can therefore not be determined for {name}": "El argumento value_modifier est\u00e1 establecido en \"auto\", pero el conjunto de opciones est\u00e1 vac\u00edo y, por lo tanto, el tipo no se puede determinar para {name}",
98 | "value_modifier must be the string \"auto\", have a \"process\" attribute, or be a callable": "value_modifier debe ser la cadena \"auto\", tener un atributo \"process\" o ser invocable",
99 | "yes": "s\u00ed",
100 | "{count} of {total} selected": "{count} de {total} seleccionados",
101 | "{descriptor}{date}": "{descriptor}{date}",
102 | "{descriptor}{first_date} - {second_date}": "{descriptor}{first_date} - {second_date}",
103 | "{label} ({num} record):": [
104 | "{label} ({num} record):",
105 | "{label} ({num} records):"
106 | ],
107 | "{label} DESC": "{label} DESC"
108 | }
109 |
--------------------------------------------------------------------------------
/tests/webgrid_ta/grids.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from webgrid import BaseGrid as BaseGrid
4 | from webgrid import (
5 | Column,
6 | ColumnGroup,
7 | DateColumn,
8 | DateTimeColumn,
9 | EnumColumn,
10 | LinkColumnBase,
11 | NumericColumn,
12 | TimeColumn,
13 | YesNoColumn,
14 | )
15 | from webgrid.filters import (
16 | DateFilter,
17 | DateTimeFilter,
18 | IntFilter,
19 | Operator,
20 | OptionsEnumFilter,
21 | OptionsFilterBase,
22 | TextFilter,
23 | TimeFilter,
24 | ops,
25 | )
26 | from webgrid.renderers import CSV
27 | from webgrid_ta.extensions import lazy_gettext as _
28 |
29 | from .app import webgrid
30 | from .model.entities import AccountType, ArrowRecord, Person, Radio, Status, Stopwatch
31 |
32 |
33 | class Grid(BaseGrid):
34 | manager = webgrid
35 |
36 |
37 | class FirstNameColumn(LinkColumnBase):
38 | def create_url(self, record):
39 | return f'/person-edit/{record.id}'
40 |
41 |
42 | class FullNameColumn(LinkColumnBase):
43 | def extract_data(self, record):
44 | return _('{record.firstname} {record.lastname}', record=record)
45 |
46 | def create_url(self, record):
47 | return f'/person-edit/{record.id}'
48 |
49 |
50 | class EmailsColumn(Column):
51 | def extract_data(self, recordset):
52 | return ', '.join([e.email for e in recordset.Person.emails])
53 |
54 |
55 | class StatusFilter(OptionsFilterBase):
56 | operators = (
57 | Operator('o', _('open'), None),
58 | ops.is_,
59 | ops.not_is,
60 | Operator('c', _('closed'), None),
61 | ops.empty,
62 | ops.not_empty,
63 | )
64 | options_from = Status.pairs
65 |
66 |
67 | class PeopleGrid(Grid):
68 | session_on = True
69 |
70 | FirstNameColumn(_('First Name'), Person.firstname, TextFilter)
71 | FullNameColumn(_('Full Name'))
72 | YesNoColumn(_('Active'), Person.inactive, reverse=True)
73 | EmailsColumn(_('Emails'))
74 | Column(_('Status'), Status.label.label('status'), StatusFilter(Status.id))
75 | DateTimeColumn(_('Created'), Person.createdts, DateTimeFilter)
76 | DateColumn(_('Due Date'), 'due_date')
77 | Column(_('State'), Person.state, render_in='xlsx')
78 | NumericColumn(_('Number'), Person.numericcol, has_subtotal=True)
79 | EnumColumn(
80 | _('Account Type'),
81 | Person.account_type,
82 | OptionsEnumFilter(Person.account_type, enum_type=AccountType),
83 | )
84 |
85 | def query_prep(self, query, has_sort, has_filters):
86 | query = (
87 | query.add_columns(
88 | Person.id,
89 | Person.lastname,
90 | Person.due_date,
91 | Person.account_type,
92 | )
93 | .add_entity(Person)
94 | .outerjoin(Person.status)
95 | )
96 |
97 | # default sort
98 | if not has_sort:
99 | query = query.order_by(Person.id)
100 |
101 | return query
102 |
103 |
104 | class PeopleGridByConfig(PeopleGrid):
105 | query_outer_joins = (Person.status,)
106 | query_default_sort = (Person.id,)
107 |
108 | def query_prep(self, query, has_sort, has_filters):
109 | query = query.add_columns(
110 | Person.id,
111 | Person.lastname,
112 | Person.due_date,
113 | Person.account_type,
114 | ).add_entity(Person)
115 |
116 | return query
117 |
118 |
119 | class DefaultOpGrid(Grid):
120 | session_on = True
121 |
122 | FirstNameColumn(
123 | _('First Name'),
124 | Person.firstname,
125 | TextFilter(Person.firstname, default_op=ops.eq),
126 | )
127 |
128 |
129 | class ArrowGrid(Grid):
130 | session_on = True
131 |
132 | DateTimeColumn(_('Created'), ArrowRecord.created_utc, DateTimeFilter)
133 |
134 | def query_prep(self, query, has_sort, has_filters):
135 | # default sort
136 | if not has_sort:
137 | query = query.order_by(ArrowRecord.id)
138 |
139 | return query
140 |
141 |
142 | class ArrowCSVGrid(Grid):
143 | session_on = True
144 | allowed_export_targets: ClassVar = {'csv': CSV}
145 | DateTimeColumn(_('Created'), ArrowRecord.created_utc, DateTimeFilter)
146 |
147 | def query_prep(self, query, has_sort, has_filters):
148 | # default sort
149 | if not has_sort:
150 | query = query.order_by(ArrowRecord.id)
151 |
152 | return query
153 |
154 |
155 | class StopwatchGrid(Grid):
156 | session_on = True
157 |
158 | class LapGroup1(ColumnGroup):
159 | label = 'Lap 1'
160 | class_ = 'lap-1'
161 |
162 | lap_group_2 = ColumnGroup('Lap 2', class_='lap-2')
163 | lap_group_3 = ColumnGroup('Lap 3', class_='lap-3')
164 |
165 | Column('ID', Stopwatch.id)
166 | Column('Label', Stopwatch.label, TextFilter)
167 | DateTimeColumn('Start Time', Stopwatch.start_time_lap1, group=LapGroup1)
168 | DateTimeColumn('Stop Time', Stopwatch.stop_time_lap1, group=LapGroup1)
169 | Column('Category', Stopwatch.category, TextFilter)
170 | DateTimeColumn('Start Time', Stopwatch.start_time_lap2, group=lap_group_2)
171 | DateTimeColumn('Stop Time', Stopwatch.stop_time_lap2, group=lap_group_2)
172 | DateTimeColumn('Start Time', Stopwatch.start_time_lap3, group=lap_group_3)
173 | DateTimeColumn('Stop Time', Stopwatch.stop_time_lap3, group=lap_group_3)
174 |
175 | def query_prep(self, query, has_sort, has_filters):
176 | # default sort
177 | if not has_sort:
178 | query = query.order_by(Stopwatch.id)
179 |
180 | return query
181 |
182 |
183 | class TemporalGrid(Grid):
184 | session_on = True
185 |
186 | DateTimeColumn(_('Created'), Person.createdts, DateTimeFilter)
187 | DateColumn(_('Due Date'), Person.due_date, DateFilter)
188 | TimeColumn(_('Start Time'), Person.start_time, TimeFilter)
189 |
190 |
191 | class RadioGrid(Grid):
192 | session_on = True
193 |
194 | Column('Make', Radio.make, TextFilter)
195 | Column('Model', Radio.model, TextFilter)
196 | Column('Year', Radio.year, IntFilter)
197 |
--------------------------------------------------------------------------------
/src/webgrid/static/webgrid.css:
--------------------------------------------------------------------------------
1 | .datagrid p.no-records {
2 | padding: 1.5em;
3 | margin-bottom: 2em;
4 | background-color: #FFF9DD;
5 | border: solid 1px #DBDBB3;
6 | font-size: 1.2em;
7 | }
8 |
9 | .datagrid form.header {
10 | border: solid 1px #ccc;
11 | padding: 5px;
12 | background: #efefef;
13 | margin-bottom: 10px;
14 | }
15 |
16 | .datagrid form.header > div {
17 | display: flex;
18 | justify-content: space-between;
19 | flex-wrap: wrap;
20 | }
21 |
22 | .datagrid form.header > div.bottom {
23 | /* wrap-reverse so that the paging controls are above the "Apply" button in mobile */
24 | flex-wrap: wrap-reverse;
25 | }
26 |
27 | .datagrid form.header .links {
28 | text-align: left;
29 | /* use long hand form to avoid IE bugs */
30 | flex-grow: 1;
31 | flex-shrink: 0;
32 | flex-basis: auto;
33 | }
34 |
35 | .datagrid form.header .links a {
36 | /* push the "reset" link away from the "Apply" button just a bit more */
37 | margin-left: .75rem;
38 | }
39 |
40 | .datagrid table.filters {
41 | width: auto;
42 | margin-bottom: 10px;
43 | }
44 |
45 | .datagrid table.filters th,
46 | .datagrid table.filters td {
47 | margin: 0;
48 | padding: 2px 10px 0 0;
49 | }
50 |
51 | .datagrid table.filters tr td:last-child {
52 | min-width: 300px;
53 | }
54 |
55 | .datagrid table.filters button.ui-state-default span {
56 | color: #000000;
57 | font-size: 0.9em;
58 | }
59 |
60 | .datagrid table.filters button.ui-corner-all {
61 | -moz-border-radius: 0;
62 | }
63 |
64 | .datagrid table.filters button.ui-state-default {
65 | background: #FFFFFF;
66 | border: 1px inset #F0F0F0;
67 | }
68 |
69 | .ui-multiselect-header a:hover {
70 | color: #FFFFFF;
71 | }
72 |
73 | .datagrid table.filters button.ui-multiselect {
74 | padding: 1px;
75 | }
76 |
77 | .datagrid table.filters div.inputs1,
78 | .datagrid table.filters div.inputs2 {
79 | display: inline-block;
80 | }
81 |
82 | .datagrid tr.add-filter th, .datagrid div.add-filter label {
83 | font-weight: normal;
84 | font-style: italic;
85 | /* keep "Add Filter" from wrapping in mobile */
86 | white-space: nowrap;
87 | }
88 |
89 | .datagrid div.add-filter {
90 | margin-left: 2em;
91 | display: inline;
92 | }
93 |
94 | .datagrid .paging {
95 | flex-basis: auto;
96 | }
97 |
98 | .datagrid .header dl {
99 | display: flex;
100 | margin: 0;
101 | align-items: center;
102 | /* Let paging controls wrap if needed when in mobile */
103 | flex-wrap: wrap;
104 | }
105 |
106 | .datagrid .header dl dt,
107 | .datagrid .header dl dd {
108 | /* use long hand form to avoid IE bugs */
109 | flex-grow: 1;
110 | flex-shrink: 0;
111 | flex-basis: auto;
112 | margin: 0;
113 | }
114 |
115 | .datagrid .header dl dt {
116 | margin-left: 1.25rem;
117 | padding-left: 1.25rem;
118 | margin-right: .75rem;
119 | border-left: solid 1px #bbb;
120 | }
121 |
122 | .datagrid .header dl dt:first-child {
123 | border: none;
124 | /* In mobile, keeping the mar./pad. looks bad. Has no visible affect in desktop */
125 | margin-left: 0;
126 | padding-left: 0;
127 | }
128 |
129 | .datagrid .paging input {
130 | /* Firefox = 5 chars, Chrome = 6 chars */
131 | width: 6rem;
132 | /* hide up/down arrows to make more room for visible text */
133 | -webkit-appearance: textfield;
134 | -moz-appearance: textfield;
135 | appearance: textfield;
136 | }
137 |
138 | .datagrid .paging input[type=number]::-webkit-inner-spin-button,
139 | .datagrid .paging input[type=number]::-webkit-outer-spin-button {
140 | /* Chrome: hide up/down arrows to make more room for visible text */
141 | -webkit-appearance: none;
142 | }
143 |
144 |
145 | .datagrid div.footer p {
146 | float: left;
147 | }
148 |
149 | .datagrid .footer {
150 | display: flex;
151 | justify-content: space-between;
152 | }
153 |
154 | .datagrid div.footer ul.paging li {
155 | display: inline;
156 | margin-left: 10px;
157 | }
158 |
159 | .datagrid div.footer ul.paging li.dead {
160 | color: #999;
161 | }
162 |
163 | div.datagrid-filter-controls-wrapper label {
164 | font-weight: bold;
165 | font-size: .8em;
166 | padding-left: 3px;
167 | }
168 |
169 | div.filteron-wrapper {
170 | float: left;
171 | }
172 |
173 | select.datagrid-filteronop {
174 | float: left;
175 | }
176 |
177 | div.filterfor-wrapper {
178 | float: left;
179 | }
180 |
181 |
182 | form.datagrid-form input,
183 | form.datagrid-form select {
184 | margin-bottom: 1px;
185 | }
186 |
187 | .manage-add-link {
188 | float: right;
189 | margin-top: 0.8em;
190 | }
191 |
192 | .datagrid table.records,
193 | table.datagrid {
194 | margin-top: 1em;
195 | }
196 |
197 | .datagrid table.records th,
198 | .datagrid table.records td,
199 | table.datagrid th,
200 | table.datagrid td {
201 | padding: .5em 1em;
202 | margin: 0px;
203 | text-align: center;
204 | }
205 |
206 | .datagrid table.records td.ta-left,
207 | table.datagrid td.ta-left {
208 | text-align: left;
209 | }
210 |
211 | .datagrid table.records th,
212 | table.datagrid th {
213 | font-weight: bold;
214 | color: #ececec;
215 | background-color: #31527C;
216 | text-align: center;
217 | border-bottom: solid 1px white;
218 | border-right: solid 1px white;
219 | border-top: solid 1px white;
220 | }
221 |
222 | .datagrid table.records th.buffer {
223 | background-color: transparent;
224 | }
225 |
226 | .datagrid table.records th a,
227 | table.datagrid th a {
228 | color: #ececec;
229 | }
230 |
231 | .datagrid table.records th a.sort-asc,
232 | table.datagrid th a.sort-asc {
233 | padding-right: 40px;
234 | background: url(th_arrow_up.png) no-repeat right -7px;
235 | }
236 |
237 | .datagrid table.records th a.sort-desc,
238 | table.datagrid th a.sort-desc {
239 | padding-right: 40px;
240 | background: url(th_arrow_down.png) no-repeat right -7px;
241 | }
242 |
243 | .datagrid table.records td,
244 | table.datagrid td {
245 | border-bottom: solid 1px white;
246 | border-right: solid 1px white;
247 | }
248 |
249 | .datagrid table.records tr.even td,
250 | table.datagrid tr.even td{
251 | background-color: #d5d5d5;
252 | }
253 |
254 | .datagrid table.records td,
255 | .datagrid table.records tr.odd td,
256 | table.datagrid td,
257 | table.datagrid tr.odd td{
258 | background-color: #e5e5e5;
259 | }
260 |
261 | .datagrid table.records td a.edit_link,
262 | .datagrid table.records td a.delete_link,
263 | table.datagrid td a.edit_link,
264 | table.datagrid td a.delete_link {
265 | float: left;
266 | height: 16px;
267 | width: 16px;
268 | text-indent: -9999px;
269 | }
270 |
271 | .datagrid table.records td a.edit_link:focus,
272 | .datagrid table.records td a.delete_link:focus,
273 | table.datagrid td a.edit_link:focus,
274 | table.datagrid td a.delete_link:focus {
275 | outline: 0;
276 | }
277 |
278 | .datagrid table.records td a.edit_link,
279 | table.datagrid td a.edit_link {
280 | background: url(application_form_edit.png);
281 | margin-left: 1em;
282 | }
283 |
284 | .datagrid table.records td a.delete_link,
285 | table.datagrid td a.delete_link {
286 | background: url(delete.png);
287 | }
288 |
289 | /* multipleselect style overrides */
290 | div.ms-parent input[type="checkbox"] {
291 | top: auto;
292 | margin-right: 0.25em;
293 | }
294 |
295 | .ms-choice {
296 | height: 20px;
297 | line-height: 1.5;
298 | -webkit-border-radius: 0;
299 | -moz-border-radius: 0;
300 | border-radius: 0;
301 | }
302 |
303 | .ms-choice > div {
304 | height: 19px;
305 | background: url('multiple-select.png') left top no-repeat !important;
306 | }
307 |
308 | .ms-drop {
309 | -webkit-border-radius: 0;
310 | -moz-border-radius: 0;
311 | border-radius: 0;
312 | }
313 |
314 | .ms-drop input[type="checkbox"] {
315 | top: 0;
316 | }
317 |
318 | .ms-choice > span {
319 | padding-top: 1px;
320 | }
321 |
322 | .ms-drop label {
323 | font-weight: normal;
324 | }
325 |
326 | .datagrid form.header hr.flex-break {
327 | flex-basis: 100%;
328 | border: none;
329 | margin: 0;
330 | }
331 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/test_testing.py:
--------------------------------------------------------------------------------
1 | import datetime as dt
2 | from io import BytesIO
3 | from unittest import mock
4 |
5 | import pytest
6 | import xlsxwriter
7 |
8 | from webgrid import testing
9 | from webgrid_ta.grids import RadioGrid, TemporalGrid
10 | from webgrid_ta.model.entities import Person, db
11 |
12 |
13 | def setup_module():
14 | import flask
15 |
16 | assert not flask.request
17 |
18 |
19 | class TestAssertListEqual:
20 | """Verify the `assert_list_equal` method performs as expected"""
21 |
22 | def test_simple_equivalents(self):
23 | testing.assert_list_equal([], [])
24 | testing.assert_list_equal([1, 2, 3], [1, 2, 3])
25 | testing.assert_list_equal((1, 2, 3), [1, 2, 3])
26 | testing.assert_list_equal('123', '123')
27 |
28 | def test_different_lengths(self):
29 | with pytest.raises(AssertionError):
30 | testing.assert_list_equal([], [1])
31 |
32 | with pytest.raises(AssertionError):
33 | testing.assert_list_equal([1], [])
34 |
35 | def test_different_elements(self):
36 | with pytest.raises(AssertionError):
37 | testing.assert_list_equal([1, 2, 3], [1, 2, 4])
38 |
39 | def test_order_is_significant(self):
40 | with pytest.raises(AssertionError):
41 | testing.assert_list_equal([1, 2, 3], [2, 3, 1])
42 |
43 | def test_generators(self):
44 | testing.assert_list_equal((x for x in range(3)), (x for x in range(3)))
45 | testing.assert_list_equal((x for x in range(3)), [0, 1, 2])
46 | testing.assert_list_equal([0, 1, 2], (x for x in range(3)))
47 |
48 |
49 | class TestAssertRenderedXlsxMatches:
50 | def setup_method(self):
51 | self.stream = BytesIO()
52 | self.workbook = xlsxwriter.Workbook(self.stream, options={'in_memory': True})
53 | self.sheet = self.workbook.add_worksheet('sheet1')
54 |
55 | self.headers_written = None
56 |
57 | def test_openpyxl_requirement(self):
58 | with (
59 | mock.patch('webgrid.testing.openpyxl', None),
60 | pytest.raises(Exception, match=r'openpyxl is required.*'),
61 | ):
62 | self.assert_matches([], [])
63 |
64 | def set_headers(self, headers):
65 | assert self.headers_written is None
66 | self.set_values(headers)
67 | self.headers_written = len(headers)
68 |
69 | def set_values(self, values):
70 | row_offset = 0
71 |
72 | if self.headers_written:
73 | row_offset = self.headers_written
74 |
75 | for row_index, row in enumerate(values, start=row_offset):
76 | for col_index, value in enumerate(row):
77 | self.sheet.write(row_index, col_index, value)
78 |
79 | def assert_matches(self, xlsx_headers, xlsx_rows):
80 | self.workbook.close()
81 | testing.assert_rendered_xlsx_matches(self.workbook, xlsx_headers, xlsx_rows)
82 |
83 | def test_empty_xlsx(self):
84 | with pytest.raises(AssertionError):
85 | testing.assert_rendered_xlsx_matches(b'', None, None)
86 |
87 | with pytest.raises(AssertionError):
88 | testing.assert_rendered_xlsx_matches(None, None, None)
89 |
90 | with pytest.raises(AssertionError):
91 | testing.assert_rendered_xlsx_matches(None, [], [])
92 |
93 | def test_blank_workbook(self):
94 | self.assert_matches([], [])
95 |
96 | def test_single_header(self):
97 | self.set_headers([['Foo']])
98 | self.assert_matches([['Foo']], [])
99 |
100 | def test_multiple_headers(self):
101 | self.set_headers([['Foo', 'Bar']])
102 | self.assert_matches([['Foo', 'Bar']], [])
103 |
104 | def test_single_row(self):
105 | self.set_values([[1, 2, 3]])
106 | self.assert_matches([], [[1, 2, 3]])
107 |
108 | def test_multiple_rows(self):
109 | self.set_values([[1, 2, 3], [2, 3, 4]])
110 |
111 | self.assert_matches([], [[1, 2, 3], [2, 3, 4]])
112 |
113 | def test_headers_and_rows(self):
114 | self.set_headers(
115 | [
116 | ['Foo', 'Bar'],
117 | ['Snoopy', 'Dog'],
118 | ],
119 | )
120 | self.set_values([[1, 2], [2, 3], [3, 4]])
121 |
122 | self.assert_matches(
123 | [
124 | ['Foo', 'Bar'],
125 | ['Snoopy', 'Dog'],
126 | ],
127 | [[1, 2], [2, 3], [3, 4]],
128 | )
129 |
130 | def test_value_types(self):
131 | self.set_values([[1, 1.23, 'hello', None, True, False]])
132 |
133 | self.assert_matches([], [[1, 1.23, 'hello', None, True, False]])
134 |
135 | def test_none_is_mangled(self):
136 | self.set_values([[None, 1, 1.23, 'hello', None]])
137 |
138 | # the right `None` gets dropped
139 | self.assert_matches([], [[None, 1, 1.23, 'hello']])
140 |
141 |
142 | class TestGridBase(testing.GridBase):
143 | grid_cls = TemporalGrid
144 |
145 | sort_tests = (
146 | ('createdts', 'persons.createdts'),
147 | ('due_date', 'persons.due_date'),
148 | ('start_time', 'persons.start_time'),
149 | )
150 |
151 | @classmethod
152 | def setup_class(cls):
153 | if db.engine.dialect.name != 'sqlite':
154 | pytest.skip('sqlite-only test')
155 |
156 | @property
157 | def filters(self):
158 | return (
159 | (
160 | 'createdts',
161 | 'eq',
162 | dt.datetime(2018, 1, 1, 5, 30),
163 | "WHERE persons.createdts BETWEEN '2018-01-01 05:30:00.000000'",
164 | ),
165 | ('due_date', 'eq', dt.date(2018, 1, 1), "WHERE persons.due_date = '2018-01-01'"),
166 | (
167 | 'start_time',
168 | 'eq',
169 | dt.time(1, 30).strftime('%H:%M'),
170 | "WHERE persons.start_time BETWEEN CAST('01:30:00.000000' AS TIME)",
171 | ),
172 | )
173 |
174 | def setup_method(self, _):
175 | Person.delete_cascaded()
176 | Person.testing_create(
177 | createdts=dt.datetime(2018, 1, 1, 5, 30),
178 | due_date=dt.date(2019, 5, 31),
179 | start_time=dt.time(1, 30),
180 | )
181 |
182 | def test_expected_rows(self):
183 | self.expect_table_header((('Created', 'Due Date', 'Start Time'),))
184 | self.expect_table_contents((('01/01/2018 05:30 AM', '05/31/2019', '01:30 AM'),))
185 |
186 | def test_query_string_applied(self):
187 | self.expect_table_contents(
188 | (),
189 | _query_string='op(due_date)=gte&v1(due_date)=2019-06-01',
190 | )
191 |
192 |
193 | class TestGridBasePG(testing.GridBase):
194 | grid_cls = TemporalGrid
195 |
196 | sort_tests = (
197 | ('createdts', 'persons.createdts'),
198 | ('due_date', 'persons.due_date'),
199 | ('start_time', 'persons.start_time'),
200 | )
201 |
202 | @classmethod
203 | def setup_class(cls):
204 | if db.engine.dialect.name != 'postgresql':
205 | pytest.skip('postgres-only test')
206 |
207 | @property
208 | def filters(self):
209 | return (
210 | (
211 | 'createdts',
212 | 'eq',
213 | dt.datetime(2018, 1, 1, 5, 30),
214 | "WHERE persons.createdts BETWEEN '2018-01-01 05:30:00.000000'",
215 | ),
216 | ('due_date', 'eq', dt.date(2018, 1, 1), "WHERE persons.due_date = '2018-01-01'"),
217 | (
218 | 'start_time',
219 | 'eq',
220 | dt.time(1, 30).strftime('%H:%M'),
221 | "WHERE persons.start_time BETWEEN CAST('01:30:00.000000' AS TIME WITHOUT TIME ZONE)", # noqa: E501
222 | ),
223 | )
224 |
225 |
226 | class TestGridBaseMSSQLDates(testing.MSSQLGridBase):
227 | grid_cls = TemporalGrid
228 |
229 | sort_tests = (
230 | ('createdts', 'persons.createdts'),
231 | ('due_date', 'persons.due_date'),
232 | ('start_time', 'persons.start_time'),
233 | )
234 |
235 | @classmethod
236 | def setup_class(cls):
237 | if db.engine.dialect.name != 'mssql':
238 | pytest.skip('sql server-only test')
239 |
240 | @property
241 | def filters(self):
242 | return (
243 | (
244 | 'createdts',
245 | 'eq',
246 | dt.datetime(2018, 1, 1, 5, 30),
247 | "WHERE persons.createdts BETWEEN '2018-01-01 05:30:00.000000'",
248 | ),
249 | ('due_date', 'eq', '2018-01-01', "WHERE persons.due_date = '2018-01-01'"),
250 | (
251 | 'start_time',
252 | 'eq',
253 | dt.time(1, 30).strftime('%H:%M'),
254 | "WHERE persons.start_time BETWEEN CAST('01:30:00.000000' AS TIME)",
255 | ),
256 | )
257 |
258 |
259 | class TestGridBaseMSSQLStrings(testing.MSSQLGridBase):
260 | grid_cls = RadioGrid
261 |
262 | @property
263 | def filters(self):
264 | return (
265 | ('make', 'eq', 'foo', "WHERE sabwp_radios.make = 'foo'"),
266 | ('model', 'eq', 'foo', "WHERE sabwp_radios.model = 'foo'"),
267 | ('year', 'eq', '1945', 'WHERE sabwp_radios.year = 1945'),
268 | )
269 |
270 | @classmethod
271 | def setup_class(cls):
272 | if db.engine.dialect.name != 'mssql':
273 | pytest.skip('sql server-only test')
274 |
--------------------------------------------------------------------------------
/tests/webgrid_tests/test_api.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 | from unittest import mock
3 |
4 | import flask
5 | import flask_webtest
6 | import flask_wtf
7 | import pytest
8 |
9 | from webgrid import BaseGrid, Column
10 | from webgrid.flask import WebGrid, WebGridAPI
11 | from webgrid.renderers import JSON, Renderer
12 |
13 |
14 | @pytest.fixture
15 | def app():
16 | app = flask.Flask(__name__)
17 | app.secret_key = 'only-testing-api'
18 | app.config['TESTING'] = True
19 | yield app
20 |
21 |
22 | @pytest.fixture
23 | def csrf(app):
24 | csrf = flask_wtf.CSRFProtect()
25 | csrf.init_app(app)
26 | yield csrf
27 |
28 |
29 | @pytest.fixture
30 | def test_app(app):
31 | yield flask_webtest.TestApp(app)
32 |
33 |
34 | @pytest.fixture
35 | def api_manager(app):
36 | manager = WebGridAPI()
37 | manager.init_app(app)
38 | yield manager
39 |
40 |
41 | @pytest.fixture
42 | def api_manager_with_csrf(csrf, app):
43 | """Technically, this is the same as having ``csrf, api_manager`` as fixtures on
44 | the test method and turning on csrf_protection. But the order matters, so for
45 | consistent results this separate combined fixture is provided."""
46 | manager = WebGridAPI()
47 | manager.csrf_protection = True
48 | manager.init_app(app)
49 | yield manager
50 |
51 |
52 | class DummyMixin:
53 | Column('Foo', 'foo')
54 |
55 | @property
56 | def records(self):
57 | return [{'foo': 'bar'}]
58 |
59 | @property
60 | def record_count(self):
61 | return 1
62 |
63 |
64 | def create_grid_cls(grid_manager):
65 | class Grid(DummyMixin, BaseGrid):
66 | manager = grid_manager
67 | allowed_export_targets: ClassVar = {'json': JSON}
68 |
69 | return Grid
70 |
71 |
72 | def register_grid(manager, identifier, grid_cls):
73 | manager.register_grid(identifier, grid_cls)
74 |
75 |
76 | class TestFlaskAPI:
77 | def post_data(self, **kwargs):
78 | data = {
79 | 'search_expr': '',
80 | 'filters': {},
81 | 'sort': [],
82 | 'paging': {'per_page': 50, 'on_page': 1},
83 | 'export_to': None,
84 | }
85 | data.update(**kwargs)
86 | return data
87 |
88 | def test_default_route(self, api_manager, test_app):
89 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
90 | resp = test_app.post_json('/webgrid-api/foo', {})
91 | assert resp.json['records'] == [{'foo': 'bar'}]
92 |
93 | def test_default_route_count(self, api_manager, test_app):
94 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
95 | resp = test_app.post_json('/webgrid-api/foo/count', {})
96 | assert resp.json['count'] == 1
97 |
98 | def test_custom_route(self, app, test_app):
99 | class GridManager(WebGridAPI):
100 | api_route = '/custom-routing/'
101 |
102 | api_manager = GridManager()
103 | api_manager.init_app(app)
104 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
105 | resp = test_app.post_json('/custom-routing/foo', {})
106 | assert resp.json['records'] == [{'foo': 'bar'}]
107 |
108 | def test_grid_not_registered(self, api_manager, test_app):
109 | test_app.post_json('/webgrid-api/foo', {}, status=404)
110 |
111 | def test_grid_registered_twice(self, api_manager, test_app):
112 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
113 | with pytest.raises(Exception, match='API grid_ident must be unique'):
114 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
115 |
116 | def test_csrf_missing_token(self, api_manager_with_csrf, test_app):
117 | register_grid(api_manager_with_csrf, 'foo', create_grid_cls(api_manager_with_csrf))
118 | resp = test_app.post_json('/webgrid-api/foo', {}, status=400)
119 | assert 'The CSRF token is missing.' in resp
120 |
121 | def test_csrf_missing_token_but_no_protection(self, api_manager, test_app):
122 | register_grid(api_manager, 'foo', create_grid_cls(api_manager))
123 | test_app.post_json('/webgrid-api/foo', {}, status=200)
124 |
125 | def test_csrf_missing_session(self, api_manager_with_csrf, test_app):
126 | register_grid(api_manager_with_csrf, 'foo', create_grid_cls(api_manager_with_csrf))
127 | with test_app.app.test_request_context():
128 | csrf_token = flask_wtf.csrf.generate_csrf()
129 | resp = test_app.post_json(
130 | '/webgrid-api/foo',
131 | {},
132 | headers={'X-CSRFToken': csrf_token},
133 | status=400,
134 | )
135 | assert 'The CSRF session token is missing.' in resp
136 |
137 | def test_csrf_invalid(self, api_manager_with_csrf, test_app):
138 | register_grid(api_manager_with_csrf, 'foo', create_grid_cls(api_manager_with_csrf))
139 | test_app.get('/webgrid-api/testing/__csrf__')
140 | resp = test_app.post_json(
141 | '/webgrid-api/foo',
142 | {},
143 | headers={'X-CSRFToken': 'my-bad-token'},
144 | status=400,
145 | )
146 | assert 'The CSRF token is invalid.' in resp
147 |
148 | def test_csrf_protected(self, api_manager_with_csrf, test_app):
149 | register_grid(api_manager_with_csrf, 'foo', create_grid_cls(api_manager_with_csrf))
150 | csrf_token = test_app.get('/webgrid-api/testing/__csrf__').body
151 | resp = test_app.post_json('/webgrid-api/foo', {}, headers={'X-CSRFToken': csrf_token})
152 | assert resp.json['records'] == [{'foo': 'bar'}]
153 |
154 | def test_csrf_token_route_only_testing(self, app, csrf, test_app):
155 | manager = WebGridAPI()
156 | with mock.patch.dict(app.config, {'TESTING': False}):
157 | manager.init_app(app)
158 |
159 | test_app.get('/webgrid-api/testing/__csrf__', status=404)
160 |
161 | def test_grid_has_renderer(self, api_manager, test_app):
162 | class MyRenderer(Renderer):
163 | name = 'test'
164 |
165 | def render(self):
166 | return '"render result"'
167 |
168 | class Grid(DummyMixin, BaseGrid):
169 | manager = api_manager
170 | allowed_export_targets: ClassVar = {'json': MyRenderer}
171 |
172 | register_grid(api_manager, 'foo', Grid)
173 | resp = test_app.post_json('/webgrid-api/foo', {})
174 | assert resp.json == 'render result'
175 |
176 | def test_grid_default_renderer(self, api_manager, test_app):
177 | class Grid(DummyMixin, BaseGrid):
178 | manager = api_manager
179 |
180 | register_grid(api_manager, 'foo', Grid)
181 | resp = test_app.post_json('/webgrid-api/foo', {})
182 | assert resp.json['records'] == [{'foo': 'bar'}]
183 |
184 | def test_grid_auth(self, api_manager, test_app):
185 | class Grid(DummyMixin, BaseGrid):
186 | manager = api_manager
187 |
188 | def check_auth(self):
189 | if b'bad' in flask.request.query_string:
190 | flask.abort(403)
191 |
192 | register_grid(api_manager, 'foo', Grid)
193 | resp = test_app.post_json('/webgrid-api/foo', {})
194 | assert resp.json['records'] == [{'foo': 'bar'}]
195 |
196 | test_app.post_json('/webgrid-api/foo?bad', {}, status=403)
197 |
198 | def test_grid_args_applied(self, api_manager, test_app):
199 | Grid = create_grid_cls(api_manager)
200 | Grid.record_count = 1
201 | register_grid(api_manager, 'foo', Grid)
202 | post_data = self.post_data(sort=[{'key': 'foo', 'flag_desc': True}])
203 | resp = test_app.post_json('/webgrid-api/foo', post_data)
204 | assert resp.json['settings']['sort'] == [{'key': 'foo', 'flag_desc': True}]
205 |
206 | def test_grid_args_applied_alt_manager(self, api_manager, test_app):
207 | Grid = create_grid_cls(WebGrid())
208 | Grid.record_count = 1
209 | register_grid(api_manager, 'foo', Grid)
210 | post_data = self.post_data(sort=[{'key': 'foo', 'flag_desc': True}])
211 | resp = test_app.post_json('/webgrid-api/foo', post_data)
212 | assert resp.json['settings']['sort'] == [{'key': 'foo', 'flag_desc': True}]
213 |
214 | def test_grid_auth_precedes_args(self, api_manager, test_app):
215 | class Grid(DummyMixin, BaseGrid):
216 | manager = api_manager
217 |
218 | def check_auth(self):
219 | flask.abort(403)
220 |
221 | register_grid(api_manager, 'foo', Grid)
222 | with mock.patch.object(api_manager, 'get_args', autospec=True, spec_set=True) as m_args:
223 | test_app.post_json('/webgrid-api/foo', {}, status=403)
224 |
225 | assert not m_args.call_count
226 |
227 | def test_grid_export(self, api_manager, test_app):
228 | class Grid(DummyMixin, BaseGrid):
229 | manager = api_manager
230 |
231 | register_grid(api_manager, 'foo', Grid)
232 | post_data = self.post_data(export_to='xlsx')
233 | resp = test_app.post_json('/webgrid-api/foo', post_data)
234 | assert 'spreadsheetml' in resp.headers['Content-Type']
235 |
236 | def test_grid_export_limit_exceeded(self, api_manager, test_app):
237 | class Grid(DummyMixin, BaseGrid):
238 | manager = api_manager
239 |
240 | @property
241 | def record_count(self):
242 | return 2000000
243 |
244 | register_grid(api_manager, 'foo', Grid)
245 | post_data = self.post_data(export_to='xlsx')
246 | resp = test_app.post_json('/webgrid-api/foo', post_data)
247 | assert resp.json['error'] == 'too many records for render target'
248 |
--------------------------------------------------------------------------------
/src/webgrid/i18n/webgrid.pot:
--------------------------------------------------------------------------------
1 | # Translations template for PROJECT.
2 | # Copyright (C) 2025 ORGANIZATION
3 | # This file is distributed under the same license as the PROJECT project.
4 | # FIRST AUTHOR , 2025.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PROJECT VERSION\n"
10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
11 | "POT-Creation-Date: 2025-08-04 14:38-0400\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=utf-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Generated-By: Babel 2.17.0\n"
19 |
20 | #: src/webgrid/__init__.py:235
21 | msgid "expected group to be a subclass of ColumnGroup"
22 | msgstr ""
23 |
24 | #: src/webgrid/__init__.py:251
25 | msgid ""
26 | "expected filter to be a SQLAlchemy column-like object, but it did not "
27 | "have a \"key\" or \"name\" attribute"
28 | msgstr ""
29 |
30 | #: src/webgrid/__init__.py:263
31 | msgid ""
32 | "the filter was a class type, but no column-like object is available from "
33 | "\"key\" to pass in as as the first argument"
34 | msgstr ""
35 |
36 | #: src/webgrid/__init__.py:373
37 | #, python-brace-format
38 | msgid "key \"{key}\" not found in record"
39 | msgstr ""
40 |
41 | #: src/webgrid/__init__.py:536
42 | msgid "True"
43 | msgstr ""
44 |
45 | #: src/webgrid/__init__.py:537
46 | msgid "False"
47 | msgstr ""
48 |
49 | #: src/webgrid/__init__.py:576
50 | msgid "Yes"
51 | msgstr ""
52 |
53 | #: src/webgrid/__init__.py:577
54 | msgid "No"
55 | msgstr ""
56 |
57 | #: src/webgrid/__init__.py:1390
58 | #, python-brace-format
59 | msgid "can't sort on invalid key \"{key}\""
60 | msgstr ""
61 |
62 | #: src/webgrid/__init__.py:1974
63 | #, python-brace-format
64 | msgid "\"{arg}\" grid argument invalid, ignoring"
65 | msgstr ""
66 |
67 | #: src/webgrid/filters.py:69 src/webgrid/filters.py:71
68 | msgid "is"
69 | msgstr ""
70 |
71 | #: src/webgrid/filters.py:70 src/webgrid/filters.py:72
72 | msgid "is not"
73 | msgstr ""
74 |
75 | #: src/webgrid/filters.py:73
76 | msgid "empty"
77 | msgstr ""
78 |
79 | #: src/webgrid/filters.py:74
80 | msgid "not empty"
81 | msgstr ""
82 |
83 | #: src/webgrid/filters.py:75
84 | msgid "contains"
85 | msgstr ""
86 |
87 | #: src/webgrid/filters.py:76
88 | msgid "doesn't contain"
89 | msgstr ""
90 |
91 | #: src/webgrid/filters.py:77
92 | msgid "less than or equal"
93 | msgstr ""
94 |
95 | #: src/webgrid/filters.py:78
96 | msgid "greater than or equal"
97 | msgstr ""
98 |
99 | #: src/webgrid/filters.py:79
100 | msgid "between"
101 | msgstr ""
102 |
103 | #: src/webgrid/filters.py:80
104 | msgid "not between"
105 | msgstr ""
106 |
107 | #: src/webgrid/filters.py:81
108 | msgid "in the past"
109 | msgstr ""
110 |
111 | #: src/webgrid/filters.py:82
112 | msgid "in the future"
113 | msgstr ""
114 |
115 | #: src/webgrid/filters.py:83
116 | msgid "days ago"
117 | msgstr ""
118 |
119 | #: src/webgrid/filters.py:84
120 | msgid "less than days ago"
121 | msgstr ""
122 |
123 | #: src/webgrid/filters.py:85
124 | msgid "more than days ago"
125 | msgstr ""
126 |
127 | #: src/webgrid/filters.py:86
128 | msgid "today"
129 | msgstr ""
130 |
131 | #: src/webgrid/filters.py:87
132 | msgid "this week"
133 | msgstr ""
134 |
135 | #: src/webgrid/filters.py:88
136 | msgid "last week"
137 | msgstr ""
138 |
139 | #: src/webgrid/filters.py:89
140 | msgid "in less than days"
141 | msgstr ""
142 |
143 | #: src/webgrid/filters.py:90
144 | msgid "in more than days"
145 | msgstr ""
146 |
147 | #: src/webgrid/filters.py:91
148 | msgid "in days"
149 | msgstr ""
150 |
151 | #: src/webgrid/filters.py:92
152 | msgid "this month"
153 | msgstr ""
154 |
155 | #: src/webgrid/filters.py:93
156 | msgid "last month"
157 | msgstr ""
158 |
159 | #: src/webgrid/filters.py:94
160 | msgid "select month"
161 | msgstr ""
162 |
163 | #: src/webgrid/filters.py:95
164 | msgid "this year"
165 | msgstr ""
166 |
167 | #: src/webgrid/filters.py:278
168 | #, python-brace-format
169 | msgid "unrecognized operator: {op}"
170 | msgstr ""
171 |
172 | #: src/webgrid/filters.py:462
173 | #, python-brace-format
174 | msgid ""
175 | "value_modifier argument set to \"auto\", but the options set is empty and"
176 | " the type can therefore not be determined for {name}"
177 | msgstr ""
178 |
179 | #: src/webgrid/filters.py:480
180 | #, python-brace-format
181 | msgid "can't use value_modifier='auto' when option keys are {key_type}"
182 | msgstr ""
183 |
184 | #: src/webgrid/filters.py:491
185 | msgid ""
186 | "value_modifier must be the string \"auto\", have a \"process\" attribute,"
187 | " or be a callable"
188 | msgstr ""
189 |
190 | #: src/webgrid/filters.py:875
191 | msgid "01-Jan"
192 | msgstr ""
193 |
194 | #: src/webgrid/filters.py:876
195 | msgid "02-Feb"
196 | msgstr ""
197 |
198 | #: src/webgrid/filters.py:877
199 | msgid "03-Mar"
200 | msgstr ""
201 |
202 | #: src/webgrid/filters.py:878
203 | msgid "04-Apr"
204 | msgstr ""
205 |
206 | #: src/webgrid/filters.py:879
207 | msgid "05-May"
208 | msgstr ""
209 |
210 | #: src/webgrid/filters.py:880
211 | msgid "06-Jun"
212 | msgstr ""
213 |
214 | #: src/webgrid/filters.py:881
215 | msgid "07-Jul"
216 | msgstr ""
217 |
218 | #: src/webgrid/filters.py:882
219 | msgid "08-Aug"
220 | msgstr ""
221 |
222 | #: src/webgrid/filters.py:883
223 | msgid "09-Sep"
224 | msgstr ""
225 |
226 | #: src/webgrid/filters.py:884
227 | msgid "10-Oct"
228 | msgstr ""
229 |
230 | #: src/webgrid/filters.py:885
231 | msgid "11-Nov"
232 | msgstr ""
233 |
234 | #: src/webgrid/filters.py:886
235 | msgid "12-Dec"
236 | msgstr ""
237 |
238 | #: src/webgrid/filters.py:956 src/webgrid/static/webgrid.js:39
239 | msgid "-- All --"
240 | msgstr ""
241 |
242 | #: src/webgrid/filters.py:1010 src/webgrid/filters.py:1011
243 | msgid "before "
244 | msgstr ""
245 |
246 | #: src/webgrid/filters.py:1012 src/webgrid/filters.py:1015
247 | msgid "excluding "
248 | msgstr ""
249 |
250 | #: src/webgrid/filters.py:1013 src/webgrid/filters.py:1014
251 | msgid "after "
252 | msgstr ""
253 |
254 | #: src/webgrid/filters.py:1016
255 | msgid "up to "
256 | msgstr ""
257 |
258 | #: src/webgrid/filters.py:1017
259 | msgid "beginning "
260 | msgstr ""
261 |
262 | #: src/webgrid/filters.py:1031
263 | msgid "invalid"
264 | msgstr ""
265 |
266 | #: src/webgrid/filters.py:1034
267 | msgid "All"
268 | msgstr ""
269 |
270 | #: src/webgrid/filters.py:1040
271 | msgid "date not specified"
272 | msgstr ""
273 |
274 | #: src/webgrid/filters.py:1042
275 | msgid "any date"
276 | msgstr ""
277 |
278 | #: src/webgrid/filters.py:1050 src/webgrid/filters.py:1789
279 | msgid "all"
280 | msgstr ""
281 |
282 | #: src/webgrid/filters.py:1063 src/webgrid/filters.py:1075
283 | #, python-brace-format
284 | msgid "{descriptor}{date}"
285 | msgstr ""
286 |
287 | #: src/webgrid/filters.py:1067
288 | #, python-brace-format
289 | msgid "{descriptor}{first_date} - {second_date}"
290 | msgstr ""
291 |
292 | #: src/webgrid/filters.py:1363 src/webgrid/filters.py:1373
293 | msgid "date filter given is out of range"
294 | msgstr ""
295 |
296 | #: src/webgrid/filters.py:1394 src/webgrid/filters.py:1413
297 | #: src/webgrid/filters.py:1638 src/webgrid/filters.py:1664
298 | msgid "invalid date"
299 | msgstr ""
300 |
301 | #: src/webgrid/filters.py:1774
302 | msgid "invalid time"
303 | msgstr ""
304 |
305 | #: src/webgrid/filters.py:1790
306 | msgid "yes"
307 | msgstr ""
308 |
309 | #: src/webgrid/filters.py:1791
310 | msgid "no"
311 | msgstr ""
312 |
313 | #: src/webgrid/renderers.py:686
314 | #, python-brace-format
315 | msgid "{label} DESC"
316 | msgstr ""
317 |
318 | #: src/webgrid/renderers.py:746
319 | #, python-brace-format
320 | msgid "of {page_count}"
321 | msgstr ""
322 |
323 | #: src/webgrid/renderers.py:834
324 | msgid "No records to display"
325 | msgstr ""
326 |
327 | #: src/webgrid/renderers.py:1003
328 | #, python-brace-format
329 | msgid "{label} ({num} record):"
330 | msgid_plural "{label} ({num} records):"
331 | msgstr[0] ""
332 | msgstr[1] ""
333 |
334 | #: src/webgrid/renderers.py:1025
335 | msgid "Page Totals"
336 | msgstr ""
337 |
338 | #: src/webgrid/renderers.py:1030
339 | msgid "Grand Totals"
340 | msgstr ""
341 |
342 | #: src/webgrid/renderers.py:1160
343 | msgid "Add Filter:"
344 | msgstr ""
345 |
346 | #: src/webgrid/renderers.py:1178
347 | msgid "Search"
348 | msgstr ""
349 |
350 | #: src/webgrid/validators.py:37
351 | msgid "Please enter a value."
352 | msgstr ""
353 |
354 | #: src/webgrid/validators.py:48
355 | msgid "Please enter an integer value."
356 | msgstr ""
357 |
358 | #: src/webgrid/validators.py:58 src/webgrid/validators.py:68
359 | msgid "Please enter a number."
360 | msgstr ""
361 |
362 | #: src/webgrid/validators.py:74
363 | msgid "must specify either min or max for range validation"
364 | msgstr ""
365 |
366 | #: src/webgrid/validators.py:81
367 | #, python-brace-format
368 | msgid "Value must be greater than or equal to {}."
369 | msgstr ""
370 |
371 | #: src/webgrid/validators.py:87
372 | #, python-brace-format
373 | msgid "Value must be less than or equal to {}."
374 | msgstr ""
375 |
376 | #: src/webgrid/validators.py:103
377 | #, python-brace-format
378 | msgid "Value must be one of {}."
379 | msgstr ""
380 |
381 | #: src/webgrid/validators.py:113
382 | msgid "Processor should be callable and take a value argument"
383 | msgstr ""
384 |
385 | #: src/webgrid/static/jquery.multiple.select.js:369
386 | msgid "Select all"
387 | msgstr ""
388 |
389 | #: src/webgrid/static/jquery.multiple.select.js:370
390 | msgid "All selected"
391 | msgstr ""
392 |
393 | #: src/webgrid/static/jquery.multiple.select.js:372
394 | #, python-brace-format
395 | msgid "{count} of {total} selected"
396 | msgstr ""
397 |
398 | #: src/webgrid/templates/grid_footer.html:11
399 | msgid " Export to "
400 | msgstr ""
401 |
402 | #: src/webgrid/templates/grid_footer.html:24
403 | #: src/webgrid/templates/grid_footer.html:31
404 | msgid "first"
405 | msgstr ""
406 |
407 | #: src/webgrid/templates/grid_footer.html:28
408 | #: src/webgrid/templates/grid_footer.html:32
409 | msgid "previous"
410 | msgstr ""
411 |
412 | #: src/webgrid/templates/grid_footer.html:37
413 | #: src/webgrid/templates/grid_footer.html:44
414 | msgid "next"
415 | msgstr ""
416 |
417 | #: src/webgrid/templates/grid_footer.html:41
418 | #: src/webgrid/templates/grid_footer.html:45
419 | msgid "last"
420 | msgstr ""
421 |
422 | #: src/webgrid/templates/grid_header.html:23
423 | msgid "Apply"
424 | msgstr ""
425 |
426 | #: src/webgrid/templates/grid_header.html:29
427 | msgid "reset"
428 | msgstr ""
429 |
430 | #: src/webgrid/templates/header_paging.html:8
431 | msgid "Records"
432 | msgstr ""
433 |
434 | #: src/webgrid/templates/header_paging.html:14
435 | msgid "Page"
436 | msgstr ""
437 |
438 | #: src/webgrid/templates/header_paging.html:19
439 | msgid "Per Page"
440 | msgstr ""
441 |
442 | #: src/webgrid/templates/header_sorting.html:9
443 | msgid "Sort By"
444 | msgstr ""
445 |
--------------------------------------------------------------------------------