├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── datatables_view ├── __init__.py ├── app_settings.py ├── apps.py ├── columns.py ├── exceptions.py ├── filters.py ├── static │ └── datatables_view │ │ ├── css │ │ └── style.css │ │ ├── images │ │ ├── details_close.png │ │ └── details_open.png │ │ └── js │ │ └── utils.js ├── templates │ ├── base.html │ └── datatables_view │ │ ├── datatable.html │ │ └── row_tools.html ├── templatetags │ ├── __init__.py │ └── datatables_view_tags.py ├── tests │ ├── __init__.py │ ├── test_autofilter.py │ ├── test_choices_filters.py │ ├── test_columndefs.py │ ├── test_datatables_qs.py │ └── test_date_filters.py ├── utils.py └── views.py ├── requirements_test.txt ├── runtests.py ├── screenshots ├── 001.png ├── 002.png ├── 003.png ├── 004a.png ├── 004b.png ├── 005.png ├── 006.png ├── 007.png ├── 008.png ├── 009.png ├── 010.png ├── clipping_results.png ├── column_filtering.png └── table_row_id.png ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── settings.py ├── test_models.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.py[cod] 3 | __pycache__ 4 | 5 | # C extensions 6 | *.so 7 | .vscode 8 | 9 | # Packages 10 | *.egg 11 | *.egg-info 12 | dist 13 | build 14 | eggs 15 | parts 16 | bin 17 | var 18 | sdist 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .coverage 29 | .tox 30 | nosetests.xml 31 | htmlcov 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | 41 | # Pycharm/Intellij 42 | .idea 43 | 44 | # Complexity 45 | output/*.html 46 | output/*/index.html 47 | 48 | # Sphinx 49 | docs/_build 50 | 51 | .vagrant 52 | db.sqlite3 53 | 54 | .ipynb* 55 | .cache 56 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ======= 5 | 6 | v3.2.3 7 | ------ 8 | * "data-parent-row-id" attribute added to details row 9 | 10 | v3.2.2 11 | ------ 12 | * accept positions expressed as column names in initial_order[] 13 | 14 | v3.2.1 15 | ------ 16 | * add className to filters 17 | * improved filtering with choices by including foreign_fields 18 | * optional "boolean" column attribute to treat calculated column as booleans on explicit request 19 | * optional "max_length" column attribute to clip results 20 | 21 | v3.2.0 22 | ------ 23 | * Automatic addition of table row ID (see `get_table_row_id()`) 24 | * `request` parameter added to `prepare_results()` and `get_response_dict()` 25 | 26 | v3.1.4 27 | ------ 28 | * fix checkbox and radio buttons not working in a form embedded in the details row when full_row_select is active 29 | 30 | v3.1.3 31 | ------ 32 | * Better behaviour for full_row_select 33 | 34 | v3.1.2 35 | ------ 36 | * `initialSearchValue` can now be a value or a callable object 37 | 38 | v3.1.1 39 | ------ 40 | * Silly JS fix 41 | 42 | v3.1.0 43 | ------ 44 | * choices / autofilter support for column filters 45 | * optional *initialSearchValue* for column filters 46 | * **Backward incompatible change**: any unrecognized column_defs attribute will raises an exception 47 | 48 | v3.0.4 49 | ------ 50 | * Support length_menu = -1 (which means: "all") 51 | 52 | v3.0.3 53 | ------ 54 | * Use `full_row_select=true` to toggled row details by clicking anywhere in the row 55 | 56 | v3.0.2 57 | ------ 58 | * Sanity check for initial_order[] 59 | 60 | v3.0.1 61 | ------ 62 | * js fix (same as v2.3.5) 63 | 64 | * js fix 65 | v3.0.0 66 | ------ 67 | * Bump major version to welcome Django 3 68 | 69 | v2.3.5 70 | ------ 71 | * js fix 72 | 73 | v2.3.4 74 | ------ 75 | * Add support for Django 3.0, drop Python 2 76 | 77 | v.2.3.3 78 | ------- 79 | * Some JS utilities added 80 | 81 | v2.3.2 82 | ------ 83 | * improved queryset optimization 84 | 85 | v2.3.1 86 | ------ 87 | * fix queryset optimization 88 | 89 | v2.3.0 90 | ------ 91 | * queryset optimization 92 | 93 | v2.2.9 94 | ------ 95 | * optional extra_data dictionary accepted by initialize_table() 96 | 97 | v2.2.8 98 | ------ 99 | * Remove `table-layout: fixed;` style from HTML table, as this causes problems in the columns' widths computation 100 | 101 | v2.2.7 102 | ------ 103 | * Explicitly set width of "row tools" column 104 | * Localize "search" prompt in column filters 105 | 106 | v2.2.6 107 | ------ 108 | * Experimental: Optionally control the (minimum) width of each single column 109 | 110 | v2.2.5 111 | ------ 112 | * cleanup 113 | 114 | v2.2.4 115 | ------ 116 | * optionally specified extra options to initialize_table() 117 | 118 | v2.2.3 119 | ------ 120 | * accept language options 121 | 122 | v2.2.2 123 | ------ 124 | * fix default footer 125 | 126 | v2.2.1 127 | ------ 128 | * README revised 129 | 130 | v2.2.0 131 | ------ 132 | * Merge into master 133 | 134 | v2.1.3 135 | ------ 136 | * Remove initialize_datatable() from main project and replace with DatatablesViewUtils.initialize_table() to share common behaviour 137 | * Notify Datatable subscribers with various events 138 | * Cleanup global filtering on dates range 139 | * Derived view class can now specify 'latest_by' when different from model.get_latest_by 140 | * Documentation revised 141 | 142 | v2.1.2 143 | ------ 144 | * basic support for DateField and DateTimeField filtering (exact date match) 145 | 146 | v2.1.1 147 | ------ 148 | * choices lookup revised 149 | 150 | v2.1.0 151 | ------ 152 | * `static/datatables_view/js/datatables_utils.js` renamed as `static/datatables_view/js/utils.js` 153 | * js helper encapsulated in DatatablesViewUtils module 154 | * First "almost" working column filtering - good enought for text search 155 | 156 | v2.0.6 157 | ------ 158 | * Accept either GET or POST requests 159 | 160 | v2.0.5 161 | ------ 162 | * Global "get_latest_by" filtering improved 163 | 164 | v2.0.4 165 | ------ 166 | * Filter tracing (for debugging) 167 | 168 | v2.0.0 169 | ------ 170 | * DatatablesView refactoring: columns_specs[] used as a substitute for columns[],searchable_columns[] and foreign_fields[] 171 | 172 | v1.2.4 173 | ------ 174 | * recognize datatime.date column type 175 | 176 | v1.2.3 177 | ------ 178 | * render_row_details() passes model_admin to the context, to permit fieldsets navigation 179 | 180 | v1.2.2 181 | ------ 182 | * generic tables explained 183 | * render_row_details customizable via templates 184 | 185 | v1.2.1 186 | ------ 187 | * merged PR #1 from Thierry BOULOGNE 188 | 189 | v1.2.0 190 | ------ 191 | * Incompatible change: postpone column initialization and pass the request to get_column_defs() for runtime table layout customization 192 | 193 | v1.0.1 194 | ------ 195 | * fix choices lookup 196 | 197 | v1.0.0 198 | ------ 199 | * fix search 200 | * better distribution (make sure templates and statics are included) 201 | 202 | v0.0.2 203 | ------ 204 | * Package version added 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2017, Mario Orlandi 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include CHANGELOG.rst 4 | recursive-include datatables_view/static * 5 | recursive-include datatables_view/templates * 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | **⚠️ Current status of this project** 3 | 4 | **This package has been renamed as `django-ajax-datatable` since v4.0.0, to avoid a conflict in PyPI, and moved to https://github.com/morlandi/django-ajax-datatable**. 5 | 6 | You are strongly suggested to switch to the new project https://github.com/morlandi/django-ajax-datatable, which is actively maintained and provides several improvements; 7 | 8 | however, all releases published in the current repo will rest in place forever. 9 | 10 | A check list of actions required to migrate your Django project to the new package is available here: 11 | https://github.com/morlandi/django-ajax-datatable/blob/master/MIGRATE_FROM_DATATABLES_VIEW_CHECKLIST.rst 12 | 13 | django-datatables-view 14 | ====================== 15 | 16 | django-datatables-view is a Django app which provides the integration of a Django 17 | project with the jQuery Javascript library DataTables.net, 18 | when used with server-side processing mode. 19 | 20 | In this context, the rendering of the table is the result of a serie of Ajax 21 | requests to the server following user interactions (i.e. when paging, ordering, searching, etc.). 22 | 23 | With django-datatables-view, basically you have to provide a DatatablesView-derived view 24 | to describe the desired table content and behaviour, and the app manages the interaction 25 | with DataTables.net by reacting to the ajax requests with suitable responses. 26 | 27 | Notes: 28 | 29 | Since someone asked ... 30 | 31 | - I use this app for my own projects, and improve it from time to time as new needs arises. 32 | 33 | - I received so much from the Django community, so I'm more than happy to share something hopefully useful for others. 34 | The app is intended to be opensource; feel free to use it we no restrictions at all. 35 | I added a MIT Licence file to the github repo, to make this more explicit. 36 | 37 | - The app hasn't been published on PyPI since, due to a name conflict, that would require renaming it. 38 | Is it worth it ? If you think so, please let me know opening an issue in the Github project. 39 | 40 | - Unfortunately I only have a few unit tests, and didn't bother (yet) to add a TOX procedure to run then with 41 | different Python/Django versions. 42 | Having said this, I can confirm that I do happen to use it with no problems in projects based on Django 2.x. 43 | However, most recent improvements have been tested mainly with Django 3. 44 | As far as I know, no Django3-specific features have been applied. 45 | In case, please open an issue, and I will fix it. 46 | 47 | - I'm not willing to support Python 2.x and Django 1.x any more; in case, use a previous release (tagged as v2.x.x); 48 | old releases will be in place in the repo forever 49 | 50 | Features: 51 | 52 | - Pagination 53 | - Column ordering 54 | - Global generic search 55 | - Global date-range search over "get_latest_by" column 56 | - Column specific filtering 57 | - Foreign key fields can be used, using the "model1__model2__field" notation 58 | - Customizable rendering of table rows 59 | - ... 60 | 61 | Inspired from: 62 | 63 | https://github.com/monnierj/django-datatables-server-side 64 | 65 | .. contents:: 66 | 67 | .. sectnum:: 68 | 69 | Installation 70 | ------------ 71 | 72 | Install the package by running: 73 | 74 | .. code:: bash 75 | 76 | pip install git+https://github.com/morlandi/django-datatables-view 77 | 78 | or possibly a specific version: 79 | 80 | .. code:: bash 81 | 82 | pip install git+https://github.com/morlandi/django-datatables-view@v3.0.0 83 | 84 | then add 'datatables_view' to your INSTALLED_APPS: 85 | 86 | .. code:: bash 87 | 88 | INSTALLED_APPS = [ 89 | ... 90 | 'datatables_view', 91 | ] 92 | 93 | 94 | Pre-requisites 95 | -------------- 96 | 97 | Your base template should include what required by `datatables.net`, plus: 98 | 99 | - /static/datatables_view/css/style.css 100 | - /static/datatables_view/js/utils.js 101 | 102 | Example: 103 | 104 | .. code:: html 105 | 106 | {% block extrastyle %} 107 | 108 | 109 | 110 | 111 | 112 | 113 | {% endblock extrastyle %} 114 | 115 | {% block extrajs %} 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | {% endcompress %} 130 | 131 | 132 | 133 | Basic DatatablesView 134 | -------------------- 135 | 136 | To provide server-side rendering of a Django Model, you need a specific 137 | view derived from DatatablesView() which will be called multiple times via Ajax during data navigation. 138 | 139 | At the very minimum, you shoud specify a suitable `column_defs` list. 140 | 141 | Example: 142 | 143 | `urls.py` 144 | 145 | .. code:: python 146 | 147 | from django.urls import path 148 | from . import datatables_views 149 | 150 | app_name = 'frontend' 151 | 152 | urlpatterns = [ 153 | ... 154 | path('datatable/registers/', datatables_views.RegisterDatatablesView.as_view(), name="datatable_registers"), 155 | ] 156 | 157 | 158 | `datatables_views.py` 159 | 160 | .. code:: python 161 | 162 | from django.contrib.auth.decorators import login_required 163 | from django.utils.decorators import method_decorator 164 | 165 | from datatables_view.views import DatatablesView 166 | from backend.models import Register 167 | 168 | 169 | @method_decorator(login_required, name='dispatch') 170 | class RegisterDatatablesView(DatatablesView): 171 | 172 | model = Register 173 | title = 'Registers' 174 | 175 | column_defs = [ 176 | { 177 | 'name': 'id', 178 | 'visible': False, 179 | }, { 180 | 'name': 'created', 181 | }, { 182 | 'name': 'type', 183 | }, { 184 | 'name': 'address', 185 | }, { 186 | 'name': 'readonly', 187 | }, { 188 | 'name': 'min', 189 | }, { 190 | 'name': 'max', 191 | }, { 192 | 'name': 'widget_type', 193 | } 194 | ] 195 | 196 | 197 | In the previous example, row id is included in the first column of the table, 198 | but hidden to the user. 199 | 200 | DatatablesView will serialize the required data during table navigation; 201 | in order to render the initial web page which should contain the table, 202 | you need another "application" view, normally based on a template. 203 | 204 | `Usage: (file register_list.html)` 205 | 206 | .. code:: html 207 | 208 | 209 |
210 | 211 | ... 212 | 213 | 233 | 234 | In the template, insert a element and connect it to the DataTable machinery, 235 | calling **DatatablesViewUtils.initialize_table(element, url, extra_options={}, extra_data={})**, which will in turn 236 | perform a first call (identified by the `action=initialize` parameter) 237 | to render the initial table layout. 238 | 239 | In this initial phase, the (base) view's responsibility is that of providing to DataTables 240 | the suitable columns specifications (and other details), based on the `column_defs` 241 | attribute specified in the (derived) view class. 242 | 243 | Then, subsequent calls to the view will be performed to populate the table with real data. 244 | 245 | This strategy allows the placement of one or more dynamic tables in the same page. 246 | 247 | In simpler situations, where only one table is needed, you can use a single view 248 | (the one derived from DatatablesView); the rendered page is based on the default 249 | template `datatables_view/database.html`, unless overridden. 250 | 251 | This is the resulting table: 252 | 253 | .. image:: screenshots/001.png 254 | 255 | 256 | DatatablesViewUtils.initialize_table() parameters are: 257 | 258 | element 259 | table element 260 | 261 | url 262 | action (remote url to be called via Ajax) 263 | 264 | extra_options={} 265 | custom options for dataTable() 266 | 267 | extra_data={} 268 | extra parameters to be sent via ajax for custom filtering 269 | 270 | 271 | DatatablesView Class attributes 272 | ------------------------------- 273 | 274 | Required: 275 | 276 | - model 277 | - column_defs 278 | 279 | Optional: 280 | 281 | - template_name = 'datatables_view/datatable.html' 282 | - initial_order = [[1, "asc"], [5, "desc"]] # positions can also be expressed as column names: [['surname', 'asc'], ] 283 | - length_menu = [[10, 20, 50, 100], [10, 20, 50, 100]] 284 | - latest_by = None 285 | - show_date_filters = None 286 | - show_column_filters = None 287 | - disable_queryset_optimization = False 288 | - table_row_id_prefix = 'row-' 289 | - table_row_id_fieldname = 'id' 290 | 291 | or override the following methods to provide attribute values at run-time, 292 | based on request: 293 | 294 | .. code:: python 295 | 296 | def get_column_defs(self): 297 | return self.column_defs 298 | 299 | def get_initial_order(self): 300 | return self.initial_order 301 | 302 | def get_length_menu(self): 303 | return self.length_menu 304 | 305 | def get_template_name(self): 306 | return self.template_name 307 | 308 | def get_latest_by(self, request): 309 | """ 310 | Override to customize based on request. 311 | 312 | Provides the name of the column to be used for global date range filtering. 313 | Return either '', a fieldname or None. 314 | 315 | When None is returned, in model's Meta 'get_latest_by' attributed will be used. 316 | """ 317 | return self.latest_by 318 | 319 | def get_show_date_filters(self, request): 320 | """ 321 | Override to customize based on request. 322 | 323 | Defines whether to use the global date range filter. 324 | Return either True, False or None. 325 | 326 | When None is returned, will'll check whether 'latest_by' is defined 327 | """ 328 | return self.show_date_filters 329 | 330 | def get_show_column_filters(self, request): 331 | """ 332 | Override to customize based on request. 333 | 334 | Defines whether to use the column filters. 335 | Return either True, False or None. 336 | 337 | When None is returned, check if at least one visible column in searchable. 338 | """ 339 | return self.show_column_filters 340 | 341 | def get_table_row_id(self, request, obj): 342 | """ 343 | Provides a specific ID for the table row; default: "row-ID" 344 | Override to customize as required. 345 | """ 346 | result = '' 347 | if self.table_row_id_fieldname: 348 | try: 349 | result = self.table_row_id_prefix + str(getattr(obj, self.table_row_id_fieldname)) 350 | except: 351 | result = '' 352 | return result 353 | 354 | column_defs customizations 355 | -------------------------- 356 | 357 | Example:: 358 | 359 | column_defs = [{ 360 | 'name': 'currency', # required 361 | 'data': None, 362 | 'title': 'Currency', # optional: default = field verbose_name or column name 363 | 'visible': True, # optional: default = True 364 | 'searchable': True, # optional: default = True if visible, False otherwise 365 | 'orderable': True, # optional: default = True if visible, False otherwise 366 | 'foreign_field': 'manager__name', # optional: follow relation 367 | 'placeholder': False, # ??? 368 | 'className': 'css-class-currency', # optional class name for cell 369 | 'defaultContent': '

test

', # ??? 370 | 'width': 300, # optional: controls the minimum with of each single column 371 | 'choices': None, # see `Filtering single columns` below 372 | 'initialSearchValue': None, # see `Filtering single columns` below 373 | 'autofilter': False, # see `Filtering single columns` below 374 | 'boolean': False, # treat calculated column as BooleanField 375 | 'max_length': 0, # if > 0, clip result longer then max_length 376 | }, { 377 | ... 378 | 379 | Notes: 380 | 381 | - **title**: if not supplied, the verbose name of the model column (when available) 382 | or **name** will be used 383 | - **width**: for this to be effective, you need to add **table-layout: fixed;** style 384 | to the HTML table, but in some situations this causes problems in the computation 385 | of the table columns' widths (at least in the current version 1.10.19 of Datatables.net) 386 | 387 | Automatic addition of table row ID 388 | ---------------------------------- 389 | 390 | Starting from v3.2.0, each table row is characterized with a specific ID on each row 391 | (tipically, the primary key value from the queryset) 392 | 393 | .. image:: screenshots/table_row_id.png 394 | 395 | The default behaviour is to provide the string "row-ID", where: 396 | 397 | - "row-" is retrieved from self.table_row_id_prefix 398 | - "ID" is retrieved from the row object, using the field with name self.table_row_id_fieldname (default: "id") 399 | 400 | Note that, for this to work, you are required to list the field "id" in the column list (maybe hidden). 401 | 402 | This default behaviour can be customized by either: 403 | 404 | - replacing the values for `table_row_id_fieldname` and/or `table_row_id_prefix`, or 405 | - overriding `def get_table_row_id(self, request, obj)` 406 | 407 | Filtering single columns 408 | ------------------------ 409 | 410 | **DatatableView.show_column_filters** (or **DatatableView.get_show_column_filters(request)**) 411 | defines whether to show specific filters for searchable columns as follows: 412 | 413 | - None (default): show if at least one visible column in searchable 414 | - True: always show 415 | - False: always hide 416 | 417 | By default, a column filter for a searchable column is rendered as a **text input** box; 418 | you can instead provide a **select** box using the following attributes: 419 | 420 | choices 421 | - None (default) or False: no choices (use text input box) 422 | - True: use Model's field choices; 423 | + failing that, we might use "autofilter"; that is: collect the list of distinct values from db table 424 | + or, for **BooleanField** columns, provide (None)/Yes/No choice sequence 425 | + calculated columns with attribute 'boolean'=True are treated as BooleanFields 426 | - ((key1, value1), (key2, values), ...) : use supplied sequence of choices 427 | 428 | autofilter 429 | - default = False 430 | - when set: if choices == True and no Model's field choices are available, 431 | collects distinct values from db table (much like Excel "autofilter" feature) 432 | 433 | For the first rendering of the table: 434 | 435 | initialSearchValue 436 | - optional initial value for column filter 437 | 438 | Note that `initialSearchValue` can be a value or a callable object. 439 | If callable it will be called every time a new object is created. 440 | 441 | For example: 442 | 443 | .. code:: python 444 | 445 | class MyDatatablesView(DatatablesView): 446 | 447 | def today(): 448 | return datetime.datetime.now().date() 449 | 450 | ... 451 | 452 | column_defs = [ 453 | ... 454 | { 455 | 'name': 'created', 456 | 'choices': True, 457 | 'autofilter': True, 458 | 'initialSearchValue': today 459 | }, 460 | ... 461 | ] 462 | 463 | .. image:: screenshots/column_filtering.png 464 | 465 | 466 | Computed (placeholder) columns 467 | ------------------------------ 468 | 469 | You can insert placeholder columns in the table, and feed their content with 470 | arbitrary HTML. 471 | 472 | Example: 473 | 474 | .. code:: python 475 | 476 | @method_decorator(login_required, name='dispatch') 477 | class RegisterDatatablesView(DatatablesView): 478 | 479 | model = Register 480 | title = _('Registers') 481 | 482 | column_defs = [ 483 | { 484 | 'name': 'id', 485 | 'visible': False, 486 | }, { 487 | 'name': 'created', 488 | }, { 489 | 'name': 'dow', 490 | 'title': 'Day of week', 491 | 'placeholder': True, 492 | 'searchable': False, 493 | 'orderable': False, 494 | 'className': 'highlighted', 495 | }, { 496 | ... 497 | } 498 | ] 499 | 500 | def customize_row(self, row, obj): 501 | days = ['monday', 'tuesday', 'wednesday', 'thyrsday', 'friday', 'saturday', 'sunday'] 502 | if obj.created is not None: 503 | row['dow'] = '%s' % days[obj.created.weekday()] 504 | else: 505 | row['dow'] = '' 506 | return 507 | 508 | .. image:: screenshots/003.png 509 | 510 | Clipping results 511 | ---------------- 512 | 513 | Sometimes you might want to clip results up to a given maximum length, to control the column width. 514 | 515 | This can be obtained by specifying a positive value for the `max_length` column_spec attribute. 516 | 517 | Results will be clipped in both the column cells and in the column filter. 518 | 519 | .. image:: screenshots/clipping_results.png 520 | 521 | Clipped results are rendered as html text as follows: 522 | 523 | .. code:: python 524 | 525 | def render_clip_value_as_html(self, long_text, short_text, is_clipped): 526 | """ 527 | Given long and shor version of text, the following html representation: 528 | short_text[ellipsis] 529 | 530 | To be overridden for further customisations. 531 | """ 532 | return '{short_text}{ellipsis}'.format( 533 | long_text=long_text, 534 | short_text=short_text, 535 | ellipsis='…' if is_clipped else '' 536 | ) 537 | 538 | You can customise the rendering by overriding `render_clip_value_as_html()` 539 | 540 | Receiving table events 541 | ---------------------- 542 | 543 | The following table events are broadcasted to your custom handlers, provided 544 | you subscribe them: 545 | 546 | - initComplete(table) 547 | - drawCallback(table, settings) 548 | - rowCallback(table, row, data) 549 | - footerCallback(table, row, data, start, end, display) 550 | 551 | Please note the the first parameter of the callback is always the event, 552 | and next parameters are additional data:: 553 | 554 | .trigger('foo', [1, 2]); 555 | 556 | .on('foo', function(event, one, two) { ... }); 557 | 558 | 559 | More events triggers sent directly by DataTables.net are listed here: 560 | 561 | https://datatables.net/reference/event/ 562 | 563 | Example: 564 | 565 | .. code :: html 566 | 567 |
568 |
569 |
570 | 571 | 572 | 592 | 593 | 594 | Overridable DatatablesView methods 595 | ---------------------------------- 596 | 597 | get_initial_queryset() 598 | ...................... 599 | 600 | Provides the queryset to work with; defaults to **self.model.objects.all()** 601 | 602 | Example: 603 | 604 | .. code:: python 605 | 606 | def get_initial_queryset(self, request=None): 607 | if not request.user.view_all_clients: 608 | queryset = request.user.related_clients.all() 609 | else: 610 | queryset = super().get_initial_queryset(request) 611 | return queryset 612 | 613 | get_foreign_queryset() 614 | ...................... 615 | 616 | When collecting data for autofiltering in a "foreign_field" column, we need some data 617 | source for doing the lookup. 618 | 619 | The default implementation is as follows: 620 | 621 | .. code:: python 622 | 623 | def get_foreign_queryset(self, request, field): 624 | queryset = field.model.objects.all() 625 | return queryset 626 | 627 | You can override it for further reducing the resulting list. 628 | 629 | customize_row() 630 | ............... 631 | 632 | Called every time a new data row is required by the client, to let you further 633 | customize cell content 634 | 635 | Example: 636 | 637 | .. code:: python 638 | 639 | def customize_row(self, row, obj): 640 | # 'row' is a dictionary representing the current row, and 'obj' is the current object. 641 | row['code'] = '%s' % ( 642 | obj.status, 643 | reverse('frontend:client-detail', args=(obj.id,)), 644 | obj.code 645 | ) 646 | if obj.recipe is not None: 647 | row['recipe'] = obj.recipe.display_as_tile() + ' ' + str(obj.recipe) 648 | return 649 | 650 | render_row_details() 651 | .................... 652 | 653 | Renders an HTML fragment to show table row content in "detailed view" fashion, 654 | as previously explained later in the **Add row tools as first column** section. 655 | 656 | See also: `row details customization`_ 657 | 658 | Example: 659 | 660 | .. code:: python 661 | 662 | def render_row_details(self, id, request=None): 663 | client = self.model.objects.get(id=id) 664 | ... 665 | return render_to_string('frontend/pages/includes/client_row_details.html', { 666 | 'client': client, 667 | ... 668 | }) 669 | 670 | footer_message() 671 | ................ 672 | 673 | You can annotate the table footer with a custom message by overridding the 674 | following View method. 675 | 676 | .. code:: python 677 | 678 | def footer_message(self, qs, params): 679 | """ 680 | Overriden to append a message to the bottom of the table 681 | """ 682 | return None 683 | 684 | Example: 685 | 686 | .. code:: python 687 | 688 | def footer_message(self, qs, params): 689 | return 'Selected rows: %d' % qs.count() 690 | 691 | .. code:: html 692 | 693 | 702 | 703 | .. image:: screenshots/005.png 704 | 705 | 706 | render_clip_value_as_html() 707 | ........................... 708 | 709 | Renders clipped results as html span tag, providing the non-clipped value as title: 710 | 711 | .. code:: python 712 | 713 | def render_clip_value_as_html(self, long_text, short_text, is_clipped): 714 | """ 715 | Given long and shor version of text, the following html representation: 716 | short_text[ellipsis] 717 | 718 | To be overridden for further customisations. 719 | """ 720 | return '{short_text}{ellipsis}'.format( 721 | long_text=long_text, 722 | short_text=short_text, 723 | ellipsis='…' if is_clipped else '' 724 | ) 725 | 726 | Override to customise the rendering of clipped cells. 727 | 728 | Queryset optimization 729 | ===================== 730 | 731 | As the purpose of this module is all about querysets rendering, any chance to optimize 732 | data extractions from the database is more then appropriate. 733 | 734 | Starting with v2.3.0, DatatablesView tries to burst performances in two ways: 735 | 736 | 1) by using `only `_ to limit the number of columns in the result set 737 | 738 | 2) by using `select_related `_ to minimize the number of queries involved 739 | 740 | The parameters passed to only() and select_related() are inferred from `column_defs`. 741 | 742 | Should this cause any problem, you can disable queryset optimization in two ways: 743 | 744 | - globally: by activating the `DATATABLES_VIEW_DISABLE_QUERYSET_OPTIMIZATION` setting 745 | - per table: by setting to True the value of the `disable_queryset_optimization` attribute 746 | 747 | 748 | A real use case 749 | --------------- 750 | 751 | (1) Plain queryset:: 752 | 753 | SELECT "tasks_devicetesttask"."id", 754 | "tasks_devicetesttask"."description", 755 | "tasks_devicetesttask"."created_on", 756 | "tasks_devicetesttask"."created_by_id", 757 | "tasks_devicetesttask"."started_on", 758 | "tasks_devicetesttask"."completed_on", 759 | "tasks_devicetesttask"."job_id", 760 | "tasks_devicetesttask"."status", 761 | "tasks_devicetesttask"."mode", 762 | "tasks_devicetesttask"."failure_reason", 763 | "tasks_devicetesttask"."progress", 764 | "tasks_devicetesttask"."log_text", 765 | "tasks_devicetesttask"."author", 766 | "tasks_devicetesttask"."order", 767 | "tasks_devicetesttask"."appliance_id", 768 | "tasks_devicetesttask"."serial_number", 769 | "tasks_devicetesttask"."program_id", 770 | "tasks_devicetesttask"."position", 771 | "tasks_devicetesttask"."hidden", 772 | "tasks_devicetesttask"."is_duplicate", 773 | "tasks_devicetesttask"."notes" 774 | FROM "tasks_devicetesttask" 775 | WHERE "tasks_devicetesttask"."hidden" = FALSE 776 | ORDER BY "tasks_devicetesttask"."created_on" DESC 777 | 778 | **[sql] (233ms) 203 queries with 182 duplicates** 779 | 780 | 781 | (2) With select_related():: 782 | 783 | SELECT "tasks_devicetesttask"."id", 784 | "tasks_devicetesttask"."description", 785 | "tasks_devicetesttask"."created_on", 786 | "tasks_devicetesttask"."created_by_id", 787 | "tasks_devicetesttask"."started_on", 788 | "tasks_devicetesttask"."completed_on", 789 | "tasks_devicetesttask"."job_id", 790 | "tasks_devicetesttask"."status", 791 | "tasks_devicetesttask"."mode", 792 | "tasks_devicetesttask"."failure_reason", 793 | "tasks_devicetesttask"."progress", 794 | "tasks_devicetesttask"."log_text", 795 | "tasks_devicetesttask"."author", 796 | "tasks_devicetesttask"."order", 797 | "tasks_devicetesttask"."appliance_id", 798 | "tasks_devicetesttask"."serial_number", 799 | "tasks_devicetesttask"."program_id", 800 | "tasks_devicetesttask"."position", 801 | "tasks_devicetesttask"."hidden", 802 | "tasks_devicetesttask"."is_duplicate", 803 | "tasks_devicetesttask"."notes", 804 | "backend_appliance"."id", 805 | "backend_appliance"."description", 806 | "backend_appliance"."hidden", 807 | "backend_appliance"."created", 808 | "backend_appliance"."created_by_id", 809 | "backend_appliance"."updated", 810 | "backend_appliance"."updated_by_id", 811 | "backend_appliance"."type", 812 | "backend_appliance"."rotation", 813 | "backend_appliance"."code", 814 | "backend_appliance"."barcode", 815 | "backend_appliance"."mechanical_efficiency_min", 816 | "backend_appliance"."mechanical_efficiency_max", 817 | "backend_appliance"."volumetric_efficiency_min", 818 | "backend_appliance"."volumetric_efficiency_max", 819 | "backend_appliance"."displacement", 820 | "backend_appliance"."speed_min", 821 | "backend_appliance"."speed_max", 822 | "backend_appliance"."pressure_min", 823 | "backend_appliance"."pressure_max", 824 | "backend_appliance"."oil_temperature_min", 825 | "backend_appliance"."oil_temperature_max", 826 | "backend_program"."id", 827 | "backend_program"."description", 828 | "backend_program"."hidden", 829 | "backend_program"."created", 830 | "backend_program"."created_by_id", 831 | "backend_program"."updated", 832 | "backend_program"."updated_by_id", 833 | "backend_program"."code", 834 | "backend_program"."start_datetime", 835 | "backend_program"."end_datetime", 836 | "backend_program"."favourite" 837 | FROM "tasks_devicetesttask" 838 | LEFT OUTER JOIN "backend_appliance" ON ("tasks_devicetesttask"."appliance_id" = "backend_appliance"."id") 839 | LEFT OUTER JOIN "backend_program" ON ("tasks_devicetesttask"."program_id" = "backend_program"."id") 840 | WHERE "tasks_devicetesttask"."hidden" = FALSE 841 | ORDER BY "tasks_devicetesttask"."created_on" DESC 842 | 843 | **[sql] (38ms) 3 queries with 0 duplicates** 844 | 845 | 846 | (3) With select_related() and only():: 847 | 848 | SELECT "tasks_devicetesttask"."id", 849 | "tasks_devicetesttask"."started_on", 850 | "tasks_devicetesttask"."completed_on", 851 | "tasks_devicetesttask"."status", 852 | "tasks_devicetesttask"."failure_reason", 853 | "tasks_devicetesttask"."author", 854 | "tasks_devicetesttask"."order", 855 | "tasks_devicetesttask"."appliance_id", 856 | "tasks_devicetesttask"."serial_number", 857 | "tasks_devicetesttask"."program_id", 858 | "tasks_devicetesttask"."position", 859 | "backend_appliance"."id", 860 | "backend_appliance"."code", 861 | "backend_program"."id", 862 | "backend_program"."code" 863 | FROM "tasks_devicetesttask" 864 | LEFT OUTER JOIN "backend_appliance" ON ("tasks_devicetesttask"."appliance_id" = "backend_appliance"."id") 865 | LEFT OUTER JOIN "backend_program" ON ("tasks_devicetesttask"."program_id" = "backend_program"."id") 866 | WHERE "tasks_devicetesttask"."hidden" = FALSE 867 | ORDER BY "tasks_devicetesttask"."created_on" DESC 868 | 869 | **[sql] (19ms) 3 queries with 0 duplicates** 870 | 871 | 872 | App settings 873 | ============ 874 | 875 | DATATABLES_VIEW_MAX_COLUMNS 876 | 877 | Default: 30 878 | 879 | DATATABLES_VIEW_ENABLE_QUERYDICT_TRACING 880 | 881 | When True, enables debug tracing of datatables requests 882 | 883 | Default: False 884 | 885 | DATATABLES_VIEW_ENABLE_QUERYSET_TRACING 886 | 887 | When True, enables debug tracing of resulting query 888 | 889 | Default: False 890 | 891 | DATATABLES_VIEW_TEST_FILTERS 892 | 893 | When True, trace results for each individual filter, for debugging purposes 894 | 895 | Default: False 896 | 897 | DATATABLES_VIEW_DISABLE_QUERYSET_OPTIMIZATION 898 | 899 | When True, all queryset optimizations are disabled 900 | 901 | Default: False 902 | 903 | 904 | More details 905 | ============ 906 | 907 | Add row tools as first column 908 | ----------------------------- 909 | 910 | You can insert **DatatablesView.render_row_tools_column_def()** as the first element 911 | in `column_defs` to obtain some tools at the beginning of each table row. 912 | 913 | If `full_row_select=true` is specified as extra-option during table initialization, 914 | row details can be toggled by clicking anywhere in the row. 915 | 916 | `datatables_views.py` 917 | 918 | .. code:: python 919 | 920 | from django.contrib.auth.decorators import login_required 921 | from django.utils.decorators import method_decorator 922 | 923 | from datatables_view.views import DatatablesView 924 | from backend.models import Register 925 | 926 | 927 | @method_decorator(login_required, name='dispatch') 928 | class RegisterDatatablesView(DatatablesView): 929 | 930 | model = Register 931 | title = 'Registers' 932 | 933 | column_defs = [ 934 | DatatablesView.render_row_tools_column_def(), 935 | { 936 | 'name': 'id', 937 | 'visible': False, 938 | }, { 939 | ... 940 | 941 | By default, these tools will provide an icon to show and hide a detailed view 942 | below each table row. 943 | 944 | The tools are rendered according to the template **datatables_view/row_tools.html**, 945 | which can be overridden. 946 | 947 | Row details are automatically collected via Ajax by calling again the views 948 | with a specific **?action=details** parameters, and will be rendered by the 949 | method:: 950 | 951 | def render_row_details(self, id, request=None) 952 | 953 | which you can further customize when needed. 954 | 955 | The default behaviour provided by the base class if shown below: 956 | 957 | .. image:: screenshots/002.png 958 | 959 | row details customization 960 | ------------------------- 961 | 962 | The default implementation of render_row_details() tries to load a template 963 | in the following order: 964 | 965 | - datatables_view///render_row_details.html 966 | - datatables_view//render_row_details.html 967 | - datatables_view/render_row_details.html 968 | 969 | and, when found, uses it for rendering. 970 | 971 | The template receives the following context:: 972 | 973 | html = template.render({ 974 | 'model': self.model, 975 | 'model_admin': self.get_model_admin(), 976 | 'object': obj, 977 | }, request) 978 | 979 | `model_admin`, when available, can be used to navigate fieldsets (if defined) 980 | in the template, much like django's `admin/change_form.html` does. 981 | 982 | If no template is available, a simple HTML table with all field values 983 | is built instead. 984 | 985 | In all cases, the resulting HTML will be wrapped in the following structure: 986 | 987 | .. code :: html 988 | 989 | 990 | 991 |
992 | ... 993 | 994 | Filter by global date range 995 | --------------------------- 996 | 997 | When a `latest_by` column has been specified and `show_date_filter` is active, 998 | a global date range filtering widget is provided, based on `jquery-ui.datepicker`: 999 | 1000 | .. image:: screenshots/004a.png 1001 | 1002 | The header of the column used for date filtering is decorated with the class 1003 | "latest_by"; you can use it to customize it's rendering. 1004 | 1005 | You can fully replace the widget with your own by providing a custom **fn_daterange_widget_initialize()** 1006 | callback at Module's initialization, as in the following example, where we 1007 | use `bootstrap.datepicker`: 1008 | 1009 | .. code:: html 1010 | 1011 | DatatablesViewUtils.init({ 1012 | search_icon_html: '', 1013 | language: { 1014 | }, 1015 | fn_daterange_widget_initialize: function(table, data) { 1016 | var wrapper = table.closest('.dataTables_wrapper'); 1017 | var toolbar = wrapper.find(".toolbar"); 1018 | toolbar.html( 1019 | '
' + 1020 | '{% trans "From" %}: ' + 1021 | '  ' + 1022 | '{% trans "To" %}: ' + 1023 | '
' 1024 | ); 1025 | var date_pickers = toolbar.find('.date_from, .date_to'); 1026 | date_pickers.datepicker(); 1027 | date_pickers.on('change', function(event) { 1028 | // Annotate table with values retrieved from date widgets 1029 | var dt_from = toolbar.find('.date_from').data("datepicker"); 1030 | var dt_to = toolbar.find('.date_to').data("datepicker"); 1031 | table.data('date_from', dt_from ? dt_from.getFormattedDate("yyyy-mm-dd") : ''); 1032 | table.data('date_to', dt_to ? dt_to.getFormattedDate("yyyy-mm-dd") : ''); 1033 | // Redraw table 1034 | table.api().draw(); 1035 | }); 1036 | } 1037 | }); 1038 | 1039 | .. image:: screenshots/004b.png 1040 | 1041 | Debugging 1042 | --------- 1043 | 1044 | In case of errors, Datatables.net shows an alert popup: 1045 | 1046 | .. image:: screenshots/006.png 1047 | 1048 | You can change it to trace the error in the browser console, insted: 1049 | 1050 | .. code:: javascript 1051 | 1052 | // change DataTables' error reporting mechanism to throw a Javascript 1053 | // error to the browser's console, rather than alerting it. 1054 | $.fn.dataTable.ext.errMode = 'throw'; 1055 | 1056 | All details of Datatables.net requests can be logged to the console by activating 1057 | this setting:: 1058 | 1059 | DATATABLES_VIEW_ENABLE_QUERYDICT_TRACING = True 1060 | 1061 | The resulting query (before pagination) can be traced as well with:: 1062 | 1063 | DATATABLES_VIEW_ENABLE_QUERYSET_TRACING = True 1064 | 1065 | Debugging traces for date range filtering, column filtering or global filtering can be displayed 1066 | by activating this setting:: 1067 | 1068 | DATATABLES_VIEW_TEST_FILTERS 1069 | 1070 | .. image:: screenshots/007.png 1071 | 1072 | 1073 | Generic tables (advanced topic) 1074 | =============================== 1075 | 1076 | Chances are you might want to supply a standard user interface for listing 1077 | several models. 1078 | 1079 | In this case, it is possible to use a generic approach and avoid code duplications, 1080 | as detailed below. 1081 | 1082 | First, we supply a generic view which receives a model as parameter, 1083 | and passes it to the template used for rendering the page: 1084 | 1085 | file `frontend/datatables_views.py`: 1086 | 1087 | .. code:: python 1088 | 1089 | @login_required 1090 | def object_list_view(request, model, template_name="frontend/pages/object_list.html"): 1091 | """ 1092 | Render the page which contains the table. 1093 | That will in turn invoke (via Ajax) object_datatable_view(), to fill the table content 1094 | """ 1095 | return render(request, template_name, { 1096 | 'model': model, 1097 | }) 1098 | 1099 | In the urlconf, link to specific models as in the example below: 1100 | 1101 | file `frontend/urls.py`: 1102 | 1103 | .. code:: python 1104 | 1105 | path('channel/', datatables_views.object_list_view, {'model': backend.models.Channel, }, name="channel-list"), 1106 | 1107 | The template uses the `model` received in the context to display appropriate `verbose_name` 1108 | and `verbose_name_plural` attributes, and to extract `app_label` and `model_name` 1109 | as needed; unfortunately, we also had to supply some very basic helper templatetags, 1110 | as the `_meta` attribute of the model is not directly visible in this context. 1111 | 1112 | .. code:: html 1113 | 1114 | {% extends 'frontend/base.html' %} 1115 | {% load static datatables_view_tags i18n %} 1116 | 1117 | {% block breadcrumbs %} 1118 |
  • 1119 | {% trans 'Home' %} 1120 |
  • 1121 |
  • 1122 | {{model|model_verbose_name_plural}} 1123 |
  • 1124 | {% endblock breadcrumbs %} 1125 | 1126 | {% block content %} 1127 | 1128 | {% testhasperm model 'view' as can_view_objects %} 1129 | {% if not can_view_objects %} 1130 |

    {% trans "Sorry, you don't have the permission to view these objects" %}

    1131 | {% else %} 1132 | 1133 |
    1134 |
    {% trans 'All' %} {{ model|model_verbose_name_plural }}
    1135 | {% ifhasperm model 'add' %} 1136 | {% trans 'Add ...' %} 1137 | {% endifhasperm %} 1138 |
    1139 |
    1140 | 1141 |
    1142 |
    1143 | 1144 | {% ifhasperm model 'add' %} 1145 | {% trans 'Add ...' %} 1146 | {% endifhasperm %} 1147 | 1148 | {% endif %} 1149 | 1150 | {% endblock content %} 1151 | 1152 | 1153 | {% block extrajs %} 1154 | 1166 | {% endblock %} 1167 | 1168 | 1169 | app_label and model_name are just strings, and as such can be specified in an url. 1170 | 1171 | The connection with the Django backend uses the following generic url:: 1172 | 1173 | {% url 'frontend:object-datatable' model|app_label model|model_name %} 1174 | 1175 | from `urls.py`:: 1176 | 1177 | # List any Model 1178 | path('datatable///', datatables_views.object_datatable_view, name="object-datatable"), 1179 | 1180 | object_datatable_view() is a lookup helper which navigates all DatatablesView-derived 1181 | classes in the module and selects the view appropriate for the specific model 1182 | in use: 1183 | 1184 | file `frontend/datatables_views.py`: 1185 | 1186 | .. code:: python 1187 | 1188 | import inspect 1189 | 1190 | def object_datatable_view(request, app_label, model_name): 1191 | 1192 | # List all DatatablesView in this module 1193 | datatable_views = [ 1194 | klass 1195 | for name, klass in inspect.getmembers(sys.modules[__name__]) 1196 | if inspect.isclass(klass) and issubclass(klass, DatatablesView) 1197 | ] 1198 | 1199 | # Scan DatatablesView until we find the right one 1200 | for datatable_view in datatable_views: 1201 | model = datatable_view.model 1202 | if (model is not None and (model._meta.app_label, model._meta.model_name) == (app_label, model_name)): 1203 | view = datatable_view 1204 | break 1205 | 1206 | return view.as_view()(request) 1207 | 1208 | which for this example happens to be: 1209 | 1210 | .. code:: python 1211 | 1212 | @method_decorator(login_required, name='dispatch') 1213 | class ChannelDatatablesView(BaseDatatablesView): 1214 | 1215 | model = Channel 1216 | title = 'Channels' 1217 | 1218 | column_defs = [ 1219 | DatatablesView.render_row_tools_column_def(), 1220 | { 1221 | 'name': 'id', 1222 | 'visible': False, 1223 | }, { 1224 | 'name': 'description', 1225 | }, { 1226 | 'name': 'code', 1227 | } 1228 | ] 1229 | 1230 | Javascript Code Snippets 1231 | ======================== 1232 | 1233 | Workaround: Adjust the column widths of all visible tables 1234 | ---------------------------------------------------------- 1235 | 1236 | .. code:: javascript 1237 | 1238 | setTimeout(function () { 1239 | DatatablesViewUtils.adjust_table_columns(); 1240 | }, 200); 1241 | 1242 | or maybe better: 1243 | 1244 | .. code:: javascript 1245 | 1246 | var table = element.DataTable({ 1247 | ... 1248 | "initComplete": function(settings) { 1249 | setTimeout(function () { 1250 | DatatablesViewUtils.adjust_table_columns(); 1251 | }, 200); 1252 | } 1253 | 1254 | where: 1255 | 1256 | .. code:: javascript 1257 | 1258 | function adjust_table_columns() { 1259 | // Adjust the column widths of all visible tables 1260 | // https://datatables.net/reference/api/%24.fn.dataTable.tables() 1261 | $.fn.dataTable 1262 | .tables({ 1263 | visible: true, 1264 | api: true 1265 | }) 1266 | .columns.adjust(); 1267 | } 1268 | 1269 | 1270 | Redraw all tables 1271 | ----------------- 1272 | 1273 | .. code:: javascript 1274 | 1275 | $.fn.dataTable.tables({ 1276 | api: true 1277 | }).draw(); 1278 | 1279 | Redraw table holding the current paging position 1280 | ------------------------------------------------ 1281 | 1282 | .. code:: javascript 1283 | 1284 | table = $(element).closest('table.dataTable'); 1285 | $.ajax({ 1286 | type: 'GET', 1287 | url: ... 1288 | }).done(function(data, textStatus, jqXHR) { 1289 | table.DataTable().ajax.reload(null, false); 1290 | }); 1291 | 1292 | Redraw a single table row 1293 | ------------------------- 1294 | 1295 | TODO: THIS DOESN'T SEEM TO WORK PROPERLY 😭 1296 | 1297 | .. code:: javascript 1298 | 1299 | table.DataTable().row(tr).invalidate().draw(); 1300 | 1301 | Example: 1302 | 1303 | .. code:: javascript 1304 | 1305 | var table = $(element).closest('table.dataTable'); 1306 | var table_row_id = table.find('tr.shown').attr('id'); 1307 | $.ajax({ 1308 | type: 'POST', 1309 | url: ... 1310 | }).done(function(data, textStatus, jqXHR) { 1311 | table.DataTable().ajax.reload(null, false); 1312 | 1313 | // Since we've update the record via Ajax, we need to redraw this table row 1314 | var tr = table.find('#' + table_row_id); 1315 | var row = table.DataTable().row(tr) 1316 | row.invalidate().draw(); 1317 | 1318 | // Hack: here we would like to enhance the updated row, by adding the 'updated' class; 1319 | // Since a callback is not available upon draw completion, 1320 | // let's use a timer to try later, and cross fingers 1321 | setTimeout(function() { 1322 | table.find('#' + table_row_id).addClass('updated'); 1323 | }, 200); 1324 | setTimeout(function() { 1325 | table.find('#' + table_row_id).addClass('updated'); 1326 | }, 1000); 1327 | 1328 | }); 1329 | 1330 | change DataTables' error reporting mechanism 1331 | -------------------------------------------- 1332 | 1333 | .. code:: javascript 1334 | 1335 | // change DataTables' error reporting mechanism to throw a Javascript 1336 | // error to the browser's console, rather than alerting it. 1337 | $.fn.dataTable.ext.errMode = 'throw'; 1338 | 1339 | 1340 | JS Utilities 1341 | ============ 1342 | 1343 | - DatatablesViewUtils.init(options) 1344 | - DatatablesViewUtils.initialize_table(element, url, extra_options={}, extra_data={}) 1345 | - DatatablesViewUtils.after_table_initialization(table, data, url) 1346 | - DatatablesViewUtils.adjust_table_columns() 1347 | - DatatablesViewUtils.redraw_all_tables() 1348 | - DatatablesViewUtils.redraw_table(element) 1349 | 1350 | Internationalization 1351 | -------------------- 1352 | 1353 | You can provide localized messages by initializing the DatatablesViewUtils JS module 1354 | as follow (example in italian): 1355 | 1356 | .. code:: javascript 1357 | 1358 | DatatablesViewUtils.init({ 1359 | search_icon_html: '', 1360 | language: { 1361 | "decimal": "", 1362 | "emptyTable": "Nessun dato disponibile", 1363 | "info": "Visualizzate da _START_ a _END_ di _TOTAL_ righe", 1364 | "infoEmpty": "", 1365 | "infoFiltered": "(filtered from _MAX_ total entries)", 1366 | "infoPostFix": "", 1367 | "thousands": ",", 1368 | "lengthMenu": "Visualizza _MENU_ righe per pagina", 1369 | "loadingRecords": "Caricamento in corso ...", 1370 | "processing": "Elaborazione in corso ...", 1371 | "search": "Cerca:", 1372 | "zeroRecords": "Nessun record trovato", 1373 | "paginate": { 1374 | "first": "Prima", 1375 | "last": "Ultima", 1376 | "next": ">>", 1377 | "previous": "<<" 1378 | }, 1379 | "aria": { 1380 | "sortAscending": ": activate to sort column ascending", 1381 | "sortDescending": ": activate to sort column descending" 1382 | } 1383 | } 1384 | }); 1385 | 1386 | 1387 | You can do this, for example, in your "base.html" template, and it will be in effect 1388 | for all subsequent instantiations: 1389 | 1390 | .. code:: html 1391 | 1392 | 1399 | 1400 | 1401 | Application examples 1402 | ==================== 1403 | 1404 | Customize row details by rendering prettified json fields 1405 | --------------------------------------------------------- 1406 | 1407 | .. image:: screenshots/009.png 1408 | 1409 | .. code:: python 1410 | 1411 | import jsonfield 1412 | from datatables_view.views import DatatablesView 1413 | from .utils import json_prettify 1414 | 1415 | 1416 | class MyDatatablesView(DatatablesView): 1417 | 1418 | ... 1419 | 1420 | def render_row_details(self, id, request=None): 1421 | 1422 | obj = self.model.objects.get(id=id) 1423 | fields = [f for f in self.model._meta.get_fields() if f.concrete] 1424 | html = '' 1425 | for field in fields: 1426 | value = getattr(obj, field.name) 1427 | if isinstance(field, jsonfield.JSONField): 1428 | value = json_prettify(value) 1429 | html += '' % (field.name, value) 1430 | html += '
    %s%s
    ' 1431 | return html 1432 | 1433 | where: 1434 | 1435 | .. code:: python 1436 | 1437 | import json 1438 | from pygments import highlight 1439 | from pygments.lexers import JsonLexer 1440 | from pygments.formatters import HtmlFormatter 1441 | from django.utils.safestring import mark_safe 1442 | 1443 | 1444 | def json_prettify_styles(): 1445 | """ 1446 | Used to generate Pygment styles (to be included in a .CSS file) as follows: 1447 | print(json_prettify_styles()) 1448 | """ 1449 | formatter = HtmlFormatter(style='colorful') 1450 | return formatter.get_style_defs() 1451 | 1452 | 1453 | def json_prettify(json_data): 1454 | """ 1455 | Adapted from: 1456 | https://www.pydanny.com/pretty-formatting-json-django-admin.html 1457 | """ 1458 | 1459 | # Get the Pygments formatter 1460 | formatter = HtmlFormatter(style='colorful') 1461 | 1462 | # Highlight the data 1463 | json_text = highlight( 1464 | json.dumps(json_data, indent=2), 1465 | JsonLexer(), 1466 | formatter 1467 | ) 1468 | 1469 | # # remove leading and trailing brances 1470 | # json_text = json_text \ 1471 | # .replace('{\n', '') \ 1472 | # .replace('}\n', '') 1473 | 1474 | # Get the stylesheet 1475 | #style = "" 1476 | style = '' 1477 | 1478 | # Safe the output 1479 | return mark_safe(style + json_text) 1480 | 1481 | 1482 | Change row color based on row content 1483 | ------------------------------------- 1484 | 1485 | .. image:: screenshots/010.png 1486 | 1487 | First, we mark the relevant info with a specific CSS class, so we can search 1488 | for it later 1489 | 1490 | .. code:: html 1491 | 1492 | column_defs = [ 1493 | ... 1494 | }, { 1495 | 'name': 'error_counter', 1496 | 'title': 'errors', 1497 | 'className': 'error_counter', 1498 | }, { 1499 | ... 1500 | ] 1501 | 1502 | Have a callback called after each table redraw 1503 | 1504 | .. code:: javascript 1505 | 1506 | var table = element.DataTable({ 1507 | ... 1508 | }); 1509 | 1510 | table.on('draw.dt', function(event) { 1511 | onTableDraw(event); 1512 | }); 1513 | 1514 | then change the rendered table as needed 1515 | 1516 | .. code:: javascript 1517 | 1518 | var onTableDraw = function (event) { 1519 | 1520 | var html_table = $(event.target); 1521 | html_table.find('tr').each(function(index, item) { 1522 | 1523 | try { 1524 | var row = $(item); 1525 | text = row.children('td.error_counter').first().text(); 1526 | var error_counter = isNaN(text) ? 0 : parseInt(text); 1527 | 1528 | if (error_counter > 0) { 1529 | row.addClass('bold'); 1530 | } 1531 | else { 1532 | row.addClass('grayed'); 1533 | } 1534 | } 1535 | catch(err) { 1536 | } 1537 | 1538 | }); 1539 | } 1540 | 1541 | **or use a rowCallback as follows:** 1542 | 1543 | .. code:: html 1544 | 1545 | // Subscribe "rowCallback" event 1546 | $('#datatable').on('rowCallback', function(event, table, row, data ) { 1547 | $(row).addClass(data.read ? 'read' : 'unread'); 1548 | } 1549 | 1550 | This works even if the 'read' column we're interested in is actually not visible. 1551 | 1552 | 1553 | Modify table content on the fly (via ajax) 1554 | ------------------------------------------ 1555 | 1556 | .. image:: screenshots/008.png 1557 | 1558 | Row details customization: 1559 | 1560 | .. code:: javascript 1561 | 1562 | def render_row_details(self, id, request=None): 1563 | 1564 | obj = self.model.objects.get(id=id) 1565 | html = '' 1566 | html += "' 1581 | 1582 | Client-side code: 1583 | 1584 | .. code:: javascript 1585 | 1586 | 71 | 72 | */ 73 | _options = options; 74 | 75 | if (!('language' in _options)) { 76 | _options.language = {}; 77 | } 78 | } 79 | 80 | 81 | function _handle_column_filter(table, data, target) { 82 | var index = target.data('index'); 83 | var value = target.val(); 84 | 85 | var column = table.api().column(index); 86 | var old_value = column.search(); 87 | console.log('Request to search value %o in column %o (current value: %o)', value, index, old_value); 88 | if (value != old_value) { 89 | console.log('searching ...'); 90 | column.search(value).draw(); 91 | } 92 | else { 93 | console.log('skipped'); 94 | } 95 | }; 96 | 97 | /* 98 | function getCookie(cname) { 99 | var name = cname + "="; 100 | var ca = document.cookie.split(';'); 101 | for(var i=0; i'); 130 | $(item.choices).each(function(index, choice) { 131 | var option = $("'; 149 | } 150 | else { 151 | filter_row += ''; 152 | } 153 | } 154 | else { 155 | if (index == 0) { 156 | var search_icon_html = _options.search_icon_html === undefined ? 157 | '
    ?
    ' : _options.search_icon_html; 158 | //filter_row += ''; 159 | filter_row += ''; 160 | } 161 | else { 162 | filter_row += ''; 163 | } 164 | } 165 | } 166 | }); 167 | filter_row += ''; 168 | 169 | var wrapper = table.closest('.dataTables_wrapper'); 170 | $(filter_row).appendTo( 171 | wrapper.find('thead') 172 | ); 173 | 174 | var column_filter_row = wrapper.find('.datatable-column-filter-row') 175 | column_filter_row.find('input,select').off().on('keyup change', function(event) { 176 | var target = $(event.target); 177 | _handle_column_filter(table, data, target); 178 | }); 179 | 180 | /* 181 | // Here, we could explicitly invoke the handler for each column filter, 182 | // to make sure that the initial table contents respect any (possible) 183 | // default value assigned to column filters. 184 | // This works, but causes multiple POST requests during the first table rendering. 185 | 186 | column_filter_row.find('input,select').each( function(index, item) { 187 | var target = $(item); 188 | _handle_column_filter(table, data, target); 189 | }); 190 | 191 | So we now prefer to supply the initial search value in the column initialization: 192 | see "searchCols" table attribute, as documented here: 193 | https://datatables.net/reference/option/searchCols 194 | */ 195 | } 196 | }; 197 | 198 | 199 | function _bind_row_tools(table, url, full_row_select, custom_id='id') 200 | { 201 | console.log('*** _bind_row_tools()'); 202 | if (!full_row_select) { 203 | table.api().on('click', 'td.dataTables_row-tools .plus, td.dataTables_row-tools .minus', function(event) { 204 | event.preventDefault(); 205 | var tr = $(this).closest('tr'); 206 | var row = table.api().row(tr); 207 | if (row.child.isShown()) { 208 | row.child.hide(); 209 | tr.removeClass('shown'); 210 | } 211 | else { 212 | row.child(_load_row_details(row.data(), url, custom_id), 'details').show('slow'); 213 | tr.addClass('shown'); 214 | } 215 | }); 216 | } 217 | else { 218 | table.api().on('click', 'td', function(event) { 219 | //event.preventDefault(); 220 | var tr = $(this).closest('tr'); 221 | 222 | // Dont' close child when clicking inside child itself, 223 | // unless clicking on a button with class "btn-close" 224 | if (tr.hasClass('details') && !$(event.target).hasClass('btn-close')) { 225 | return; 226 | } 227 | 228 | var row = table.api().row(tr); 229 | if (row.child.isShown()) { 230 | row.child.hide(); 231 | tr.removeClass('shown'); 232 | } 233 | else { 234 | table.find('tr').removeClass('shown'); 235 | table.api().rows().every(function( rowIdx, tableLoop, rowLoop) { 236 | this.child.hide(); 237 | }); 238 | if (!tr.hasClass('details')) { 239 | row.child(_load_row_details(row.data(), url, custom_id), 'details').show('slow'); 240 | tr.addClass('shown'); 241 | } 242 | } 243 | }); 244 | } 245 | }; 246 | 247 | function _load_row_details(rowData, url, custom_id) { 248 | var div = $('
    ') 249 | .addClass('row-details-wrapper loading') 250 | .text('Loading...'); 251 | 252 | if (rowData !== undefined) { 253 | $.ajax({ 254 | url: url, 255 | data: { 256 | action: 'details', 257 | id: rowData[custom_id] 258 | }, 259 | dataType: 'json', 260 | success: function(json) { 261 | var parent_row_id = json['parent-row-id']; 262 | if (parent_row_id !== undefined) { 263 | div.attr('data-parent-row-id', parent_row_id); 264 | } 265 | div.html(json.html).removeClass('loading'); 266 | } 267 | }); 268 | } 269 | 270 | return div; 271 | }; 272 | 273 | 274 | function adjust_table_columns() { 275 | // Adjust the column widths of all visible tables 276 | // https://datatables.net/reference/api/%24.fn.dataTable.tables() 277 | $.fn.dataTable 278 | .tables({ 279 | visible: true, 280 | api: true 281 | }) 282 | .columns.adjust(); 283 | }; 284 | 285 | 286 | function _daterange_widget_initialize(table, data) { 287 | if (data.show_date_filters) { 288 | if (_options.fn_daterange_widget_initialize) { 289 | _options.fn_daterange_widget_initialize(table, data); 290 | } 291 | else { 292 | var wrapper = table.closest('.dataTables_wrapper'); 293 | var toolbar = wrapper.find(".toolbar"); 294 | toolbar.html( 295 | '
    ' + 296 | 'From: ' + 297 | 'To: ' + 298 | '
    ' 299 | ); 300 | toolbar.find('.date_from, .date_to').on('change', function(event) { 301 | // Annotate table with values retrieved from date widgets 302 | table.data('date_from', wrapper.find('.date_from').val()); 303 | table.data('date_to', wrapper.find('.date_to').val()); 304 | // Redraw table 305 | table.api().draw(); 306 | }); 307 | } 308 | } 309 | } 310 | 311 | 312 | function after_table_initialization(table, data, url, full_row_select) { 313 | console.log('*** after_table_initialization()'); 314 | _bind_row_tools(table, url, full_row_select); 315 | _setup_column_filters(table, data); 316 | } 317 | 318 | 319 | function _write_footer(table, html) { 320 | var wrapper = table.closest('.dataTables_wrapper'); 321 | var footer = wrapper.find('.dataTables_extraFooter'); 322 | if (footer.length <= 0) { 323 | $('
    ').appendTo(wrapper); 324 | footer = wrapper.find('.dataTables_extraFooter'); 325 | } 326 | footer.html(html); 327 | } 328 | 329 | function initialize_table(element, url, extra_options={}, extra_data={}) { 330 | 331 | $.ajax({ 332 | type: 'GET', 333 | url: url + '?action=initialize', 334 | dataType: 'json' 335 | }).done(function(data, textStatus, jqXHR) { 336 | 337 | // https://datatables.net/manual/api#Accessing-the-API 338 | // It is important to note the difference between: 339 | // - $(selector).DataTable(): returns a DataTables API instance 340 | // - $(selector).dataTable(): returns a jQuery object 341 | // An api() method is added to the jQuery object so you can easily access the API, 342 | // but the jQuery object can be useful for manipulating the table node, 343 | // as you would with any other jQuery instance (such as using addClass(), etc.). 344 | 345 | var options = { 346 | processing: true, 347 | serverSide: true, 348 | scrollX: true, 349 | autoWidth: true, 350 | dom: '<"toolbar">lrftip', 351 | language: _options.language, 352 | full_row_select: false, 353 | // language: { 354 | // "decimal": "", 355 | // "emptyTable": "Nessun dato disponibile per la tabella", 356 | // "info": "Visualizzate da _START_ a _END_ di _TOTAL_ entries", 357 | // "infoEmpty": "Visualizzate da 0 a 0 di 0 entries", 358 | // "infoFiltered": "(filtered from _MAX_ total entries)", 359 | // "infoPostFix": "", 360 | // "thousands": ",", 361 | // "lengthMenu": "Visualizza _MENU_ righe per pagina", 362 | // "loadingRecords": "Caricamento in corso ...", 363 | // "processing": "Elaborazione in corso ...", 364 | // "search": "Cerca:", 365 | // "zeroRecords": "Nessun record trovato", 366 | // "paginate": { 367 | // "first": "Prima", 368 | // "last": "Ultima", 369 | // "next": "Prossima", 370 | // "previous": "Precedente" 371 | // }, 372 | // "aria": { 373 | // "sortAscending": ": activate to sort column ascending", 374 | // "sortDescending": ": activate to sort column descending" 375 | // } 376 | // }, 377 | ajax: function(data, callback, settings) { 378 | var table = $(this); 379 | data.date_from = table.data('date_from'); 380 | data.date_to = table.data('date_to'); 381 | if (extra_data) { 382 | Object.assign(data, extra_data); 383 | } 384 | console.log("data tx: %o", data); 385 | $.ajax({ 386 | type: 'POST', 387 | url: url, 388 | data: data, 389 | dataType: 'json', 390 | cache: false, 391 | crossDomain: false, 392 | headers: {'X-CSRFToken': getCookie('csrftoken')} 393 | }).done(function(data, textStatus, jqXHR) { 394 | console.log('data rx: %o', data); 395 | callback(data); 396 | 397 | var footer_message = data.footer_message; 398 | if (footer_message !== null) { 399 | _write_footer(table, footer_message); 400 | } 401 | 402 | }).fail(function(jqXHR, textStatus, errorThrown) { 403 | console.log('ERROR: ' + jqXHR.responseText); 404 | }); 405 | }, 406 | columns: data.columns, 407 | searchCols: data.searchCols, 408 | lengthMenu: data.length_menu, 409 | order: data.order, 410 | initComplete: function() { 411 | // HACK: wait 200 ms then adjust the column widths 412 | // of all visible tables 413 | setTimeout(function() { 414 | DatatablesViewUtils.adjust_table_columns(); 415 | }, 200); 416 | 417 | // Notify subscribers 418 | //console.log('Broadcast initComplete()'); 419 | table.trigger( 420 | 'initComplete', [table] 421 | ); 422 | }, 423 | drawCallback: function(settings) { 424 | // Notify subscribers 425 | //console.log('Broadcast drawCallback()'); 426 | table.trigger( 427 | 'drawCallback', [table, settings] 428 | ); 429 | }, 430 | rowCallback: function(row, data) { 431 | // Notify subscribers 432 | //console.log('Broadcast rowCallback()'); 433 | table.trigger( 434 | 'rowCallback', [table, row, data] 435 | ); 436 | }, 437 | footerCallback: function (row, data, start, end, display) { 438 | // Notify subscribers 439 | //console.log('Broadcast footerCallback()'); 440 | table.trigger( 441 | 'footerCallback', [table, row, data, start, end, display] 442 | ); 443 | } 444 | } 445 | 446 | if (extra_options) { 447 | Object.assign(options, extra_options); 448 | } 449 | 450 | var table = element.dataTable(options); 451 | 452 | _daterange_widget_initialize(table, data); 453 | after_table_initialization(table, data, url, options.full_row_select); 454 | }) 455 | } 456 | 457 | 458 | function redraw_all_tables() { 459 | $.fn.dataTable.tables({ 460 | api: true 461 | }).draw(); 462 | } 463 | 464 | 465 | // Redraw table holding the current paging position 466 | function redraw_table(element) { 467 | var table = $(element).closest('table.dataTable'); 468 | // console.log('element: %o', element); 469 | // console.log('table: %o', table); 470 | table.DataTable().ajax.reload(null, false); 471 | } 472 | 473 | 474 | return { 475 | init: init, 476 | initialize_table: initialize_table, 477 | after_table_initialization: after_table_initialization, 478 | adjust_table_columns: adjust_table_columns, 479 | redraw_all_tables: redraw_all_tables, 480 | redraw_table: redraw_table 481 | }; 482 | 483 | })(); 484 | -------------------------------------------------------------------------------- /datatables_view/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block title %}{{ SITE_TITLE }}{% endblock %} 10 | 11 | {% block extrahead %} 12 | {% endblock extrahead %} 13 | 14 | {% comment %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {# #} 22 | {% endcomment %} 23 | 24 | 25 | 26 | 27 |
    28 | {% block content %} 29 | {% endblock content %} 30 |
    31 | 32 | {% comment %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% endcomment %} 47 | 48 | 49 | {# Additional JS files in footer, right before #} 50 | {% block extrajs %} 51 | {% comment %} 52 | 53 | 57 | {% endcomment %} 58 | {% endblock %} 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /datatables_view/templates/datatables_view/datatable.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static i18n %} 3 | 4 | 5 | {% block content %} 6 | 7 |

    {{title}}

    8 | 9 |
    10 |
    alarm status:" 1567 | for choice in BaseTask.ALARM_STATUS_CHOICES: 1568 | # Lo stato corrente lo visualizziamo in grassetto 1569 | if choice[0] == obj.alarm: 1570 | html += '%s ' % (choice[1]) 1571 | else: 1572 | # Se non "unalarmed", mostriamo i link per cambiare lo stato 1573 | # (tutti tranne "unalarmed") 1574 | if obj.alarm != BaseTask.ALARM_STATUS_UNALARMED and choice[0] != BaseTask.ALARM_STATUS_UNALARMED: 1575 | html += '%s ' % ( 1576 | str(obj.id), 1577 | choice[0], 1578 | choice[1] 1579 | ) 1580 | html += '
    ' + html + '' + html + ' ' + search_icon_html + ' 
    11 | 12 |
    13 | 14 | {% endblock content %} 15 | 16 | 17 | {% block extrajs %} 18 | {{ block.super }} 19 | 20 | 55 | {% endblock %} 56 | 57 | 58 | -------------------------------------------------------------------------------- /datatables_view/templates/datatables_view/row_tools.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | 3 |
    4 | 5 | 6 | 7 | 8 | 9 | 10 |
    11 | -------------------------------------------------------------------------------- /datatables_view/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/datatables_view/templatetags/__init__.py -------------------------------------------------------------------------------- /datatables_view/templatetags/datatables_view_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | ################################################################################ 7 | # Support for generic editing in the front-end 8 | 9 | @register.filter 10 | def model_verbose_name(model): 11 | """ 12 | Sample usage: 13 | {{model|model_name}} 14 | """ 15 | return model._meta.verbose_name 16 | 17 | 18 | @register.filter 19 | def model_verbose_name_plural(model): 20 | """ 21 | Sample usage: 22 | {{model|model_name}} 23 | """ 24 | return model._meta.verbose_name_plural 25 | 26 | 27 | @register.filter 28 | def model_name(model): 29 | """ 30 | Sample usage: 31 | {{model|model_name}} 32 | """ 33 | return model._meta.model_name 34 | 35 | 36 | @register.filter 37 | def app_label(model): 38 | """ 39 | Sample usage: 40 | {{model|app_label}} 41 | """ 42 | return model._meta.app_label 43 | 44 | 45 | @register.simple_tag(takes_context=True) 46 | def testhasperm(context, model, action): 47 | """ 48 | Returns True iif the user have the specified permission over the model. 49 | For 'model', we accept either a Model class, or a string formatted as "app_label.model_name". 50 | 51 | Sample usage: 52 | 53 | {% testhasperm model 'view' as can_view_objects %} 54 | {% if not can_view_objects %} 55 |

    Sorry, you have no permission to view these objects

    56 | {% endif %} 57 | """ 58 | user = context['request'].user 59 | if isinstance(model, str): 60 | app_label, model_name = model.split('.') 61 | else: 62 | app_label = model._meta.app_label 63 | model_name = model._meta.model_name 64 | required_permission = '%s.%s_%s' % (app_label, action, model_name) 65 | return user.is_authenticated and user.has_perm(required_permission) 66 | 67 | 68 | @register.tag 69 | def ifhasperm(parser, token): 70 | """ 71 | Check user permission over specified model. 72 | (You can specify either a model or an object). 73 | 74 | Sample usage: 75 | 76 | {% ifhasperm model 'add' %} 77 |
    User can add objects
    78 | {% else %} 79 |
    User cannot add objects
    80 | {% endifhasperm %} 81 | """ 82 | 83 | # Separating the tag name from the parameters 84 | try: 85 | tag, model, action = token.contents.split() 86 | except (ValueError, TypeError): 87 | raise template.TemplateSyntaxError( 88 | "'%s' tag takes three parameters" % tag) 89 | 90 | default_states = ['ifhasperm', 'else'] 91 | end_tag = 'endifhasperm' 92 | 93 | # Place to store the states and their values 94 | states = {} 95 | 96 | # Let's iterate over our context and find our tokens 97 | while token.contents != end_tag: 98 | current = token.contents 99 | states[current.split()[0]] = parser.parse(default_states + [end_tag]) 100 | token = parser.next_token() 101 | 102 | model_var = parser.compile_filter(model) 103 | action_var = parser.compile_filter(action) 104 | return CheckPermNode(states, model_var, action_var) 105 | 106 | 107 | class CheckPermNode(template.Node): 108 | def __init__(self, states, model_var, action_var): 109 | self.states = states 110 | self.model_var = model_var 111 | self.action_var = action_var 112 | 113 | def render(self, context): 114 | 115 | # Resolving variables passed by the user 116 | model = self.model_var.resolve(context) 117 | action = self.action_var.resolve(context) 118 | 119 | # Check user permission 120 | if testhasperm(context, model, action): 121 | html = self.states['ifhasperm'].render(context) 122 | else: 123 | html = self.states['else'].render(context) if 'else' in self.states else '' 124 | 125 | return html 126 | -------------------------------------------------------------------------------- /datatables_view/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/datatables_view/tests/__init__.py -------------------------------------------------------------------------------- /datatables_view/tests/test_autofilter.py: -------------------------------------------------------------------------------- 1 | #from django.test import TestCase 2 | from django.db import models 3 | from unittest import TestCase 4 | import factory 5 | import factory.random 6 | from django.contrib.auth import get_user_model 7 | from django.core.paginator import Paginator 8 | from datatables_view import * 9 | 10 | 11 | User = get_user_model() 12 | 13 | 14 | class UserDatatablesView(DatatablesView): 15 | model = User 16 | column_defs = [ 17 | DatatablesView.render_row_tools_column_def(), 18 | { 19 | 'name': 'id', 20 | 'visible': False, 21 | }, { 22 | 'name': 'username', 23 | }, { 24 | 'name': 'first_name', 25 | 'choices': True, 26 | 'autofilter': True, 27 | }, { 28 | 'name': 'last_name', 29 | } 30 | ] 31 | 32 | 33 | class UserDatatablesWithWrongKeyView(DatatablesView): 34 | model = User 35 | column_defs = [ 36 | DatatablesView.render_row_tools_column_def(), 37 | { 38 | 'name': 'id', 39 | 'visible': False, 40 | }, { 41 | 'name': 'username', 42 | 'wrongkey': 'you_baaaad', 43 | }, { 44 | 'name': 'first_name', 45 | 'choices': True, 46 | 'autofilter': True, 47 | }, { 48 | 'name': 'last_name', 49 | } 50 | ] 51 | 52 | 53 | class UserDatatablesWithEmptyColumnNameView(DatatablesView): 54 | model = User 55 | column_defs = [ 56 | DatatablesView.render_row_tools_column_def(), 57 | { 58 | 'name': 'id', 59 | 'visible': False, 60 | }, { 61 | 'name': '', 62 | }, { 63 | 'name': '', 64 | }, { 65 | 'name': 'first_name', 66 | 'choices': True, 67 | 'autofilter': True, 68 | }, { 69 | 'name': 'last_name', 70 | } 71 | ] 72 | 73 | 74 | class UserFactory(factory.django.DjangoModelFactory): 75 | 76 | class Meta: 77 | model = User 78 | 79 | username = factory.Sequence(lambda n: 'username_{}'.format(n)) 80 | password = 'password' 81 | first_name = factory.Faker('first_name') 82 | last_name = factory.Faker('last_name') 83 | 84 | 85 | class AutoFilterTestCase(TestCase): 86 | 87 | def setUp(self): 88 | factory.random.reseed_random('test_static_columns') 89 | self.build_fake_data() 90 | 91 | def tearDown(self): 92 | User.objects.all().delete() 93 | 94 | def build_fake_data(self): 95 | UserFactory.create_batch(100) 96 | 97 | def test_autofilter_columns(self): 98 | 99 | request = None 100 | view = UserDatatablesView() 101 | view.initialize(request) 102 | print(view.column_spec_by_name('first_name')) 103 | 104 | # Since we activated 'autofilter' for 'first_name', 105 | # we should see a choices list filled with distinct values 106 | queryset = view.get_initial_queryset(request) 107 | names = queryset.values_list('first_name', flat=True).distinct().order_by('first_name') 108 | # Example: 109 | # [('Alicia', 'Alicia'), ('Amanda', 'Amanda'), ('Amy', 'Amy'), ... 110 | expected_choices = [(item, item) for item in names] 111 | 112 | self.assertSequenceEqual(expected_choices, view.column_spec_by_name('first_name')['choices']) 113 | 114 | def test_unexcpeted_key(self): 115 | 116 | request = None 117 | view = UserDatatablesWithWrongKeyView() 118 | 119 | with self.assertRaises(Exception) as raise_context: 120 | view.initialize(request) 121 | self.assertTrue('Unexpected key "wrongkey"' in str(raise_context.exception)) 122 | print(str(raise_context.exception)) 123 | 124 | def test_missing_column_name(self): 125 | # TODO: to be investigated 126 | request = None 127 | view = UserDatatablesWithEmptyColumnNameView() 128 | view.initialize(request) 129 | -------------------------------------------------------------------------------- /datatables_view/tests/test_choices_filters.py: -------------------------------------------------------------------------------- 1 | #from django.test import TestCase 2 | from django.db import models 3 | import unittest 4 | from datatables_view import * 5 | 6 | 7 | class TestModel(models.Model): 8 | 9 | F5_CHOICES = (('aaa', 'AAA'), ('bbb', 'BBB'),) 10 | 11 | f1 = models.CharField(max_length=20) 12 | f2 = models.CharField(max_length=20) 13 | f3 = models.CharField(max_length=20) 14 | f4 = models.CharField(max_length=20) 15 | f5 = models.CharField(max_length=20, choices=F5_CHOICES) 16 | f6 = models.CharField(max_length=20) 17 | f7 = models.CharField(max_length=20) 18 | 19 | class Meta: 20 | app_label = 'myappname' 21 | 22 | 23 | class TestDatatablesView(DatatablesView): 24 | model = TestModel 25 | 26 | F4_CHOICES = (('one', 'One'), ('two', 'Two'), ('three', 'Three'), ) 27 | 28 | column_defs = [ 29 | DatatablesView.render_row_tools_column_def(), 30 | { 31 | 'name': 'id', 32 | 'visible': False, 33 | }, { 34 | # Don't use choices 35 | 'name': 'f1', 36 | #'choices': None 37 | }, { 38 | # Don't use choices 39 | 'name': 'f2', 40 | 'choices': None, 41 | }, { 42 | # Don't use choices 43 | 'name': 'f3', 44 | 'choices': False, 45 | }, { 46 | # Use supplied choices 47 | 'name': 'f4', 48 | 'choices': F4_CHOICES, 49 | }, { 50 | # Copy from field's choices if any (found) 51 | 'name': 'f5', 52 | 'choices': True, 53 | }, { 54 | # Copy from field's choices if any (not found) 55 | 'name': 'f6', 56 | 'choices': True, 57 | } 58 | ] 59 | 60 | class ChoicesFiltersTestCase(unittest.TestCase): 61 | 62 | def test_choices_filters_defs(self): 63 | 64 | request = None 65 | 66 | view = TestDatatablesView() 67 | view.initialize(request) 68 | 69 | print(view.column_spec_by_name('f1')) 70 | print(view.column_spec_by_name('f2')) 71 | print(view.column_spec_by_name('f3')) 72 | print(view.column_spec_by_name('f4')) 73 | print(view.column_spec_by_name('f5')) 74 | print(view.column_spec_by_name('f6')) 75 | 76 | self.assertEqual(None, view.column_spec_by_name('f1')['choices']) 77 | self.assertEqual(None, view.column_spec_by_name('f2')['choices']) 78 | self.assertEqual(None, view.column_spec_by_name('f3')['choices']) 79 | self.assertSequenceEqual(TestDatatablesView.F4_CHOICES, view.column_spec_by_name('f4')['choices']) 80 | self.assertSequenceEqual(TestModel.F5_CHOICES, view.column_spec_by_name('f5')['choices']) 81 | self.assertEqual(None, view.column_spec_by_name('f6')['choices']) 82 | -------------------------------------------------------------------------------- /datatables_view/tests/test_columndefs.py: -------------------------------------------------------------------------------- 1 | #from django.test import TestCase 2 | from django.db import models 3 | from unittest import TestCase 4 | import factory 5 | import factory.random 6 | from django.contrib.auth import get_user_model 7 | from django.core.paginator import Paginator 8 | from datatables_view import * 9 | 10 | 11 | User = get_user_model() 12 | 13 | 14 | class UserDatatablesView(DatatablesView): 15 | 16 | model = User 17 | 18 | column_defs = [ 19 | DatatablesView.render_row_tools_column_def(), 20 | { 21 | 'name': 'id', 22 | 'visible': False, 23 | }, { 24 | 'name': 'username', 25 | }, { 26 | 'name': 'first_name', 27 | }, { 28 | 'name': 'last_name', 29 | } 30 | ] 31 | 32 | 33 | class UserFactory(factory.django.DjangoModelFactory): 34 | 35 | class Meta: 36 | model = User 37 | 38 | username = factory.Sequence(lambda n: 'username_{}'.format(n)) 39 | password = 'password' 40 | first_name = factory.Faker('first_name') 41 | last_name = factory.Faker('last_name') 42 | 43 | 44 | class TestColumnDefs(TestCase): 45 | 46 | def setUp(self): 47 | factory.random.reseed_random('test_static_columns') 48 | self.build_fake_data() 49 | 50 | def tearDown(self): 51 | User.objects.all().delete() 52 | 53 | def build_fake_data(self): 54 | UserFactory.create_batch(100) 55 | 56 | def test_static_columns(self): 57 | 58 | view = UserDatatablesView() 59 | queryset = view.get_initial_queryset() 60 | per_page = 10 61 | paginator = Paginator(queryset, per_page=per_page) 62 | 63 | request = None 64 | view.initialize(request) 65 | response_dict = view.get_response_dict(request, paginator, draw_idx=0, start_pos=0) 66 | 67 | self.assertEqual(100, response_dict['recordsTotal']) 68 | self.assertEqual(100, response_dict['recordsFiltered']) 69 | data = response_dict['data'] 70 | self.assertEqual(per_page, len(data)) 71 | 72 | for row in data: 73 | user = User.objects.get(id=row['id']) 74 | self.assertEqual(user.username, row['username']) 75 | self.assertEqual(user.first_name, row['first_name']) 76 | self.assertEqual(user.last_name, row['last_name']) 77 | -------------------------------------------------------------------------------- /datatables_view/tests/test_datatables_qs.py: -------------------------------------------------------------------------------- 1 | #from django.test import TestCase 2 | from django.db import models 3 | import unittest 4 | from datatables_view import * 5 | 6 | 7 | class MyTestModel(models.Model): 8 | one = models.CharField(max_length=20) 9 | two = models.CharField(max_length=20) 10 | 11 | class Meta: 12 | app_label = 'myappname' 13 | 14 | 15 | #class TestAuth(TestCase): 16 | class TestDatatablesQs(unittest.TestCase): 17 | 18 | def test_order(self): 19 | 20 | column_specs = [{ 21 | 'name': 'one', 22 | 'foreign_field': '', 23 | }, { 24 | 'name': 'two', 25 | 'foreign_field': '', 26 | }] 27 | 28 | #model_columns = Column.collect_model_columns(MyTestModel, ['one', 'two']) 29 | #model_columns = Column.collect_model_columns(MyTestModel, column_specs) 30 | model_columns = { 31 | 'one': Column.column_factory(MyTestModel, column_specs[0]), 32 | 'two': Column.column_factory(MyTestModel, column_specs[1]), 33 | } 34 | column_links = [ 35 | ColumnLink('one', model_columns['one']), 36 | ColumnLink('two', model_columns['two'], placeholder=True), 37 | ] 38 | 39 | order_one_asc = Order(0, 'asc', column_links) 40 | print(order_one_asc) 41 | self.assertEqual('one', order_one_asc.get_order_mode()) 42 | 43 | order_one_desc = Order(0, 'desc', column_links) 44 | print(order_one_desc) 45 | self.assertEqual('-one', order_one_desc.get_order_mode()) 46 | 47 | self.assertRaises( 48 | ColumnOrderError, 49 | lambda: Order(1, 'asc', column_links) 50 | ) 51 | 52 | -------------------------------------------------------------------------------- /datatables_view/tests/test_date_filters.py: -------------------------------------------------------------------------------- 1 | #from django.test import TestCase 2 | from django.db import models 3 | import unittest 4 | from datatables_view import * 5 | 6 | 7 | class TestModelWithoutLatestBy(models.Model): 8 | one = models.CharField(max_length=20) 9 | two = models.CharField(max_length=20) 10 | 11 | class Meta: 12 | app_label = 'myappname2' 13 | 14 | 15 | class TestModelWithLatestBy(models.Model): 16 | one = models.CharField(max_length=20) 17 | two = models.CharField(max_length=20) 18 | 19 | class Meta: 20 | app_label = 'myappname2' 21 | get_latest_by = "one" 22 | 23 | 24 | class DatatablesWithoutLatestByView(DatatablesView): 25 | model = TestModelWithoutLatestBy 26 | 27 | 28 | class DatatablesWithLatestByView(DatatablesView): 29 | model = TestModelWithLatestBy 30 | 31 | column_defs = [ 32 | DatatablesView.render_row_tools_column_def(), 33 | { 34 | 'name': 'id', 35 | 'visible': False, 36 | }, { 37 | 'name': 'one', 38 | }, { 39 | 'name': 'two', 40 | } 41 | ] 42 | 43 | class DatatablesForceFilterView(DatatablesView): 44 | model = TestModelWithoutLatestBy 45 | 46 | def get_show_date_filters(self, request): 47 | return True 48 | 49 | 50 | class DateFiltersTestCase(unittest.TestCase): 51 | 52 | def test_filters_flag(self): 53 | 54 | request = None 55 | 56 | view = DatatablesWithoutLatestByView() 57 | view.initialize(request) 58 | self.assertIsNone(view.latest_by) 59 | self.assertFalse(view.show_date_filters) 60 | 61 | view = DatatablesWithLatestByView() 62 | view.initialize(request) 63 | self.assertIsNotNone(view.latest_by) 64 | self.assertTrue(view.show_date_filters) 65 | 66 | column_spec = view.column_spec_by_name(view.latest_by) 67 | self.assertIsNotNone(column_spec) 68 | self.assertIn('latest_by', column_spec.get('className', '')) 69 | 70 | view = DatatablesForceFilterView() 71 | view.initialize(request) 72 | self.assertTrue(view.show_date_filters) 73 | -------------------------------------------------------------------------------- /datatables_view/utils.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | import datetime 3 | from django.utils import timezone 4 | from django.conf import settings 5 | from django.utils import formats 6 | 7 | import pytz 8 | 9 | try: 10 | import sqlparse 11 | except ImportError: 12 | class sqlparse: 13 | @staticmethod 14 | def format(text, *args, **kwargs): 15 | return text 16 | 17 | 18 | def trace(message, prompt=''): 19 | print('\n\x1b[1;36;40m', end='') 20 | if prompt: 21 | print(prompt + ':') 22 | pprint.pprint(message) 23 | print('\x1b[0m\n', end='') 24 | 25 | 26 | def prettyprint_queryset(qs): 27 | print('\x1b[1;33;40m', end='') 28 | # https://code.djangoproject.com/ticket/22973 !!! 29 | try: 30 | message = sqlparse.format(str(qs.query), reindent=True, keyword_case='upper') 31 | except Exception as e: 32 | message = str(e) 33 | if not message: 34 | message = repr(e) 35 | message = 'ERROR: ' + message 36 | print(message) 37 | print('\x1b[0m\n') 38 | 39 | 40 | def format_datetime(dt, include_time=True): 41 | """ 42 | Here we adopt the following rule: 43 | 1) format date according to active localization 44 | 2) append time in military format 45 | """ 46 | if dt is None: 47 | return '' 48 | 49 | if isinstance(dt, datetime.datetime): 50 | try: 51 | dt = timezone.localtime(dt) 52 | except: 53 | local_tz = pytz.timezone(getattr(settings, 'TIME_ZONE', 'UTC')) 54 | dt = local_tz.localize(dt) 55 | else: 56 | assert isinstance(dt, datetime.date) 57 | include_time = False 58 | 59 | use_l10n = getattr(settings, 'USE_L10N', False) 60 | text = formats.date_format(dt, use_l10n=use_l10n, format='SHORT_DATE_FORMAT') 61 | if include_time: 62 | text += dt.strftime(' %H:%M:%S') 63 | return text 64 | 65 | 66 | def parse_date(formatted_date): 67 | parsed_date = None 68 | for date_format in formats.get_format('DATE_INPUT_FORMATS'): 69 | try: 70 | parsed_date = datetime.datetime.strptime(formatted_date, date_format) 71 | except ValueError: 72 | continue 73 | else: 74 | break 75 | if not parsed_date: 76 | raise ValueError 77 | return parsed_date.date() 78 | -------------------------------------------------------------------------------- /datatables_view/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | #from django.utils import six 4 | 5 | import datetime 6 | import json 7 | from django.views.generic import View 8 | from django.http.response import HttpResponse, HttpResponseBadRequest 9 | from django.core.paginator import Paginator 10 | from django.core.serializers.json import DjangoJSONEncoder 11 | from django.db import models 12 | from django.db.models import Q 13 | from django.shortcuts import render 14 | from django.views.decorators.csrf import csrf_exempt 15 | from django.utils.decorators import method_decorator 16 | from django.template.loader import render_to_string 17 | from django.http import JsonResponse 18 | from django.utils.safestring import mark_safe 19 | from django.template.loader import render_to_string 20 | from django.template import TemplateDoesNotExist 21 | from django.template import loader, Context 22 | from django.utils.translation import ugettext_lazy as _ 23 | 24 | from .columns import Column 25 | from .columns import ForeignColumn 26 | from .columns import ColumnLink 27 | from .columns import PlaceholderColumnLink 28 | from .columns import Order 29 | from .exceptions import ColumnOrderError 30 | from .utils import prettyprint_queryset 31 | from .utils import trace 32 | from .utils import format_datetime 33 | from .filters import build_column_filter 34 | from .app_settings import MAX_COLUMNS 35 | from .app_settings import ENABLE_QUERYSET_TRACING 36 | from .app_settings import ENABLE_QUERYDICT_TRACING 37 | from .app_settings import TEST_FILTERS 38 | from .app_settings import DISABLE_QUERYSET_OPTIMIZATION 39 | 40 | 41 | class DatatablesView(View): 42 | 43 | # Either override in derived class, or override self.get_column_defs() 44 | column_defs = [] 45 | 46 | model = None 47 | template_name = 'datatables_view/datatable.html' 48 | initial_order = [[1, "asc"]] 49 | length_menu = [[10, 20, 50, 100], [10, 20, 50, 100]] 50 | table_row_id_prefix = 'row-' 51 | table_row_id_fieldname = 'id' 52 | 53 | # Set with self.initialize() 54 | column_specs = [] # used to keep column ording as required 55 | column_index = {} # used to speedup lookups 56 | #column_objs_lut = {} 57 | 58 | #model_columns = {} 59 | latest_by = None 60 | show_date_filters = None 61 | show_column_filters = None 62 | 63 | disable_queryset_optimization = False 64 | 65 | def initialize(self, request): 66 | 67 | # Retrieve and normalize latest_by fieldname 68 | latest_by = self.get_latest_by(request) 69 | if latest_by is None: 70 | latest_by = getattr(self.model._meta, 'get_latest_by', None) 71 | if isinstance(latest_by, (list, tuple)): 72 | latest_by = latest_by[0] if len(latest_by) > 0 else '' 73 | if latest_by: 74 | if latest_by.startswith('-'): 75 | latest_by = latest_by[1:] 76 | self.latest_by = latest_by 77 | 78 | # Grab column defs and initialize self.column_specs 79 | column_defs_ex = self.get_column_defs(request) 80 | self.column_specs = [] 81 | for c in column_defs_ex: 82 | 83 | column = { 84 | 'name': '', 85 | 'data': None, 86 | 'title': '', 87 | 'searchable': False, 88 | 'orderable': False, 89 | 'visible': True, 90 | 'foreign_field': None, 91 | 'placeholder': False, 92 | 'className': None, 93 | 'defaultContent': None, 94 | 'width': None, 95 | 'choices': None, 96 | 'initialSearchValue': None, 97 | 'autofilter': False, 98 | 'boolean': False, 99 | 'max_length': 0, 100 | } 101 | 102 | #valid_keys = [key for key in column.keys()][:] 103 | #valid_keys = column.keys().copy() 104 | valid_keys = list(column.keys()) 105 | 106 | column.update(c) 107 | 108 | # We now accept a collable as "initialSearchValue" 109 | if callable(column['initialSearchValue']): 110 | column['initialSearchValue'] = column['initialSearchValue']() 111 | 112 | # TODO: do we really want to accept an empty column name ? 113 | # Investigate ! 114 | if c['name']: 115 | 116 | # Detect unexpected keys 117 | for key in c.keys(): 118 | if not key in valid_keys: 119 | raise Exception('Unexpected key "%s" for column "%s"' % (key, c['name'])) 120 | 121 | if 'title' in c: 122 | title = c['title'] 123 | else: 124 | try: 125 | title = self.model._meta.get_field(c['name']).verbose_name.title() 126 | except: 127 | title = c['name'] 128 | 129 | column['name'] = c['name'] 130 | column['data'] = c['name'] 131 | #column['title'] = c.get('title') if 'title' in c else self.model._meta.get_field(c['name']).verbose_name.title() 132 | column['title'] = title 133 | column['searchable'] = c.get('searchable', column['visible']) 134 | column['orderable'] = c.get('orderable', column['visible']) 135 | 136 | self.column_specs.append(column) 137 | 138 | # # build LUT for column objects 139 | # # 140 | # # self.column_objs_lut = { 141 | # # 'id': , 142 | # # 'code': , 143 | # # ... 144 | # # 145 | # self.column_objs_lut = Column.collect_model_columns( 146 | # self.model, 147 | # self.column_specs 148 | # ) 149 | 150 | # For each table column, we build either a Columns or ForeignColumns as required; 151 | # both "column spec" dictionary and the column object are saved in "column_index" 152 | # to speed up later lookups; 153 | # Finally, we elaborate "choices" list 154 | 155 | self.column_index = {} 156 | for cs in self.column_specs: 157 | 158 | key = cs['name'] 159 | column = Column.column_factory(self.model, cs) 160 | choices = [] 161 | 162 | # 163 | # Adjust choices 164 | # we do this here since the model field itself is finally available 165 | # 166 | 167 | # (1) None (default) or False: no choices (use text input box) 168 | if cs['choices'] == False: 169 | # Do not use choices 170 | cs['choices'] = None 171 | # (2) True: use Model's field choices; 172 | # - failing that, we might use "autofilter"; that is: collect the list of distinct values from db table 173 | # - BooleanFields deserve a special treatement 174 | elif cs['choices'] == True: 175 | 176 | # For boolean fields, provide (None)/Yes/No choice sequence 177 | if isinstance(column.model_field, models.BooleanField): 178 | if column.model_field.null: 179 | # UNTESTED ! 180 | choices = [(None, ''), ] 181 | else: 182 | choices = [] 183 | choices += [(True, _('Yes')), (False, _('No'))] 184 | elif cs['boolean']: 185 | choices += [(True, _('Yes')), (False, _('No'))] 186 | else: 187 | # Otherwise, retrieve field's choices, if any ... 188 | choices = getattr(column.model_field, 'choices', None) 189 | if choices is None: 190 | choices = [] 191 | else: 192 | # 193 | # Here, we could abbreviate select's options as well; 194 | # however, the caller can easily apply a 'width' css attribute to the select tag, instead 195 | # 196 | # max_length = cs['max_length'] 197 | # if max_length <= 0: 198 | # choices = [(c[0], c[1]) for c in choices] 199 | # else: 200 | # choices = [(c[0], self.clip_value(c[1], max_length, False)) for c in choices] 201 | # 202 | choices = choices[:] 203 | 204 | # ... or collect distict values if 'autofilter' has been enabled 205 | if len(choices) <= 0 and cs['autofilter']: 206 | choices = self.list_autofilter_choices(request, cs, column.model_field, cs['initialSearchValue']) 207 | cs['choices'] = choices if len(choices) > 0 else None 208 | # (3) Otherwise, just use the sequence of choices that has been supplied. 209 | 210 | 211 | self.column_index[key] = { 212 | 'spec': cs, 213 | 'column': column, 214 | } 215 | 216 | # Initialize "show_date_filters" 217 | show_date_filters = self.get_show_date_filters(request) 218 | if show_date_filters is None: 219 | show_date_filters = bool(self.latest_by) 220 | self.show_date_filters = show_date_filters 221 | 222 | # If global date filter is visible, 223 | # add class 'get_latest_by' to the column used for global date filtering 224 | if self.show_date_filters and self.latest_by: 225 | column_def = self.column_spec_by_name(self.latest_by) 226 | if column_def: 227 | if column_def['className']: 228 | column_def['className'] += 'latest_by' 229 | else: 230 | column_def['className'] = 'latest_by' 231 | 232 | # Initialize "show_column_filters" 233 | show_column_filters = self.get_show_column_filters(request) 234 | if show_column_filters is None: 235 | # By default we show the column filters if there is at least 236 | # one searchable and visible column 237 | num_searchable_columns = len([c for c in self.column_specs if c.get('searchable') and c.get('visible')]) 238 | show_column_filters = (num_searchable_columns > 0) 239 | self.show_column_filters = show_column_filters 240 | 241 | if ENABLE_QUERYDICT_TRACING: 242 | trace(self.column_specs, prompt='column_specs') 243 | 244 | def get_column_defs(self, request): 245 | """ 246 | Override to customize based of request 247 | """ 248 | return self.column_defs 249 | 250 | def get_initial_order(self, request): 251 | """ 252 | Override to customize based of request 253 | """ 254 | return self.initial_order 255 | 256 | def get_length_menu(self, request): 257 | """ 258 | Override to customize based of request 259 | """ 260 | return self.length_menu 261 | 262 | def get_template_name(self, request): 263 | """ 264 | Override to customize based of request 265 | """ 266 | return self.template_name 267 | 268 | def get_latest_by(self, request): 269 | """ 270 | Override to customize based of request. 271 | 272 | Provides the name of the column to be used for global date range filtering. 273 | Return either '', a fieldname or None. 274 | 275 | When None is returned, in model's Meta 'get_latest_by' attributed will be used. 276 | """ 277 | return self.latest_by 278 | 279 | def get_show_date_filters(self, request): 280 | """ 281 | Override to customize based of request. 282 | 283 | Defines whether to use the global date range filter. 284 | Return either True, False or None. 285 | 286 | When None is returned, will'll check whether 'latest_by' is defined 287 | """ 288 | return self.show_date_filters 289 | 290 | def get_show_column_filters(self, request): 291 | """ 292 | Override to customize based of request. 293 | 294 | Defines whether to use the column filters. 295 | Return either True, False or None. 296 | 297 | When None is returned, check if at least one visible column in searchable. 298 | """ 299 | return self.show_column_filters 300 | 301 | def column_obj(self, name): 302 | """ 303 | Lookup columnObj for the column_spec identified by 'name' 304 | """ 305 | # assert name in self.column_objs_lut 306 | # return self.column_objs_lut[name] 307 | assert name in self.column_index 308 | return self.column_index[name]['column'] 309 | 310 | def column_spec_by_name(self, name): 311 | """ 312 | Lookup the column_spec identified by 'name' 313 | """ 314 | if name in self.column_index: 315 | return self.column_index[name]['spec'] 316 | return None 317 | 318 | def fix_initial_order(self, initial_order): 319 | """ 320 | "initial_order" is a list of (position, direction) tuples; for example: 321 | [[1, 'asc'], [5, 'desc']] 322 | 323 | Here, we also accept positions expressed as column names, 324 | and convert the to the corresponding numeric position. 325 | """ 326 | values = [] 327 | keys = list(self.column_index.keys()) 328 | for position, direction in initial_order: 329 | if type(position) == str: 330 | position = keys.index(position) 331 | values.append([position, direction]) 332 | return values 333 | 334 | #@method_decorator(csrf_exempt) 335 | def dispatch(self, request, *args, **kwargs): 336 | 337 | if not getattr(request, 'REQUEST', None): 338 | request.REQUEST = request.GET if request.method=='GET' else request.POST 339 | 340 | self.initialize(request) 341 | if request.is_ajax(): 342 | action = request.REQUEST.get('action', '') 343 | if action == 'initialize': 344 | 345 | # Sanity check for initial order 346 | initial_order = self.get_initial_order(request) 347 | initial_order = self.fix_initial_order(initial_order) 348 | 349 | initial_order_columns = [row[0] for row in initial_order] 350 | for col in initial_order_columns: 351 | if col >= len(self.column_specs): 352 | raise Exception('Initial order column %d does not exists' % col) 353 | elif not self.column_specs[col]['orderable']: 354 | raise Exception('Column %d is not orderable' % col) 355 | 356 | # Initial values for column filters, when supplied 357 | # See: https://datatables.net/reference/option/searchCols 358 | searchCols = [ 359 | {'search': cs['initialSearchValue'], } 360 | for cs in self.column_specs 361 | ] 362 | 363 | return JsonResponse({ 364 | 'columns': self.column_specs, 365 | 'searchCols': searchCols, 366 | 'order': initial_order, 367 | 'length_menu': self.get_length_menu(request), 368 | 'show_date_filters': self.show_date_filters, 369 | 'show_column_filters': self.show_column_filters, 370 | }) 371 | elif action == 'details': 372 | #row_id = request.REQUEST.get('id') 373 | row_id = request.REQUEST.get(self.table_row_id_fieldname) 374 | return JsonResponse({ 375 | 'html': self.render_row_details(row_id, request), 376 | 'parent-row-id': row_id, 377 | }) 378 | 379 | response = super(DatatablesView, self).dispatch(request, *args, **kwargs) 380 | else: 381 | assert False 382 | #response = HttpResponse(self.render_table(request)) 383 | return response 384 | 385 | def get_model_admin(self): 386 | from django.contrib import admin 387 | if self.model in admin.site._registry: 388 | return admin.site._registry[self.model] 389 | return None 390 | 391 | def render_row_details(self, id, request=None): 392 | 393 | obj = self.model.objects.get(id=id) 394 | 395 | # Search a custom template for rendering, if available 396 | try: 397 | template = loader.select_template([ 398 | 'datatables_view/%s/%s/render_row_details.html' % (self.model._meta.app_label, self.model._meta.model_name), 399 | 'datatables_view/%s/render_row_details.html' % (self.model._meta.app_label, ), 400 | 'datatables_view/render_row_details.html', 401 | ]) 402 | html = template.render({ 403 | 'model': self.model, 404 | 'model_admin': self.get_model_admin(), 405 | 'object': obj, 406 | }, request) 407 | 408 | # Failing that, display a simple table with field values 409 | except TemplateDoesNotExist: 410 | fields = [f.name for f in self.model._meta.get_fields() if f.concrete] 411 | html = '' 412 | for field in fields: 413 | try: 414 | value = getattr(obj, field) 415 | html += '' % (field, value) 416 | except: 417 | pass 418 | html += '
    %s%s
    ' 419 | return html 420 | 421 | @staticmethod 422 | def render_row_tools_column_def(): 423 | column_def = { 424 | 'name': '', 425 | 'visible': True, 426 | # https://datatables.net/blog/2017-03-31 427 | 'defaultContent': render_to_string('datatables_view/row_tools.html', {'foo': 'bar'}), 428 | "className": 'dataTables_row-tools', 429 | 'width': 30, 430 | } 431 | return column_def 432 | 433 | # def render_table(self, request): 434 | 435 | # template_name = self.get_template_name(request) 436 | 437 | # # # When called via Ajax, use the "smaller" template "_inner.html" 438 | # # if request.is_ajax(): 439 | # # template_name = getattr(self, 'ajax_template_name', '') 440 | # # if not template_name: 441 | # # split = self.template_name.split('.html') 442 | # # split[-1] = '_inner' 443 | # # split.append('.html') 444 | # # template_name = ''.join(split) 445 | 446 | # html = render_to_string( 447 | # template_name, { 448 | # 'title': self.title, 449 | # 'columns': self.list_columns(request), 450 | # 'column_details': mark_safe(json.dumps(self.list_columns(request))), 451 | # 'initial_order': mark_safe(json.dumps(self.get_initial_order(request))), 452 | # 'length_menu': mark_safe(json.dumps(self.get_length_menu(request))), 453 | # 'view': self, 454 | # 'show_date_filter': self.model._meta.get_latest_by is not None, 455 | # }, 456 | # request=request 457 | # ) 458 | 459 | # return html 460 | 461 | def post(self, request, *args, **kwargs): 462 | """ 463 | Treat POST and GET the like 464 | """ 465 | return self.get(request, *args, **kwargs) 466 | 467 | def get(self, request, *args, **kwargs): 468 | 469 | # if not getattr(request, 'REQUEST', None): 470 | # request.REQUEST = request.GET if request.method=='GET' else request.POST 471 | 472 | t0 = datetime.datetime.now() 473 | 474 | if not request.is_ajax(): 475 | return HttpResponseBadRequest() 476 | 477 | try: 478 | query_dict = request.REQUEST 479 | params = self.read_parameters(query_dict) 480 | except ValueError: 481 | return HttpResponseBadRequest() 482 | 483 | if ENABLE_QUERYDICT_TRACING: 484 | trace(query_dict, prompt='query_dict') 485 | trace(params, prompt='params') 486 | 487 | # Prepare the queryset and apply the search and order filters 488 | qs = self.get_initial_queryset(request) 489 | if not DISABLE_QUERYSET_OPTIMIZATION and not self.disable_queryset_optimization: 490 | qs = self.optimize_queryset(qs) 491 | qs = self.prepare_queryset(params, qs) 492 | if ENABLE_QUERYSET_TRACING: 493 | prettyprint_queryset(qs) 494 | 495 | # Slice result 496 | paginator = Paginator(qs, params['length'] if params['length'] != -1 else qs.count()) 497 | response_dict = self.get_response_dict(request, paginator, params['draw'], params['start']) 498 | response_dict['footer_message'] = self.footer_message(qs, params) 499 | 500 | # Prepare response 501 | response = HttpResponse( 502 | json.dumps( 503 | response_dict, 504 | cls=DjangoJSONEncoder 505 | ), 506 | content_type="application/json") 507 | 508 | # Trace elapsed time 509 | if ENABLE_QUERYSET_TRACING: 510 | td = datetime.datetime.now() - t0 511 | ms = (td.seconds * 1000) + (td.microseconds / 1000.0) 512 | trace('%d [ms]' % ms, prompt="Table rendering time") 513 | 514 | return response 515 | 516 | def read_parameters(self, query_dict): 517 | """ 518 | Converts and cleans up the GET parameters. 519 | """ 520 | params = {field: int(query_dict[field]) for field in ['draw', 'start', 'length']} 521 | params['date_from'] = query_dict.get('date_from', None) 522 | params['date_to'] = query_dict.get('date_to', None) 523 | 524 | column_index = 0 525 | has_finished = False 526 | column_links = [] 527 | 528 | while column_index < MAX_COLUMNS and not has_finished: 529 | column_base = 'columns[%d]' % column_index 530 | try: 531 | column_name = query_dict[column_base + '[name]'] 532 | if column_name == '': 533 | column_name = query_dict[column_base + '[data]'] 534 | 535 | if column_name != '': 536 | column_links.append( 537 | ColumnLink( 538 | column_name, 539 | #self.model_columns[column_name], 540 | self.column_obj(column_name), 541 | query_dict.get(column_base + '[orderable]'), 542 | query_dict.get(column_base + '[searchable]'), 543 | query_dict.get(column_base + '[search][value]'), 544 | ) 545 | ) 546 | else: 547 | column_links.append(PlaceholderColumnLink()) 548 | except KeyError: 549 | has_finished = True 550 | 551 | column_index += 1 552 | 553 | orders = [] 554 | order_index = 0 555 | has_finished = False 556 | columns = [c['name'] for c in self.column_specs] 557 | while order_index < len(columns) and not has_finished: 558 | try: 559 | order_base = 'order[%d]' % order_index 560 | order_column = query_dict[order_base + '[column]'] 561 | orders.append(Order( 562 | order_column, 563 | query_dict[order_base + '[dir]'], 564 | column_links)) 565 | except ColumnOrderError: 566 | pass 567 | except KeyError: 568 | has_finished = True 569 | 570 | order_index += 1 571 | 572 | search_value = query_dict.get('search[value]') 573 | if search_value: 574 | params['search_value'] = search_value 575 | 576 | params.update({'column_links': column_links, 'orders': orders}) 577 | 578 | return params 579 | 580 | def get_initial_queryset(self, request=None): 581 | return self.model.objects.all() 582 | 583 | def get_foreign_queryset(self, request, field): 584 | queryset = field.model.objects.all() 585 | return queryset 586 | 587 | def render_column(self, row, column): 588 | #return self.model_columns[column].render_column(row) 589 | value = self.column_obj(column).render_column(row) 590 | return value 591 | 592 | def render_clip_value_as_html(self, long_text, short_text, is_clipped): 593 | """ 594 | Given long and shor version of text, the following html representation: 595 | short_text[ellipsis] 596 | 597 | To be overridden for further customisations. 598 | """ 599 | return '{short_text}{ellipsis}'.format( 600 | long_text=long_text, 601 | short_text=short_text, 602 | ellipsis='…' if is_clipped else '' 603 | ) 604 | 605 | def clip_value(self, text, max_length, as_html): 606 | """ 607 | Given `text`, returns: 608 | - original `text` if it's length is less then or equal `max_length` 609 | - the clipped left part + ellipses otherwise 610 | as either plain text or html (depending on `as_html`) 611 | """ 612 | is_clipped = False 613 | long_text = text 614 | if len(text) <= max_length: 615 | short_text = text 616 | else: 617 | short_text = text[:max_length] 618 | is_clipped = True 619 | if as_html: 620 | result = self.render_clip_value_as_html(long_text, short_text, is_clipped) 621 | else: 622 | result = short_text 623 | if is_clipped: 624 | result += '…' 625 | return result 626 | 627 | def clip_results(self, retdict): 628 | rows = [(cs['name'], cs['max_length']) for cs in self.column_specs if cs['max_length'] > 0] 629 | for name, max_length in rows: 630 | retdict[name] = self.clip_value(str(retdict[name]), max_length, True) 631 | 632 | def prepare_results(self, request, qs): 633 | json_data = [] 634 | columns = [c['name'] for c in self.column_specs] 635 | for cur_object in qs: 636 | retdict = { 637 | #fieldname: '
    %s
    ' % (fieldname, self.render_column(cur_object, fieldname)) 638 | fieldname: self.render_column(cur_object, fieldname) 639 | for fieldname in columns 640 | if fieldname 641 | } 642 | 643 | self.customize_row(retdict, cur_object) 644 | self.clip_results(retdict) 645 | 646 | row_id = self.get_table_row_id(request, cur_object) 647 | if row_id: 648 | # "Automatic addition of row ID attributes" 649 | # https://datatables.net/examples/server_side/ids.html 650 | retdict['DT_RowId'] = row_id 651 | 652 | json_data.append(retdict) 653 | return json_data 654 | 655 | def get_response_dict(self, request, paginator, draw_idx, start_pos): 656 | page_id = (start_pos // paginator.per_page) + 1 657 | if page_id > paginator.num_pages: 658 | page_id = paginator.num_pages 659 | elif page_id < 1: 660 | page_id = 1 661 | 662 | objects = self.prepare_results(request, paginator.page(page_id)) 663 | 664 | return {"draw": draw_idx, 665 | "recordsTotal": paginator.count, 666 | "recordsFiltered": paginator.count, 667 | "data": objects, 668 | } 669 | 670 | def get_table_row_id(self, request, obj): 671 | """ 672 | Provides a specific ID for the table row; default: "row-ID" 673 | Override to customize as required. 674 | 675 | Do to a limitation of datatables.net, we can only supply to table rows 676 | a id="row-ID" attribute, and not a data-row-id="ID" attribute 677 | """ 678 | result = '' 679 | if self.table_row_id_fieldname: 680 | try: 681 | result = self.table_row_id_prefix + str(getattr(obj, self.table_row_id_fieldname)) 682 | except: 683 | result = '' 684 | return result 685 | 686 | def customize_row(self, row, obj): 687 | # 'row' is a dictionnary representing the current row, and 'obj' is the current object. 688 | #row['age_is_even'] = obj.age%2==0 689 | pass 690 | 691 | def optimize_queryset(self, qs): 692 | 693 | # use sets to remove duplicates 694 | only = set() 695 | select_related = set() 696 | 697 | # collect values for qs optimizations 698 | fields = [f.name for f in self.model._meta.get_fields()] 699 | for column in self.column_specs: 700 | foreign_field = column.get('foreign_field') 701 | if foreign_field: 702 | 703 | # Examples: 704 | # 705 | # +-----------------------------+-------------------------------+-----------------------------------+ 706 | # | if foreign_key is: | add this to only[]: | and add this to select_related[]: | 707 | # +-----------------------------+-------------------------------+-----------------------------------+ 708 | # | 'lotto__codice' | 'lotto__codice' | 'lotto' | 709 | # | 'lotto__articolo__codice' | 'lotto__articolo__codice' | 'lotto__articolo' | 710 | # +-----------------------------+-------------------------------+-----------------------------------+ 711 | # 712 | 713 | only.add(foreign_field) 714 | #select_related.add(column.get('name')) 715 | #select_related.add(foreign_field.split('__')[0]) 716 | select_related.add('__'.join(foreign_field.split('__')[0:-1])) 717 | else: 718 | [f.name for f in self.model._meta.get_fields()] 719 | field = column.get('name') 720 | if field in fields: 721 | only.add(field) 722 | 723 | # convert to lists 724 | only = [item for item in list(only) if item] 725 | select_related = list(select_related) 726 | 727 | # apply optimizations: 728 | 729 | # (1) use select_related() to reduce the number of queries 730 | if select_related: 731 | qs = qs.select_related(*select_related) 732 | 733 | # (2) use only() to reduce the number of columns in the resultset 734 | if only: 735 | qs = qs.only(*only) 736 | 737 | return qs 738 | 739 | def prepare_queryset(self, params, qs): 740 | qs = self.filter_queryset(params, qs) 741 | qs = self.sort_queryset(params, qs) 742 | return qs 743 | 744 | def filter_queryset(self, params, qs): 745 | 746 | qs = self.filter_queryset_by_date_range(params.get('date_from', None), params.get('date_to', None), qs) 747 | 748 | if 'search_value' in params: 749 | qs = self.filter_queryset_all_columns(params['search_value'], qs) 750 | 751 | for column_link in params['column_links']: 752 | if column_link.searchable and column_link.search_value: 753 | qs = self.filter_queryset_by_column(column_link.name, column_link.search_value, qs) 754 | 755 | return qs 756 | 757 | def sort_queryset(self, params, qs): 758 | if len(params['orders']): 759 | qs = qs.order_by( 760 | *[order.get_order_mode() for order in params['orders']]) 761 | return qs 762 | 763 | # TODO: currently unused; in the orginal project was probably related to the 764 | # management of fields with choices; 765 | # check and refactor this 766 | def choice_field_search(self, column, search_value): 767 | values_dict = self.choice_fields_completion[column] 768 | # matching_choices = [ 769 | # val for key, val in six.iteritems(values_dict) 770 | # if key.startswith(search_value) 771 | # ] 772 | matching_choices = [ 773 | val for key, val in values_dict.items() 774 | if key.startswith(search_value) 775 | ] 776 | return Q(**{column + '__in': matching_choices}) 777 | 778 | def _filter_queryset(self, column_names, search_value, qs): 779 | 780 | if TEST_FILTERS: 781 | trace(', '.join(column_names), 'Filtering "%s" over fields' % search_value) 782 | 783 | search_filters = Q() 784 | for column_name in column_names: 785 | 786 | column_obj = self.column_obj(column_name) 787 | column_spec = self.column_spec_by_name(column_name) 788 | 789 | column_filter = build_column_filter(column_name, column_obj, column_spec, search_value) 790 | if column_filter: 791 | search_filters |= column_filter 792 | if TEST_FILTERS: 793 | trace(column_name, "Test filter") 794 | qstest = qs.filter(column_filter) 795 | trace('%d/%d records filtered' % (qstest.count(), qs.count())) 796 | 797 | if TEST_FILTERS: 798 | trace(search_filters, prompt='Search filters') 799 | 800 | return qs.filter(search_filters) 801 | 802 | def filter_queryset_all_columns(self, search_value, qs): 803 | searchable_columns = [c['name'] for c in self.column_specs if c['searchable']] 804 | return self._filter_queryset(searchable_columns, search_value, qs) 805 | 806 | def filter_queryset_by_column(self, column_name, search_value, qs): 807 | return self._filter_queryset([column_name, ], search_value, qs) 808 | 809 | def filter_queryset_by_date_range(self, date_from, date_to, qs): 810 | 811 | if self.latest_by and (date_from or date_to): 812 | 813 | daterange_filter = Q() 814 | is_datetime = isinstance(self.latest_by, models.DateTimeField) 815 | 816 | if date_from: 817 | dt = datetime.datetime.strptime(date_from, '%Y-%m-%d').date() 818 | if is_datetime: 819 | daterange_filter &= Q(**{self.latest_by+'__date__gte': dt}) 820 | else: 821 | daterange_filter &= Q(**{self.latest_by+'__gte': dt}) 822 | 823 | if date_to: 824 | dt = datetime.datetime.strptime(date_to, '%Y-%m-%d').date() 825 | if is_datetime: 826 | daterange_filter &= Q(**{self.latest_by+'__date__lte': dt}) 827 | else: 828 | daterange_filter &= Q(**{self.latest_by+'__lte': dt}) 829 | 830 | if TEST_FILTERS: 831 | n0 = qs.count() 832 | 833 | qs = qs.filter(daterange_filter) 834 | 835 | if TEST_FILTERS: 836 | n1 = qs.count() 837 | trace(daterange_filter, prompt='Daterange filter') 838 | trace('%d/%d records filtered' % (n1, n0)) 839 | 840 | return qs 841 | 842 | def footer_message(self, qs, params): 843 | """ 844 | Overriden to append a message to the bottom of the table 845 | """ 846 | #return 'Selected rows: %d' % qs.count() 847 | return None 848 | 849 | 850 | def list_autofilter_choices(self, request, column_spec, field, initial_search_value): 851 | """ 852 | Collects distinct values from specified field, 853 | and prepares a list of choices for "autofilter" selection. 854 | Sample result: 855 | [ 856 | ('Alicia', 'Alicia'), ('Amanda', 'Amanda'), ('Amy', 'Amy'), 857 | ... 858 | ('William', 'William'), ('Yolanda', 'Yolanda'), ('Yvette', 'Yvette'), 859 | ] 860 | """ 861 | try: 862 | if field.model == self.model: 863 | queryset = self.get_initial_queryset(request) 864 | else: 865 | queryset = self.get_foreign_queryset(request, field) 866 | values = list(queryset 867 | .values_list(field.name, flat=True) 868 | .distinct() 869 | .order_by(field.name) 870 | ) 871 | 872 | # Make sure initial_search_value is available 873 | if initial_search_value is not None: 874 | if initial_search_value not in values: 875 | values.append(initial_search_value) 876 | 877 | if isinstance(field, models.DateField): 878 | choices = [(item, format_datetime(item)) for item in values] 879 | else: 880 | max_length = column_spec['max_length'] 881 | if max_length <= 0: 882 | choices = [(item, item) for item in values] 883 | else: 884 | choices = [ 885 | (item, self.clip_value(str(item), max_length, False)) 886 | for item in values 887 | ] 888 | 889 | except Exception as e: 890 | # TODO: investigate what happens here with FKs 891 | print('ERROR: ' + str(e)) 892 | choices = [] 893 | return choices 894 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | django 2 | factory-boy==2.12.0 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | 11 | def run_tests(*test_args): 12 | 13 | # Since out app has no Models, we need to involve another 'tests' app 14 | # with at least a Model to make sure that migrations are run for test sqlite database 15 | if not test_args: 16 | test_args = ['tests', 'datatables_view', ] 17 | 18 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 19 | django.setup() 20 | TestRunner = get_runner(settings) 21 | test_runner = TestRunner() 22 | failures = test_runner.run_tests(test_args) 23 | sys.exit(bool(failures)) 24 | 25 | 26 | if __name__ == '__main__': 27 | run_tests(*sys.argv[1:]) 28 | -------------------------------------------------------------------------------- /screenshots/001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/001.png -------------------------------------------------------------------------------- /screenshots/002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/002.png -------------------------------------------------------------------------------- /screenshots/003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/003.png -------------------------------------------------------------------------------- /screenshots/004a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/004a.png -------------------------------------------------------------------------------- /screenshots/004b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/004b.png -------------------------------------------------------------------------------- /screenshots/005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/005.png -------------------------------------------------------------------------------- /screenshots/006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/006.png -------------------------------------------------------------------------------- /screenshots/007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/007.png -------------------------------------------------------------------------------- /screenshots/008.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/008.png -------------------------------------------------------------------------------- /screenshots/009.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/009.png -------------------------------------------------------------------------------- /screenshots/010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/010.png -------------------------------------------------------------------------------- /screenshots/clipping_results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/clipping_results.png -------------------------------------------------------------------------------- /screenshots/column_filtering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/column_filtering.png -------------------------------------------------------------------------------- /screenshots/table_row_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/screenshots/table_row_id.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.2.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:datatables_view/__init__.py] 7 | 8 | [wheel] 9 | universal = 1 10 | 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def get_version(*file_paths): 7 | """Retrieves the version from specific file""" 8 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 9 | version_file = open(filename).read() 10 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 11 | if version_match: 12 | return version_match.group(1) 13 | raise RuntimeError('Unable to find version string.') 14 | 15 | 16 | version = get_version("datatables_view", "__init__.py") 17 | readme = open('README.rst').read() 18 | history = open('CHANGELOG.rst').read().replace('.. :changelog:', '') 19 | 20 | 21 | setup(name='django-datatables-view', 22 | version=version, 23 | description='Helper class to integrate Django with datatables', 24 | long_description=readme + '\n\n' + history, 25 | url='http://github.com/morlandi/django-datatables-view', 26 | author='Mario Orlandi', 27 | author_email='morlandi@brainstorm.it', 28 | license='MIT', 29 | include_package_data=True, 30 | packages=find_packages(), 31 | zip_safe=False) 32 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morlandi/django-datatables-view/4a61e0fd53ca679c1c6b1fe59105985f78917188/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | import django 5 | 6 | DEBUG = True 7 | USE_TZ = True 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = "77777777777777777777777777777777777777777777777777" 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.sqlite3", 15 | "NAME": ":memory:", 16 | } 17 | } 18 | 19 | ROOT_URLCONF = "tests.urls" 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.auth", 23 | "django.contrib.contenttypes", 24 | "django.contrib.sites", 25 | "datatables_view", 26 | ] 27 | 28 | SITE_ID = 1 29 | 30 | MIDDLEWARE = () 31 | 32 | 33 | TEMPLATES = [ 34 | { 35 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 36 | 'DIRS': [], 37 | 'APP_DIRS': True, 38 | }, 39 | ] 40 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-task 6 | ------------ 7 | 8 | Tests for `django-task` models module. 9 | """ 10 | 11 | from django.test import TestCase 12 | 13 | 14 | class TestDjango_task(TestCase): 15 | 16 | def setUp(self): 17 | pass 18 | 19 | def test_something(self): 20 | pass 21 | 22 | def tearDown(self): 23 | pass 24 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import url, include 3 | except: 4 | from django.conf.urls import url, include 5 | 6 | 7 | urlpatterns = [ 8 | ] 9 | --------------------------------------------------------------------------------