├── 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 |
8 | {%- if form_attrs['method'] == 'post' -%} 9 | 10 | {%- endif -%} 11 |
12 |
13 | {{ renderer.header_filtering()|wg_safe }} 14 |
15 | 16 |
17 | {{ renderer.header_sorting()|wg_safe }} 18 |
19 |
20 | 21 |
22 | 31 | 32 |
33 | {{ renderer.header_paging()|wg_safe }} 34 |
35 |
36 |
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
IDEditDealerMakeModelColorActiveActive ReverseActive Yes/No
1editbob7fordF150&pink :(TrueFalseYes
2editfred9chevy1500blue :)FalseTrueNo
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
IDEditDealerMakeModelColorActiveActive ReverseActive Yes/No
1editbob7fordF150&pink :(TrueFalseYes
2editfred9chevy1500blue :)FalseTrueNo
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 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
First NameFull NameActiveEmailsStatusCreatedDue DateNumber
fn004fn004 ln004Yesemail004@example.com, email004@gmail.com 02/22/2012 10:04 AM02/04/20122.13
fn002fn002 ln002Yesemail002@example.com, email002@gmail.compending  2.13
fn001fn001 ln001Yesemail001@example.com, email001@gmail.comin process02/22/2012 10:01 AM02/01/20122.13
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 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
First NameFull NameActiveEmailsStatusCreatedDue DateNumberAccount Type
fn004fn004 ln004Yesemail004@example.com, email004@gmail.com 02/22/2012 10:04 AM02/04/20122.13 
fn002fn002 ln002Yesemail002@example.com, email002@gmail.compending  2.13Employee
fn001fn001 ln001Yesemail001@example.com, email001@gmail.comin process02/22/2012 10:01 AM02/01/20122.13Admin
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 | [![nox](https://github.com/level12/webgrid/actions/workflows/nox.yaml/badge.svg)](https://github.com/level12/webgrid/actions/workflows/nox.yaml) 3 | [![Codecov](https://codecov.io/gh/level12/webgrid/branch/master/graph/badge.svg)](https://codecov.io/gh/level12/webgrid) 4 | [![AppVeyor Build Status](https://ci.appveyor.com/api/projects/status/6s1886gojqi9c8h6?svg=true)](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 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 |
Lap 1Lap 2Lap 3
IDLabelStart TimeStop TimeCategoryStart TimeStop TimeStart TimeStop Time
1Watch 101/01/2019 01:00 AM01/01/2019 02:00 AMSports01/01/2019 03:00 AM01/01/2019 04:00 AM01/01/2019 05:00 AM01/01/2019 06:00 AM
2Watch 201/01/2019 02:00 AM01/01/2019 03:00 AMSports01/01/2019 04:00 AM01/01/2019 05:00 AM01/01/2019 06:00 AM01/01/2019 07:00 AM
3Watch 301/01/2019 03:00 AM01/01/2019 04:00 AMSports01/01/2019 05:00 AM01/01/2019 06:00 AM01/01/2019 07:00 AM01/01/2019 08:00 AM
4Watch 401/01/2019 04:00 AM01/01/2019 05:00 AMSports01/01/2019 06:00 AM01/01/2019 07:00 AM01/01/2019 08:00 AM01/01/2019 09:00 AM
5Watch 501/01/2019 05:00 AM01/01/2019 06:00 AMSports01/01/2019 07:00 AM01/01/2019 08:00 AM01/01/2019 09:00 AM01/01/2019 10:00 AM
6Watch 601/01/2019 06:00 AM01/01/2019 07:00 AMSports01/01/2019 08:00 AM01/01/2019 09:00 AM01/01/2019 10:00 AM01/01/2019 11:00 AM
7Watch 701/01/2019 07:00 AM01/01/2019 08:00 AMSports01/01/2019 09:00 AM01/01/2019 10:00 AM01/01/2019 11:00 AM01/01/2019 12:00 PM
8Watch 801/01/2019 08:00 AM01/01/2019 09:00 AMSports01/01/2019 10:00 AM01/01/2019 11:00 AM01/01/2019 12:00 PM01/01/2019 01:00 PM
9Watch 901/01/2019 09:00 AM01/01/2019 10:00 AMSports01/01/2019 11:00 AM01/01/2019 12:00 PM01/01/2019 01:00 PM01/01/2019 02:00 PM
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 | --------------------------------------------------------------------------------