├── .coveragerc ├── .github └── workflows │ ├── django3.yml │ ├── django4.yml │ ├── django5.yml │ └── pep257.yml ├── .gitignore ├── .pylintrc ├── .pypirc ├── LICENSE ├── MANIFEST.in ├── README.md ├── READMEru.md ├── deploy.txt ├── django_admin_filters ├── __init__.py ├── base.py ├── daterange.py ├── multi_choice.py ├── static │ ├── css │ │ └── datetimepicker.css │ └── js │ │ └── datetimepicker.js └── templates │ ├── base_admin_list_filter.html │ ├── daterange.html │ ├── daterange_picker.html │ └── multi_choice.html ├── example ├── __init__.py ├── admin.py ├── models.py ├── settings.py ├── urls.py ├── views.py └── wsgi.py ├── history.txt ├── img ├── color_filter.png ├── daterange_en.png ├── daterange_ru.png ├── multi_choice_en.png ├── picker_en.png └── picker_ru.png ├── makefile ├── manage.py ├── pyproject.toml ├── pytest3.ini ├── pytest4.ini ├── pytest5.ini ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── django3.txt ├── django4.txt ├── django5.txt ├── requirements.txt └── test ├── __init__.py ├── test_base.py ├── test_daterange.py ├── test_multi_choice.py └── test_urls.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | 4 | [html] 5 | directory = htmlcov 6 | -------------------------------------------------------------------------------- /.github/workflows/django3.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python 2 | name: django3 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | 14 | django3: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r tests/requirements.txt 32 | pip install -r tests/django3.txt 33 | 34 | - name: flake8 35 | run: | 36 | flake8 --count --show-source --statistics --max-line-length=120 django_admin_filters 37 | flake8 --count --show-source --statistics --max-line-length=120 tests/test 38 | 39 | - name: pylint 40 | run: | 41 | python -m pylint django_admin_filters 42 | python -m pylint tests/test 43 | 44 | - name: pytest 45 | run: | 46 | python manage.py collectstatic --noinput --settings example.settings 47 | python manage.py makemigrations --settings example.settings example 48 | python manage.py migrate --settings example.settings 49 | pytest -c pytest3.ini --cov=django_admin_filters --cov-report xml --cov-report term:skip-covered --durations=5 tests 50 | -------------------------------------------------------------------------------- /.github/workflows/django4.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python 2 | name: django4 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | 14 | django4: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r tests/requirements.txt 32 | pip install -r tests/django4.txt 33 | 34 | - name: flake8 35 | run: | 36 | flake8 --count --show-source --statistics --max-line-length=120 django_admin_filters 37 | flake8 --count --show-source --statistics --max-line-length=120 tests/test 38 | 39 | - name: pylint 40 | run: | 41 | python -m pylint django_admin_filters 42 | python -m pylint tests/test 43 | 44 | - name: pytest 45 | run: | 46 | python manage.py collectstatic --noinput --settings example.settings 47 | python manage.py makemigrations --settings example.settings example 48 | python manage.py migrate --settings example.settings 49 | pytest -c pytest4.ini --cov=django_admin_filters --cov-report xml --cov-report term:skip-covered --durations=5 tests 50 | -------------------------------------------------------------------------------- /.github/workflows/django5.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python 2 | name: django5 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | 14 | django5: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.10', '3.11', '3.12', '3.13'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r tests/requirements.txt 32 | pip install -r tests/django5.txt 33 | 34 | - name: flake8 35 | run: | 36 | flake8 --count --show-source --statistics --max-line-length=120 django_admin_filters 37 | flake8 --count --show-source --statistics --max-line-length=120 tests/test 38 | 39 | - name: pylint 40 | run: | 41 | python -m pylint django_admin_filters 42 | python -m pylint tests/test 43 | 44 | - name: pytest 45 | env: 46 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} 47 | run: | 48 | python manage.py collectstatic --noinput --settings example.settings 49 | python manage.py makemigrations --settings example.settings example 50 | python manage.py migrate --settings example.settings 51 | pytest -c pytest5.ini --cov=django_admin_filters --cov-report xml --cov-report term:skip-covered --durations=5 tests 52 | if [ "$CODACY_PROJECT_TOKEN" != "" ]; then 53 | python-codacy-coverage -r coverage.xml 54 | fi 55 | -------------------------------------------------------------------------------- /.github/workflows/pep257.yml: -------------------------------------------------------------------------------- 1 | name: pep257 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | 13 | pep257: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ['3.11'] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | pip install pydocstyle 30 | 31 | - name: source 32 | run: | 33 | python -m pydocstyle django_admin_filters 34 | 35 | - name: tests 36 | run: | 37 | python -m pydocstyle --match='.*\.py' tests/test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | migrations 3 | static/* 4 | *.pyc 5 | *.mo 6 | *.log 7 | *.sqlite3 8 | .coverage 9 | htmlcov/* 10 | venv* 11 | build/ 12 | dist/ 13 | *.egg-info/ 14 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | # ignore-patterns=settings_* 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | init-hook=import sys;sys.path.insert(0, './') 19 | 20 | # Use multiple processes to speed up Pylint. 21 | jobs=1 22 | 23 | # List of plugins (as comma separated values of python modules names) to load, 24 | # usually to register additional checkers. 25 | load-plugins=pylint_django,pylint.extensions.mccabe 26 | django-settings-module=example.settings 27 | max-complexity=10 28 | 29 | # Pickle collected data for later comparisons. 30 | persistent=yes 31 | 32 | # Specify a configuration file. 33 | #rcfile= 34 | 35 | # When enabled, pylint would attempt to guess common misconfiguration and emit 36 | # user-friendly hints instead of false-positive error messages 37 | suggestion-mode=yes 38 | 39 | # Allow loading of arbitrary C extensions. Extensions are imported into the 40 | # active Python interpreter and may run arbitrary code. 41 | unsafe-load-any-extension=no 42 | 43 | 44 | [MESSAGES CONTROL] 45 | 46 | # Only show warnings with the listed confidence levels. Leave empty to show 47 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 48 | confidence= 49 | 50 | # Disable the message, report, category or checker with the given id(s). You 51 | # can either give multiple identifiers separated by comma (,) or put this 52 | # option multiple times (only on the command line, not in the configuration 53 | # file where it should appear only once).You can also use "--disable=all" to 54 | # disable everything first and then reenable specific checks. For example, if 55 | # you want to run only the similarities checker, you can use "--disable=all 56 | # --enable=similarities". If you want to run only the classes checker, but have 57 | # no Warning level messages displayed, use"--disable=all --enable=classes 58 | # --disable=W" 59 | disable=raw-checker-failed, 60 | bad-inline-option, 61 | locally-disabled, 62 | file-ignored, 63 | suppressed-message, 64 | useless-suppression, 65 | deprecated-pragma, 66 | superfluous-parens, 67 | import-outside-toplevel, 68 | consider-using-with, 69 | deprecated-module, 70 | consider-using-f-string, 71 | too-few-public-methods 72 | 73 | # Enable the message, report, category or checker with the given id(s). You can 74 | # either give multiple identifier separated by comma (,) or put this option 75 | # multiple time (only on the command line, not in the configuration file where 76 | # it should appear only once). See also the "--disable" option for examples. 77 | enable=c-extension-no-member 78 | 79 | 80 | [REPORTS] 81 | 82 | # Python expression which should return a note less than 10 (10 is the highest 83 | # note). You have access to the variables errors warning, statement which 84 | # respectively contain the number of errors / warnings messages and the total 85 | # number of statements analyzed. This is used by the global evaluation report 86 | # (RP0004). 87 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 88 | 89 | # Template used to display messages. This is a python new-style format string 90 | # used to format the message information. See doc for all details 91 | #msg-template= 92 | 93 | # Set the output format. Available formats are text, parseable, colorized, json 94 | # and msvs (visual studio).You can also give a reporter class, eg 95 | # mypackage.mymodule.MyReporterClass. 96 | output-format=text 97 | 98 | # Tells whether to display a full report or only the messages 99 | reports=no 100 | 101 | # Activate the evaluation score. 102 | score=yes 103 | 104 | 105 | [REFACTORING] 106 | 107 | # Maximum number of nested blocks for function / method body 108 | max-nested-blocks=5 109 | 110 | # Complete name of functions that never returns. When checking for 111 | # inconsistent-return-statements if a never returning function is called then 112 | # it will be considered as an explicit return statement and no message will be 113 | # printed. 114 | never-returning-functions=optparse.Values,sys.exit 115 | 116 | 117 | [BASIC] 118 | 119 | # Naming style matching correct argument names 120 | argument-naming-style=snake_case 121 | 122 | # Regular expression matching correct argument names. Overrides argument- 123 | # naming-style 124 | #argument-rgx= 125 | 126 | # Naming style matching correct attribute names 127 | attr-naming-style=snake_case 128 | 129 | # Regular expression matching correct attribute names. Overrides attr-naming- 130 | # style 131 | #attr-rgx= 132 | 133 | # Bad variable names which should always be refused, separated by a comma 134 | bad-names=foo, 135 | bar, 136 | baz, 137 | toto, 138 | tutu, 139 | tata 140 | 141 | # Naming style matching correct class attribute names 142 | class-attribute-naming-style=any 143 | 144 | # Regular expression matching correct class attribute names. Overrides class- 145 | # attribute-naming-style 146 | #class-attribute-rgx= 147 | 148 | # Naming style matching correct class names 149 | class-naming-style=PascalCase 150 | 151 | # Regular expression matching correct class names. Overrides class-naming-style 152 | #class-rgx= 153 | 154 | # Naming style matching correct constant names 155 | const-naming-style=UPPER_CASE 156 | 157 | # Regular expression matching correct constant names. Overrides const-naming- 158 | # style 159 | #const-rgx= 160 | 161 | # Minimum line length for functions/classes that require docstrings, shorter 162 | # ones are exempt. 163 | docstring-min-length=-1 164 | 165 | # Naming style matching correct function names 166 | function-naming-style=snake_case 167 | 168 | # Regular expression matching correct function names. Overrides function- 169 | # naming-style 170 | #function-rgx= 171 | 172 | # Good variable names which should always be accepted, separated by a comma 173 | good-names=i, 174 | j, 175 | k, 176 | ex, 177 | _ 178 | 179 | # Include a hint for the correct naming format with invalid-name 180 | include-naming-hint=no 181 | 182 | # Naming style matching correct inline iteration names 183 | inlinevar-naming-style=any 184 | 185 | # Regular expression matching correct inline iteration names. Overrides 186 | # inlinevar-naming-style 187 | #inlinevar-rgx= 188 | 189 | # Naming style matching correct method names 190 | method-naming-style=snake_case 191 | 192 | # Regular expression matching correct method names. Overrides method-naming- 193 | # style 194 | #method-rgx= 195 | 196 | # Naming style matching correct module names 197 | module-naming-style=snake_case 198 | 199 | # Regular expression matching correct module names. Overrides module-naming- 200 | # style 201 | #module-rgx= 202 | 203 | # Colon-delimited sets of names that determine each other's naming style when 204 | # the name regexes allow several styles. 205 | name-group= 206 | 207 | # Regular expression which should only match function or class names that do 208 | # not require a docstring. 209 | no-docstring-rgx=^_ 210 | 211 | # List of decorators that produce properties, such as abc.abstractproperty. Add 212 | # to this list to register other decorators that produce valid properties. 213 | property-classes=abc.abstractproperty 214 | 215 | # Naming style matching correct variable names 216 | variable-naming-style=snake_case 217 | 218 | # Regular expression matching correct variable names. Overrides variable- 219 | # naming-style 220 | #variable-rgx= 221 | 222 | 223 | [FORMAT] 224 | 225 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 226 | expected-line-ending-format= 227 | 228 | # Regexp for a line that is allowed to be longer than the limit. 229 | ignore-long-lines=^\s*(# )??$ 230 | 231 | # Number of spaces of indent required inside a hanging or continued line. 232 | indent-after-paren=2 233 | 234 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 235 | # tab). 236 | indent-string=' ' 237 | 238 | # Maximum number of characters on a single line. 239 | max-line-length=120 240 | 241 | # Maximum number of lines in a module 242 | max-module-lines=1000 243 | 244 | # List of optional constructs for which whitespace checking is disabled. `dict- 245 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 246 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 247 | # `empty-line` allows space-only lines. 248 | #no-space-check=trailing-comma 249 | 250 | # Allow the body of a class to be on the same line as the declaration if body 251 | # contains single statement. 252 | single-line-class-stmt=no 253 | 254 | # Allow the body of an if to be on the same line as the test if there is no 255 | # else. 256 | single-line-if-stmt=no 257 | 258 | 259 | [LOGGING] 260 | 261 | # Logging modules to check that the string format arguments are in logging 262 | # function parameter format 263 | logging-modules=logging 264 | 265 | 266 | [MISCELLANEOUS] 267 | 268 | # List of note tags to take in consideration, separated by a comma. 269 | notes=FIXME, 270 | XXX, 271 | TODO 272 | 273 | 274 | [SIMILARITIES] 275 | 276 | # Ignore comments when computing similarities. 277 | ignore-comments=yes 278 | 279 | # Ignore docstrings when computing similarities. 280 | ignore-docstrings=no 281 | 282 | # Ignore imports when computing similarities. 283 | ignore-imports=no 284 | 285 | # Minimum lines number of a similarity. 286 | min-similarity-lines=4 287 | 288 | 289 | [SPELLING] 290 | 291 | # Limits count of emitted suggestions for spelling mistakes 292 | max-spelling-suggestions=4 293 | 294 | # Spelling dictionary name. Available dictionaries: none. To make it working 295 | # install python-enchant package. 296 | spelling-dict= 297 | 298 | # List of comma separated words that should not be checked. 299 | spelling-ignore-words= 300 | 301 | # A path to a file that contains private dictionary; one word per line. 302 | spelling-private-dict-file= 303 | 304 | # Tells whether to store unknown words to indicated private dictionary in 305 | # --spelling-private-dict-file option instead of raising a message. 306 | spelling-store-unknown-words=no 307 | 308 | 309 | [TYPECHECK] 310 | 311 | # List of decorators that produce context managers, such as 312 | # contextlib.contextmanager. Add to this list to register other decorators that 313 | # produce valid context managers. 314 | contextmanager-decorators=contextlib.contextmanager 315 | 316 | # List of members which are set dynamically and missed by pylint inference 317 | # system, and so shouldn't trigger E1101 when accessed. Python regular 318 | # expressions are accepted. 319 | generated-members=objects 320 | 321 | # Tells whether missing members accessed in mixin class should be ignored. A 322 | # mixin class is detected if its name ends with "mixin" (case insensitive). 323 | ignore-mixin-members=yes 324 | 325 | # This flag controls whether pylint should warn about no-member and similar 326 | # checks whenever an opaque object is returned when inferring. The inference 327 | # can return multiple potential results while evaluating a Python object, but 328 | # some branches might not be evaluated, which results in partial inference. In 329 | # that case, it might be useful to still emit no-member and other checks for 330 | # the rest of the inferred objects. 331 | ignore-on-opaque-inference=yes 332 | 333 | # List of class names for which member attributes should not be checked (useful 334 | # for classes with dynamically set attributes). This supports the use of 335 | # qualified names. 336 | ignored-classes=optparse.Values,thread._local,_thread._local 337 | 338 | # List of module names for which member attributes should not be checked 339 | # (useful for modules/projects where namespaces are manipulated during runtime 340 | # and thus existing member attributes cannot be deduced by static analysis. It 341 | # supports qualified module names, as well as Unix pattern matching. 342 | ignored-modules=pyodbc 343 | 344 | # Show a hint with possible names when a member name was not found. The aspect 345 | # of finding the hint is based on edit distance. 346 | missing-member-hint=yes 347 | 348 | # The minimum edit distance a name should have in order to be considered a 349 | # similar match for a missing member name. 350 | missing-member-hint-distance=1 351 | 352 | # The total number of similar names that should be taken in consideration when 353 | # showing a hint for a missing member. 354 | missing-member-max-choices=1 355 | 356 | 357 | [VARIABLES] 358 | 359 | # List of additional names supposed to be defined in builtins. Remember that 360 | # you should avoid to define new builtins when possible. 361 | additional-builtins=_ 362 | 363 | # Tells whether unused global variables should be treated as a violation. 364 | allow-global-unused-variables=yes 365 | 366 | # List of strings which can identify a callback function by name. A callback 367 | # name must start or end with one of those strings. 368 | callbacks=cb_, 369 | _cb 370 | 371 | # A regular expression matching the name of dummy variables (i.e. expectedly 372 | # not used). 373 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 374 | 375 | # Argument names that match this expression will be ignored. Default to name 376 | # with leading underscore 377 | ignored-argument-names=_.*|^ignored_|^unused_ 378 | 379 | # Tells whether we should check for unused import in __init__ files. 380 | init-import=no 381 | 382 | # List of qualified module names which can have objects that can redefine 383 | # builtins. 384 | redefining-builtins-modules=six.moves,past.builtins,future.builtins 385 | 386 | 387 | [CLASSES] 388 | 389 | # List of method names used to declare (i.e. assign) instance attributes. 390 | defining-attr-methods=__init__, 391 | __new__, 392 | setUp 393 | 394 | # List of member names, which should be excluded from the protected access 395 | # warning. 396 | exclude-protected=_asdict, 397 | _fields, 398 | _replace, 399 | _source, 400 | _make 401 | 402 | # List of valid names for the first argument in a class method. 403 | valid-classmethod-first-arg=cls 404 | 405 | # List of valid names for the first argument in a metaclass class method. 406 | valid-metaclass-classmethod-first-arg=mcs 407 | 408 | 409 | [DESIGN] 410 | 411 | # Maximum number of arguments for function / method 412 | max-args=15 413 | max-positional-arguments=10 414 | 415 | # Maximum number of attributes for a class (see R0902). 416 | max-attributes=15 417 | 418 | # Maximum number of boolean expressions in a if statement 419 | max-bool-expr=5 420 | 421 | # Maximum number of branch for function / method body 422 | max-branches=12 423 | 424 | # Maximum number of locals for function / method body 425 | max-locals=15 426 | 427 | # Maximum number of parents for a class (see R0901). 428 | max-parents=15 429 | 430 | # Maximum number of public methods for a class (see R0904). 431 | max-public-methods=20 432 | 433 | # Maximum number of return / yield for function / method body 434 | max-returns=6 435 | 436 | # Maximum number of statements in function / method body 437 | max-statements=50 438 | 439 | # Minimum number of public methods for a class (see R0903). 440 | min-public-methods=2 441 | 442 | 443 | [IMPORTS] 444 | 445 | # Allow wildcard imports from modules that define __all__. 446 | allow-wildcard-with-all=no 447 | 448 | # Analyse import fallback blocks. This can be used to support both Python 2 and 449 | # 3 compatible code, which means that the block might have code that exists 450 | # only in one or another interpreter, leading to false positives when analysed. 451 | analyse-fallback-blocks=no 452 | 453 | # Deprecated modules which should not be used, separated by a comma 454 | deprecated-modules=regsub, 455 | TERMIOS, 456 | Bastion, 457 | rexec 458 | 459 | # Create a graph of external dependencies in the given file (report RP0402 must 460 | # not be disabled) 461 | ext-import-graph= 462 | 463 | # Create a graph of every (i.e. internal and external) dependencies in the 464 | # given file (report RP0402 must not be disabled) 465 | import-graph= 466 | 467 | # Create a graph of internal dependencies in the given file (report RP0402 must 468 | # not be disabled) 469 | int-import-graph= 470 | 471 | # Force import order to recognize a module as part of the standard 472 | # compatibility libraries. 473 | known-standard-library= 474 | 475 | # Force import order to recognize a module as part of a third party library. 476 | known-third-party=enchant 477 | 478 | 479 | [EXCEPTIONS] 480 | 481 | # Exceptions that will emit a warning when being caught. Defaults to 482 | # "Exception" 483 | overgeneral-exceptions=builtins.Exception 484 | -------------------------------------------------------------------------------- /.pypirc: -------------------------------------------------------------------------------- 1 | [distutils] 2 | index-servers = 3 | pypi 4 | testpypi 5 | 6 | [pypi] 7 | repository = https://upload.pypi.org/legacy/ 8 | username = __token__ 9 | password = pypi- 10 | 11 | [testpypi] 12 | repository = https://test.pypi.org/legacy/ 13 | username = __token__ 14 | password = pypi- 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vitaly Bogomolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include django_admin_filters/templates * 2 | recursive-include django_admin_filters/static * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DjangoAdminFilters library 2 | 3 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vb64/django.admin.filters/pep257.yml?label=Pep257&style=plastic&branch=main)](https://github.com/vb64/django.admin.filters/actions?query=workflow%3Apep257) 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vb64/django.admin.filters/django3.yml?label=Django%203.2.25&style=plastic&branch=main)](https://github.com/vb64/django.admin.filters/actions?query=workflow%3Adjango3) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vb64/django.admin.filters/django4.yml?label=Django%204.2.19&style=plastic&branch=main)](https://github.com/vb64/django.admin.filters/actions?query=workflow%3Adjango4) 6 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/vb64/django.admin.filters/django5.yml?label=Django%205.1.6&style=plastic&branch=main)](https://github.com/vb64/django.admin.filters/actions?query=workflow%3Adjango5) 7 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/926ec3c1141f4230b4d0508497e5561f)](https://app.codacy.com/gh/vb64/django.admin.filters/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 8 | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/926ec3c1141f4230b4d0508497e5561f)](https://app.codacy.com/gh/vb64/django.admin.filters/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) 9 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/django-admin-list-filters?label=pypi%20installs)](https://pypistats.org/packages/django-admin-list-filters) 10 | 11 | [In Russian](READMEru.md) 12 | 13 | The free, open-source DjangoAdminFilters library is designed to filter objects in the Django admin site. 14 | The library provide few filters for this purpose. 15 | 16 | - `MultiChoice`: multi choice selection with checkboxes for CharField and IntegerField fields with 'choices' option 17 | - `MultiChoiceExt`: another version of previous filter, that allows filtering by custom defined properties 18 | - `DateRange`: set a custom date range using `input` fields 19 | - `DateRangePicker`: set a custom date range using javascript widget for select datetime from calendar 20 | 21 | MultiChoice and MultiChoiceExt | DateRange | DateRangePicker 22 | :------:|:-----:|:----: 23 | ![MultiChoice filter](img/multi_choice_en.png) | ![DateRange with input field](img/daterange_en.png) | ![DateRange with js widget](img/picker_en.png) 24 | 25 | For javascript widget for DateRangePicker was used code from [date-and-time-picker project](https://github.com/polozin/date-and-time-picker) with merged [pull request](https://github.com/polozin/date-and-time-picker/pull/4/files), that allow to select dates before current. 26 | 27 | ## Installation 28 | 29 | ```bash 30 | pip install django-admin-list-filters 31 | ``` 32 | 33 | To connect library to your project, add `django_admin_filters` to the `INSTALLED_APPS` list in your `settings.py` file. 34 | 35 | ```python 36 | 37 | INSTALLED_APPS = ( 38 | 39 | ... 40 | 41 | 'django_admin_filters', 42 | ) 43 | ``` 44 | 45 | Then connect the static files of the library. 46 | 47 | ```bash 48 | manage.py collectstatic 49 | ``` 50 | 51 | ## Initial data 52 | 53 | Let's say we have a table in the database. The records contain follows fields. 54 | 55 | ```python 56 | # models.py 57 | 58 | from django.db import models 59 | 60 | STATUS_CHOICES = ( 61 | ('P', 'Pending'), 62 | ('A', 'Approved'), 63 | ('R', 'Rejected'), 64 | ) 65 | 66 | class Log(models.Model): 67 | text = models.CharField(max_length=100) 68 | 69 | timestamp1 = models.DateTimeField(default=None, null=True) 70 | timestamp2 = models.DateTimeField(default=None, null=True) 71 | 72 | status = models.CharField(max_length=1, default='P', choices=STATUS_CHOICES) 73 | 74 | is_online = models.BooleanField(default=False) 75 | is_trouble1 = models.BooleanField(default=False) 76 | is_trouble2 = models.BooleanField(default=False) 77 | ``` 78 | 79 | ## Shared settings for all filters in the library 80 | 81 | You can customize the appearance and behavior of filters to suit your needs by inheriting the filter classes from the library and overriding some of the attributes. 82 | All library filters support the following attributes. 83 | 84 | ```python 85 | from django_admin_filters import MultiChoice 86 | 87 | class MyChoicesFilter(MultiChoice): 88 | FILTER_LABEL = "Select options" 89 | BUTTON_LABEL = "Apply" 90 | ``` 91 | 92 | - FILTER_LABEL: Filter title 93 | - BUTTON_LABEL: Title for filter apply button 94 | 95 | ## MultiChoice filter 96 | 97 | For model fields of type `CharField` or `IntegerField` defined using the `choices` parameter (for example, the 'status' field in the `Log` model), you can use the MultiChoice filter. 98 | Values of the parameter `choices` will be displayed as checkboxes. 99 | 100 | To use MultiChoice filter, you need to specify them in the `admin.py` file in the `list_filter` attribute of the corresponding class. 101 | 102 | ```python 103 | # admin.py 104 | 105 | from django.contrib import admin 106 | from django_admin_filters import MultiChoice 107 | from .models import Log 108 | 109 | class StatusFilter(MultiChoice): 110 | FILTER_LABEL = "By status" 111 | 112 | class Admin(admin.ModelAdmin): 113 | list_display = ['text', 'status'] 114 | list_filter = [('status', StatusFilter)] 115 | 116 | admin.site.register(Log, Admin) 117 | ``` 118 | 119 | In the Django admin panel, check the required checkboxes in the filter and click the "Apply" button. 120 | If all filter checkboxes are unchecked and the apply filter button is pressed, than the filter will not been aplied and all records will be displayed. 121 | 122 | ## MultiChoiceExt filter 123 | 124 | Sometimes you need to filter data by a custom defined property that does not match a single field in the model. 125 | 126 | For example, in the `Log` model of the source data, there are three boolean fields. 127 | 128 | ```python 129 | is_online = models.BooleanField(default=False) 130 | is_trouble1 = models.BooleanField(default=False) 131 | is_trouble2 = models.BooleanField(default=False) 132 | ``` 133 | 134 | For this model, we define the `color` property as follows. 135 | 136 | - The `color` property has the value 'red' if the field `is_online == False`. 137 | - If `is_online == True` and both `is_trouble1` and `is_trouble2` fields are False, then the value of the property is 'green'. 138 | - If `is_online == True` and at least one of the fields `is_trouble1` and `is_trouble2` is True, then the property has the value 'yellow'. 139 | 140 | ```python 141 | # models.py 142 | 143 | @property 144 | def color(self): 145 | status = 'red' 146 | if self.is_online: 147 | status = 'green' 148 | if self.is_trouble1 or self.is_trouble2: 149 | status = 'yellow' 150 | 151 | return status 152 | ``` 153 | 154 | To filter data by such a property in the Django admin panel, you can use the MultiChoiceExt filter. 155 | In the `options` attribute, you need to specify a list of checkboxes that will be displayed when using the filter. 156 | 157 | Each element of the list consists of three values. 158 | 159 | - a unique string to be used in the GET request parameter 160 | - checkbox label 161 | - filtering expression applied to the DB model in the form of [Django Q-objects](https://docs.djangoproject.com/en/dev/topics/db/queries/#complex-lookups-with-q-objects) 162 | 163 | In the `parameter_name` attribute, you need to specify the name of the GET request parameter for sending filter data. 164 | 165 | For our example, the code will look like this. 166 | 167 | ```python 168 | # admin.py 169 | 170 | from django.db.models import Q 171 | from django_admin_filters import MultiChoiceExt 172 | 173 | class ColorFilter(MultiChoiceExt): 174 | FILTER_LABEL = "By color" 175 | parameter_name = "color" 176 | options = [ 177 | ('red', 'Red', Q(is_online=False)), 178 | ('yellow', 'Yellow', Q(is_online=True) & (Q(is_trouble1=True) | Q(is_trouble2=True))), 179 | ('green', 'Green', Q(is_online=True) & Q(is_trouble1=False) & Q(is_trouble2=False)), 180 | ] 181 | 182 | class Admin(admin.ModelAdmin): 183 | list_display = ['text', 'color'] 184 | list_filter = [ColorFilter] 185 | 186 | admin.site.register(Log, Admin) 187 | ``` 188 | 189 | Otherwise, the behavior and settings of the `MultiChoiceExt` filter are similar to the `MultiChoice` filter described earlier. 190 | 191 | ## DateRange and DateRangePicker filters 192 | 193 | To use filters with a date interval, you need to specify them in the `admin.py` file in the `list_filter` attribute of the corresponding class. 194 | 195 | ```python 196 | # admin.py 197 | 198 | from django.contrib import admin 199 | from django_admin_filters import DateRange, DateRangePicker 200 | from .models import Log 201 | 202 | class Admin(admin.ModelAdmin): 203 | list_display = ['text', 'timestamp1', 'timestamp2'] 204 | list_filter = (('timestamp1', DateRange), ('timestamp2', DateRangePicker)) 205 | 206 | admin.site.register(Log, Admin) 207 | ``` 208 | 209 | ### Customization for DateRange filter 210 | 211 | ```python 212 | # admin.py 213 | 214 | from django_admin_filters import DateRange 215 | 216 | class MyDateRange(DateRange): 217 | FILTER_LABEL = "Data range" 218 | BUTTON_LABEL = "Set range" 219 | FROM_LABEL = "From" 220 | TO_LABEL = "To" 221 | ALL_LABEL = 'All' 222 | CUSTOM_LABEL = "custom range" 223 | NULL_LABEL = "no date" 224 | DATE_FORMAT = "YYYY-MM-DD HH:mm" 225 | 226 | is_null_option = True 227 | 228 | options = ( 229 | ('1da', "24 hours ahead", 60 * 60 * 24), 230 | ('1dp', "24 hours in the past", 60 * 60 * -24), 231 | ) 232 | ``` 233 | 234 | You can override the following attributes. 235 | 236 | - `FILTER_LABEL`: Title of the filter. 237 | - `BUTTON_LABEL`: Text on the apply filter button. 238 | - `FROM_LABEL`: The label of the start date field. 239 | - `TO_LABEL`: The label of the end date field. 240 | - `ALL_LABEL`: The label of the menu item for displaying all records. 241 | - `CUSTOM_LABEL`: The label of the menu item when date range is set. 242 | - `NULL_LABEL`: The label of the menu item for displaying records without date. 243 | - `is_null_option`: Set this attribute to `False` to remove the option to display record without date from the filter menu. 244 | - `parameter_start_mask`: Mask of the GET request parameter name for the start date of the date range. 245 | - `parameter_end_mask`: Mask of the GET request parameter name for the end date of the date range. 246 | - `DATE_FORMAT`: Hint about the format of the date and time fields. 247 | 248 | You can change the date/time input format to your own. 249 | However, you may need to override the `to_dtime` method as well. 250 | This method is used to convert a user-entered string into a `datetime` value. 251 | By default, the method is defined as follows. 252 | 253 | ```python 254 | @staticmethod 255 | def to_dtime(text): 256 | try: 257 | return datetime.fromisoformat(text) 258 | except ValueError: 259 | return None 260 | ``` 261 | 262 | The `options` attribute specifies filter menu items that allow you to select data from the current moment to an offset of a specified number of seconds in the past or future. 263 | Each element of the `options` list contains three values. 264 | 265 | - A unique string to use in the GET request parameters. Except for the strings 'custom' and 'empty' which are used by the filter. 266 | - The title of the item in the filter menu. 267 | - Offset in seconds relative to the current moment. A negative value specifies an offset to the past. 268 | 269 | ### Customization for DateRangePicker filter 270 | 271 | The `DateRangePicker` filter with a javascript calendar date/time picker widget is derived from the `DateRange` filter and allows you to override all the attributes described above. 272 | Also, additional attributes can be overridden in `DateRangePicker`. 273 | 274 | ```python 275 | # admin.py 276 | 277 | from django_admin_filters import DateRangePicker 278 | 279 | class MyDateRangePicker(DateRangePicker): 280 | WIDGET_LOCALE = 'en' 281 | WIDGET_BUTTON_LABEL = "Set" 282 | WIDGET_WITH_TIME = True 283 | 284 | WIDGET_START_TITLE = 'Start date' 285 | WIDGET_START_TOP = -350 286 | WIDGET_START_LEFT = -400 287 | 288 | WIDGET_END_TITLE = 'End date' 289 | WIDGET_END_TOP = -350 290 | WIDGET_END_LEFT = -400 291 | ``` 292 | 293 | - WIDGET_LOCALE: The language code for display the names of the months and days of the week. By default is the value of the `LANGUAGE_CODE` item in your project's `settings.py` file. 294 | - WIDGET_BUTTON_LABEL: The label of the select button. 295 | - WIDGET_WITH_TIME: Set this attribute to `False` if you only want to select a date without a time. 296 | - WIDGET_START_TITLE: The title of the widget when selecting the start date of the interval. 297 | - WIDGET_START_TOP: The vertical offset of the widget's calendar window when selecting the start date of the interval. 298 | - WIDGET_START_LEFT: The horizontal offset of the widget's calendar window when selecting the start date of the interval. 299 | - WIDGET_END_TITLE: The title of the widget when selecting the end date of the interval. 300 | - WIDGET_END_TOP: The vertical offset of the widget's calendar window when selecting the end date of the interval. 301 | - WIDGET_END_LEFT: The horizontal offset of the widget's calendar window when selecting the end date of the interval. 302 | 303 | ## Usage example 304 | 305 | You can run an example of using the library on your local host. 306 | 307 | On the Windows platform, you must first install the following programs. 308 | 309 | - [Python3](https://www.python.org/downloads/release/python-3712/) 310 | - GNU [Unix Utils](http://unxutils.sourceforge.net/) for operations via makefile 311 | - [Git for Windows](https://git-scm.com/download/win) to access the source code repository. 312 | 313 | Then clone the repository and run the installation, specifying the path to Python 3. 314 | 315 | ```bash 316 | git clone git@github.com:vb64/django.admin.filters.git 317 | cd django.admin.filters 318 | make setup PYTHON_BIN=/usr/bin/python3 319 | ``` 320 | 321 | Collect static files and create a database. 322 | 323 | ```bash 324 | make static 325 | make db 326 | ``` 327 | 328 | Create a database superuser by specifying a login and password for it. 329 | 330 | ```bash 331 | make superuser 332 | ``` 333 | 334 | Run example. 335 | 336 | ```bash 337 | make example 338 | ``` 339 | 340 | Open `http://127.0.0.1:8000/admin/` in a browser to view the example site. 341 | To enter the admin panel you need to use the login and password that were set when creating the superuser. 342 | 343 | ## Related projects 344 | 345 | - [django-admin-list-filter-dropdown](https://github.com/mrts/django-admin-list-filter-dropdown) `DropdownFilter` class that renders as a drop-down in the filtering sidebar for Django admin list views. 346 | -------------------------------------------------------------------------------- /READMEru.md: -------------------------------------------------------------------------------- 1 | # Библиотека DjangoAdminFilters 2 | 3 | [На английском](README.md) 4 | 5 | Бесплатная, с открытым исходным кодом библиотека DjangoAdminFilters позволяет использовать несколько дополнительных фильтров в таблицах админки Django. 6 | 7 | - `MultiChoice`: множественный выбор с чекбоксами для полей типа CharField и IntegerField, имеющих опцию 'choices' 8 | - `MultiChoiceExt`: другая версия предыдущего фильтра, который позволяет фильтровать по заданным пользователем свойствам 9 | - `DateRange`: позволяет задавать пользовательский интервал дат с использованием полей `input` 10 | - `DateRangePicker`: позволяет задавать пользовательский интервал дат с использованием javascript виджета выбора даты/времени из календаря 11 | 12 | MultiChoice и MultiChoiceExt | DateRange | DateRangePicker 13 | :------------:|:-------------:|:------------: 14 | ![MultiChoice](img/multi_choice_en.png) | ![DateRange с полем input](img/daterange_ru.png) | ![DateRangePicker с js виджетом](img/picker_ru.png) 15 | 16 | Для javascript виджета в фильтре DateRangePicker используется код [проекта date-and-time-picker](https://github.com/polozin/date-and-time-picker) с внедренным [пул-реквестом](https://github.com/polozin/date-and-time-picker/pull/4/files), позволяющем выбирать в этом виджете даты ранее текущей. 17 | 18 | ## Установка 19 | 20 | ```bash 21 | pip install django-admin-list-filters 22 | ``` 23 | 24 | Для подключения библиотеки к проекту нужно добавить `django_admin_filters` в список `INSTALLED_APPS` в файле `settings.py`. 25 | 26 | ```python 27 | 28 | INSTALLED_APPS = ( 29 | 30 | ... 31 | 32 | 'django_admin_filters', 33 | ) 34 | ``` 35 | 36 | Затем подключите статические файлы библиотеки. 37 | 38 | ```bash 39 | manage.py collectstatic 40 | ``` 41 | 42 | ## Исходные данные 43 | 44 | Допустим, у нас в БД имеется таблица, записи которой содержат следующие поля. 45 | 46 | ```python 47 | # models.py 48 | 49 | from django.db import models 50 | 51 | STATUS_CHOICES = ( 52 | ('P', 'Pending'), 53 | ('A', 'Approved'), 54 | ('R', 'Rejected'), 55 | ) 56 | 57 | class Log(models.Model): 58 | text = models.CharField(max_length=100) 59 | 60 | timestamp1 = models.DateTimeField(default=None, null=True) 61 | timestamp2 = models.DateTimeField(default=None, null=True) 62 | 63 | status = models.CharField(max_length=1, default='P', choices=STATUS_CHOICES) 64 | 65 | is_online = models.BooleanField(default=False) 66 | is_trouble1 = models.BooleanField(default=False) 67 | is_trouble2 = models.BooleanField(default=False) 68 | ``` 69 | 70 | ## Общие настройки для всех фильтров библиотеки 71 | 72 | Вы можете настроить внешний вид и поведение фильтров под свои требования путем наследования классов фильтров из библиотеки и переопределения некоторых атрибутов. 73 | Все фильтры библиотеки поддерживают следующие атрибуты. 74 | 75 | ```python 76 | from django_admin_filters import MultiChoice 77 | 78 | class MyChoicesFilter(MultiChoice): 79 | FILTER_LABEL = "Выберите опции" 80 | BUTTON_LABEL = "Применить" 81 | ``` 82 | 83 | - FILTER_LABEL: Заголовок фильтра 84 | - BUTTON_LABEL: Заголовок кнопки применения фильтра 85 | 86 | ## Фильтр MultiChoice 87 | 88 | Для полей модели типа `CharField` или `IntegerField`, определенных с использованием параметра `choices` (например, поле 'status' в модели `Log`), можно использовать фильтр MultiChoice. 89 | Значения из параметра `choices` будут отображаться в виде чекбоксов. 90 | 91 | Для использования фильтра MultiChoice, укажите его в атрибуте `list_filter` соответствующего класса файла `admin.py`. 92 | 93 | ```python 94 | # admin.py 95 | 96 | from django.contrib import admin 97 | from django_admin_filters import MultiChoice 98 | from .models import Log 99 | 100 | class StatusFilter(MultiChoice): 101 | FILTER_LABEL = "По статусу" 102 | BUTTON_LABEL = "Применить" 103 | 104 | class Admin(admin.ModelAdmin): 105 | list_display = ['text', 'status'] 106 | list_filter = [('status', StatusFilter)] 107 | 108 | admin.site.register(Log, Admin) 109 | ``` 110 | 111 | В админке Django отметьте нужные чекбоксы в фильтре и нажмите кнопку "Применить". 112 | Если пометка снята со всех чекбоксов фильтра и нажата кнопка применения фильтра, то фильтр не будет действовать и отобразятся все записи. 113 | 114 | ## Фильтр MultiChoiceExt 115 | 116 | Иногда нужно фильтровать данные по виртуальному свойству, которому не соответствует единственное поле модели. 117 | 118 | Например, в модели `Log` исходных данных есть три булевых поля. 119 | 120 | ```python 121 | is_online = models.BooleanField(default=False) 122 | is_trouble1 = models.BooleanField(default=False) 123 | is_trouble2 = models.BooleanField(default=False) 124 | ``` 125 | 126 | Для этой модели мы определяем свойство `color` следующим образом. 127 | 128 | - Свойство `color` имеет значение 'red', если поле `is_online == False`. 129 | - Если `is_online == True` и оба поля `is_trouble1` и `is_trouble2` имеют значение False, то свойство имеет значение 'green'. 130 | - Если `is_online == True` и хотя бы одно из полей `is_trouble1` и `is_trouble2` имеет значение True, то свойство имеет значение 'yellow'. 131 | 132 | ```python 133 | # models.py 134 | 135 | @property 136 | def color(self): 137 | status = 'red' 138 | if self.is_online: 139 | status = 'green' 140 | if self.is_trouble1 or self.is_trouble2: 141 | status = 'yellow' 142 | 143 | return status 144 | ``` 145 | 146 | Для фильтрации данных по такому свойству в админке Django можно использовать фильтр MultiChoiceExt. 147 | В атрибуте `options` нужно указать список чекбоксов, который будет отображаться при использовании фильтра. 148 | 149 | Каждый элемент списка состоит из трех значений. 150 | 151 | - уникальная строка, которая будет использоваться в параметре GET-запроса 152 | - текст у чекбокса 153 | - применяемое к таблице модели в БД выражение фильтрации в виде [Q-объектов Django](https://docs.djangoproject.com/en/dev/topics/db/queries/#complex-lookups-with-q-objects) 154 | 155 | В атрибуте `parameter_name` нужно указать имя параметра GET-запроса, в котором будут передаваться данные фильтра. 156 | 157 | Для нашего примера код будет таким. 158 | 159 | ```python 160 | # admin.py 161 | 162 | from django.db.models import Q 163 | from django_admin_filters import MultiChoiceExt 164 | 165 | class ColorFilter(MultiChoiceExt): 166 | FILTER_LABEL = "По цвету" 167 | parameter_name = "color" 168 | options = [ 169 | ('red', 'Red', Q(is_online=False)), 170 | ('yellow', 'Yellow', Q(is_online=True) & (Q(is_trouble1=True) | Q(is_trouble2=True))), 171 | ('green', 'Green', Q(is_online=True) & Q(is_trouble1=False) & Q(is_trouble2=False)), 172 | ] 173 | 174 | class Admin(admin.ModelAdmin): 175 | list_display = ['text', 'color'] 176 | list_filter = [ColorFilter] 177 | 178 | admin.site.register(Log, Admin) 179 | ``` 180 | 181 | В остальном поведение и настройки фильтра `MultiChoiceExt` аналогичны описанному ранее фильтру `MultiChoice`. 182 | 183 | ## Фильтры DateRange и DateRangePicker 184 | 185 | Для использования фильтров с интервалом дат нужно в файле `admin.py` указать их в атрибуте `list_filter` соответствующего класса. 186 | 187 | ```python 188 | # admin.py 189 | 190 | from django.contrib import admin 191 | from django_admin_filters import DateRange, DateRangePicker 192 | from .models import Log 193 | 194 | class Admin(admin.ModelAdmin): 195 | list_display = ['text', 'timestamp1', 'timestamp2'] 196 | list_filter = (('timestamp1', DateRange), ('timestamp2', DateRangePicker)) 197 | 198 | admin.site.register(Log, Admin) 199 | ``` 200 | 201 | ## Настройка фильтра DateRange 202 | 203 | ```python 204 | # admin.py 205 | 206 | from django_admin_filters import DateRange 207 | 208 | class MyDateRange(DateRange): 209 | FILTER_LABEL = "Интервал данных" 210 | BUTTON_LABEL = "Задать интервал" 211 | FROM_LABEL = "От" 212 | TO_LABEL = "До" 213 | ALL_LABEL = 'Все' 214 | CUSTOM_LABEL = "пользовательский" 215 | NULL_LABEL = "без даты" 216 | DATE_FORMAT = "YYYY-MM-DD HH:mm" 217 | 218 | is_null_option = True 219 | 220 | options = ( 221 | ('1da', "24 часа вперед", 60 * 60 * 24), 222 | ('1dp', "последние 24 часа", 60 * 60 * -24), 223 | ) 224 | ``` 225 | 226 | Можно переопределять следующие атрибуты. 227 | 228 | - `FILTER_LABEL`: Заголовок фильтра. 229 | - `BUTTON_LABEL`: Текст кнопки применения фильтра. 230 | - `FROM_LABEL`: Текст у поля начальной даты. 231 | - `TO_LABEL`: Текст у поля конечной даты. 232 | - `ALL_LABEL`: Текст пункта меню фильтра для отображения всех записей. 233 | - `CUSTOM_LABEL`: Текст пункта меню фильтра при использовании интервала дат. 234 | - `NULL_LABEL`: Текст пункта меню фильтра для отображения записей без даты. 235 | - `is_null_option`: Установите этот атрибут в `False`, чтобы убрать из меню фильтра пункт отображения записей без даты. 236 | - `parameter_start_mask`: Маска имени параметра GET-запроса для начальной даты диапазона дат. 237 | - `parameter_end_mask`: Маска имени параметра GET-запроса для конечной даты диапазона дат. 238 | - `DATE_FORMAT`: Текст подсказки о формате полей даты и времени. 239 | 240 | Вы можете изменить формат ввода даты/времени на собственный. 241 | Но при этом вам возможно будет необходимо также переопределить метод `to_dtime`. 242 | Этот метод используется для преобразования введенной пользователем строки в значение `datetime`. 243 | По умолчанию метод определен следующим образом. 244 | 245 | ```python 246 | @staticmethod 247 | def to_dtime(text): 248 | try: 249 | return datetime.fromisoformat(text) 250 | except ValueError: 251 | return None 252 | ``` 253 | 254 | Атрибут `options` задает пункты меню фильтра, позволяющие выбирать данные от текущего момента до смещения на заданное количество секунд в прошлом либо будущем. 255 | Каждый элемент списка `options` содержит три значения. 256 | 257 | - Уникальная строка для использования в параметрах GET запроса. Кроме строк 'custom' и 'empty', которые используются фильтром. 258 | - Заголовок пункта в меню фильтра. 259 | - Смещение в секундах относительно текущего момента. Отрицательное значение задает смещение в прошлое. 260 | 261 | ## Настройка фильтра DateRangePicker 262 | 263 | Фильтр `DateRangePicker` с javascript виджетом выбора даты/времени из календаря является производным от фильтра `DateRange` и позволяет переопределять все описанные выше атрибуты. 264 | Кроме того, в `DateRangePicker` можно переопределить дополнительные атрибуты. 265 | 266 | ```python 267 | # admin.py 268 | 269 | from django_admin_filters import DateRangePicker 270 | 271 | class MyDateRangePicker(DateRangePicker): 272 | WIDGET_LOCALE = 'ru' 273 | WIDGET_BUTTON_LABEL = "Выбрать" 274 | WIDGET_WITH_TIME = True 275 | 276 | WIDGET_START_TITLE = 'Начальная дата' 277 | WIDGET_START_TOP = -350 278 | WIDGET_START_LEFT = -400 279 | 280 | WIDGET_END_TITLE = 'Конечная дата' 281 | WIDGET_END_TOP = -350 282 | WIDGET_END_LEFT = -400 283 | ``` 284 | 285 | - WIDGET_LOCALE: Код языка, на котором виджет будет отображать названия месяцев и дней недели. По умолчанию используется значение параметра `LANGUAGE_CODE` файла `settings.py` вашего проекта. 286 | - WIDGET_BUTTON_LABEL: Текст кнопки выбора виджета. 287 | - WIDGET_WITH_TIME: Установите значение этого атрибута в `False`, если вам требуется только выбор даты без времени. 288 | - WIDGET_START_TITLE: Заголовок виджета при выборе начальной даты интервала. 289 | - WIDGET_START_TOP: Смещение по вертикали окна календаря виджета при выборе начальной даты интервала. 290 | - WIDGET_START_LEFT: Смещение по горизонтали окна календаря виджета при выборе начальной даты интервала. 291 | - WIDGET_END_TITLE: Заголовок виджета при выборе конечной даты интервала. 292 | - WIDGET_END_TOP: Смещение по вертикали окна календаря виджета при выборе конечной даты интервала. 293 | - WIDGET_END_LEFT: Смещение по горизонтали окна календаря виджета при выборе конечной даты интервала. 294 | 295 | ## Пример использования 296 | 297 | Вы можете запустить работающий на локальном компьютере пример использования библиотеки. 298 | 299 | На платформе Windows для этого нужно предварительно установить следующие программы. 300 | 301 | - [Python3](https://www.python.org/downloads/release/python-3712/) 302 | - GNU [Unix Utils](http://unxutils.sourceforge.net/) для операций через makefile 303 | - [Git for Windows](https://git-scm.com/download/win) для доступа к репозитарию исходных кодов. 304 | 305 | Затем склонировать репозитарий и запустить установку, указав путь на Python 3. 306 | 307 | ```bash 308 | git clone git@github.com:vb64/django.admin.filters.git 309 | cd django.admin.filters 310 | make setup PYTHON_BIN=/usr/bin/python3 311 | ``` 312 | 313 | Подключить статические файлы библиотеки и создать базу данных. 314 | 315 | ```bash 316 | make static 317 | make db 318 | ``` 319 | 320 | Создать суперюзера базы данных, указав для него логин и пароль. 321 | 322 | ```bash 323 | make superuser 324 | ``` 325 | 326 | Запустить пример. 327 | 328 | ```bash 329 | make example 330 | ``` 331 | 332 | Открыть в браузере адрес `http://127.0.0.1:8000/admin/` для просмотра сайта примера. 333 | Для входа в админку нужно использовать логин и пароль, заданные при создании суперюзера. 334 | 335 | ## Похожие проекты 336 | 337 | - [django-admin-list-filter-dropdown](https://github.com/mrts/django-admin-list-filter-dropdown) фильтр как "выпадающий список" для боковой панели в админке Django. 338 | -------------------------------------------------------------------------------- /deploy.txt: -------------------------------------------------------------------------------- 1 | setuptools>=42 2 | wheel 3 | build 4 | twine 5 | -------------------------------------------------------------------------------- /django_admin_filters/__init__.py: -------------------------------------------------------------------------------- 1 | """Filters for Django Admin site.""" 2 | from .daterange import Filter as DateRange, FilterPicker as DateRangePicker # noqa: F401 3 | from .multi_choice import Filter as MultiChoice, FilterExt as MultiChoiceExt # noqa: F401 4 | -------------------------------------------------------------------------------- /django_admin_filters/base.py: -------------------------------------------------------------------------------- 1 | """Base class for lib filters.""" 2 | from django.contrib import admin 3 | 4 | 5 | class Base: 6 | """Mixin class for filters with title, apply button and collapsed state.""" 7 | 8 | parameter_name = 'filter' 9 | title = None 10 | 11 | FILTER_LABEL = "Admin filter" 12 | BUTTON_LABEL = "Apply" 13 | 14 | def set_title(self): 15 | """Init title values.""" 16 | self.title = { 17 | 'parameter_name': self.parameter_name, 18 | 'filter_name': self.FILTER_LABEL, 19 | 'button_label': self.BUTTON_LABEL, 20 | } 21 | 22 | 23 | class Filter(admin.FieldListFilter, Base): 24 | """Base class for filters applied to field with title, apply button and collapsed state.""" 25 | 26 | parameter_name_mask = 'adminfilter_' 27 | 28 | def __init__(self, field, request, params, model, model_admin, field_path): 29 | """Customize FieldListFilter functionality.""" 30 | self.parameter_name = self.parameter_name_mask + field_path 31 | super().__init__(field, request, params, model, model_admin, field_path) 32 | self.set_title() 33 | 34 | def get_facet_counts(self, _pk_attname, _filtered_qs): 35 | """Django5 amin site Facets. 36 | 37 | https://docs.djangoproject.com/en/5.0/ref/contrib/admin/filters/#facet-filters 38 | """ 39 | return {} 40 | 41 | def value(self): 42 | """Return the string provided in the request's query string. 43 | 44 | None if the value wasn't provided. 45 | """ 46 | val = self.used_parameters.get(self.parameter_name) 47 | if isinstance(val, list): 48 | val = val[0] 49 | 50 | return val 51 | 52 | def expected_parameters(self): 53 | """Parameter list for chice filter.""" 54 | return [self.parameter_name] 55 | 56 | def choices(self, changelist): 57 | """Must be implemented in childs.""" 58 | raise NotImplementedError('Method choices') 59 | 60 | 61 | class FilterSimple(admin.SimpleListFilter, Base): 62 | """Base class for filters without field with title, apply button and collapsed state.""" 63 | 64 | parameter_name = 'adminfilter' 65 | title = 'Filter' 66 | 67 | def __init__(self, request, params, model, model_admin): 68 | """Combine parents init.""" 69 | super().__init__(request, params, model, model_admin) 70 | self.set_title() 71 | 72 | def lookups(self, request, model_admin): 73 | """Must be implemented in childs.""" 74 | raise NotImplementedError('Method lookups') 75 | 76 | def queryset(self, request, queryset): 77 | """Must be implemented in childs.""" 78 | raise NotImplementedError('Method queryset') 79 | -------------------------------------------------------------------------------- /django_admin_filters/daterange.py: -------------------------------------------------------------------------------- 1 | """Django admin daterange filters with shortcuts.""" 2 | from datetime import datetime, timedelta 3 | from django.conf import settings 4 | from .base import Filter as BaseFilter 5 | 6 | HOUR_SECONDS = 60 * 60 7 | DAY_SECONDS = HOUR_SECONDS * 24 8 | 9 | KEY_SELECTED = 'selected' 10 | KEY_QUERY = 'query_string' 11 | KEY_DISPLAY = 'display' 12 | 13 | 14 | class Filter(BaseFilter): 15 | """Date range filter with input fields.""" 16 | 17 | FILTER_LABEL = "Data range" 18 | BUTTON_LABEL = "Set range" 19 | 20 | FROM_LABEL = "From" 21 | TO_LABEL = "To" 22 | ALL_LABEL = 'All' 23 | CUSTOM_LABEL = "custom range" 24 | NULL_LABEL = "no date" 25 | DATE_FORMAT = "YYYY-MM-DD HH:mm" 26 | INITIAL_START = '' 27 | INITIAL_END = '' 28 | 29 | WRONG_OPTION_VALUE = -DAY_SECONDS 30 | is_null_option = True 31 | 32 | options = ( 33 | ('1da', "24 hours ahead", DAY_SECONDS), 34 | ('1dp', "24 hours in the past", -DAY_SECONDS), 35 | ) 36 | 37 | template = 'daterange.html' 38 | parameter_name_mask = 'range_' 39 | parameter_start_mask = "start_{}" 40 | parameter_end_mask = "end_{}" 41 | option_custom = 'custom' 42 | option_null = 'empty' 43 | 44 | def __init__(self, field, request, params, model, model_admin, field_path): 45 | """Customize BaseFilter functionality.""" 46 | self.parameter_start = self.parameter_start_mask.format(field_path) 47 | self.parameter_end = self.parameter_end_mask.format(field_path) 48 | super().__init__(field, request, params, model, model_admin, field_path) 49 | 50 | self.lookup_choices = list(self.lookups(request, model_admin)) 51 | self.interval = {i[0]: i[2] for i in self.options} 52 | self.title.update({ 53 | 'parameter_start': self.parameter_start, 54 | 'parameter_end': self.parameter_end, 55 | 'option_custom': self.option_custom, 56 | 'start_label': self.FROM_LABEL, 57 | 'end_label': self.TO_LABEL, 58 | 'date_format': self.DATE_FORMAT, 59 | 'start_val': request.GET.get(self.parameter_start, self.INITIAL_START), 60 | 'end_val': request.GET.get(self.parameter_end, self.INITIAL_END), 61 | }) 62 | 63 | @staticmethod 64 | def to_dtime(text): 65 | """Convert string to datetime.""" 66 | if isinstance(text, list): 67 | text = text[0] 68 | 69 | try: 70 | return datetime.fromisoformat(text) 71 | except ValueError: 72 | return None 73 | 74 | def expected_parameters(self): 75 | """Parameter list for filter.""" 76 | return [self.parameter_name, self.parameter_start, self.parameter_end] 77 | 78 | def lookups(self, request, _model_admin): 79 | """Return a list of tuples. 80 | 81 | The first element in each tuple is the coded value for the option that will appear in the URL query. 82 | The second element is the human-readable name for the option that will appear in the right sidebar. 83 | """ 84 | return [i[:2] for i in self.options] 85 | 86 | def queryset(self, request, queryset): 87 | """Return the filtered queryset. 88 | 89 | Based on the value provided in the query string and retrievable via `self.value()`. 90 | """ 91 | value = self.value() 92 | 93 | if value is None: 94 | return queryset 95 | 96 | if value == self.option_custom: 97 | 98 | if self.parameter_start in self.used_parameters: 99 | dtime = self.to_dtime(self.used_parameters[self.parameter_start]) 100 | if dtime: 101 | queryset = queryset.filter(**{self.field_path + "__gte": dtime}) 102 | 103 | if self.parameter_end in self.used_parameters: 104 | dtime = self.to_dtime(self.used_parameters[self.parameter_end]) 105 | if dtime: 106 | queryset = queryset.filter(**{self.field_path + "__lt": dtime}) 107 | 108 | return queryset 109 | 110 | if value == self.option_null: 111 | return queryset.filter(**{self.field_path + "__isnull": True}) 112 | 113 | now = datetime.utcnow() 114 | delta = self.interval.get(value, self.WRONG_OPTION_VALUE) 115 | 116 | if delta < 0: # in past 117 | params = { 118 | self.field_path + "__gte": now + timedelta(seconds=delta), 119 | self.field_path + "__lt": now, 120 | } 121 | else: # in future 122 | params = { 123 | self.field_path + "__lte": now + timedelta(seconds=delta), 124 | self.field_path + "__gt": now, 125 | } 126 | 127 | return queryset.filter(**params) 128 | 129 | def choices(self, changelist): 130 | """Define filter shortcuts.""" 131 | yield { 132 | KEY_SELECTED: self.value() is None, 133 | KEY_QUERY: changelist.get_query_string(remove=[self.parameter_name]), 134 | KEY_DISPLAY: self.ALL_LABEL, 135 | } 136 | 137 | for lookup, title in self.lookup_choices: 138 | yield { 139 | KEY_SELECTED: self.value() == str(lookup), 140 | KEY_QUERY: changelist.get_query_string({self.parameter_name: lookup}), 141 | KEY_DISPLAY: title, 142 | } 143 | 144 | if self.is_null_option: 145 | yield { 146 | KEY_SELECTED: self.value() == self.option_null, 147 | KEY_QUERY: changelist.get_query_string({self.parameter_name: self.option_null}), 148 | KEY_DISPLAY: self.NULL_LABEL, 149 | } 150 | 151 | yield { 152 | KEY_SELECTED: self.value() == self.option_custom, 153 | KEY_QUERY: changelist.get_query_string({self.parameter_name: self.option_custom}), 154 | KEY_DISPLAY: self.CUSTOM_LABEL, 155 | } 156 | 157 | 158 | class FilterPicker(Filter): 159 | """Date range filter with js datetime picker widget.""" 160 | 161 | template = 'daterange_picker.html' 162 | 163 | INITIAL_START = 'now' 164 | INITIAL_END = 'now' 165 | 166 | WIDGET_LOCALE = settings.LANGUAGE_CODE 167 | WIDGET_BUTTON_LABEL = "Set" 168 | WIDGET_WITH_TIME = True 169 | 170 | WIDGET_START_TITLE = 'Start date' 171 | WIDGET_START_TOP = -350 172 | WIDGET_START_LEFT = -400 if WIDGET_WITH_TIME else -100 173 | 174 | WIDGET_END_TITLE = 'End date' 175 | WIDGET_END_TOP = -350 176 | WIDGET_END_LEFT = -400 if WIDGET_WITH_TIME else -100 177 | 178 | def __init__(self, field, request, params, model, model_admin, field_path): 179 | """Apply js widget settings.""" 180 | super().__init__(field, request, params, model, model_admin, field_path) 181 | self.title.update({ 182 | 'widget_locale': self.WIDGET_LOCALE, 183 | 'widget_button_label': self.WIDGET_BUTTON_LABEL, 184 | 'widget_with_time': 'true' if self.WIDGET_WITH_TIME else 'false', 185 | 'widget_start_title': self.WIDGET_START_TITLE, 186 | 'widget_start_top': self.WIDGET_START_TOP, 187 | 'widget_start_left': self.WIDGET_START_LEFT, 188 | 'widget_end_title': self.WIDGET_END_TITLE, 189 | 'widget_end_top': self.WIDGET_END_TOP, 190 | 'widget_end_left': self.WIDGET_END_LEFT, 191 | }) 192 | -------------------------------------------------------------------------------- /django_admin_filters/multi_choice.py: -------------------------------------------------------------------------------- 1 | """Django admin multi choice filter with checkboxes for db fields with choices option.""" 2 | from .base import Filter as BaseFilter, FilterSimple as BaseFilterSimple 3 | 4 | 5 | class Choices: 6 | """Multi choice options filter.""" 7 | 8 | template = None 9 | selected = [] 10 | lookup_choices = [] 11 | 12 | FILTER_LABEL = "By choices" 13 | CHOICES_SEPARATOR = ',' 14 | 15 | def set_selected(self, val, title): 16 | """Init choices according request parameter string.""" 17 | self.template = 'multi_choice.html' 18 | title.update({ 19 | 'choices_separator': self.CHOICES_SEPARATOR, 20 | }) 21 | 22 | if isinstance(val, str): 23 | val = val.split(self.CHOICES_SEPARATOR) 24 | 25 | self.selected = val or [] 26 | 27 | def choices(self, _changelist): 28 | """Define filter checkboxes.""" 29 | for lookup, title in self.lookup_choices: 30 | yield { 31 | 'selected': lookup in self.selected, 32 | 'value': lookup, 33 | 'display': title, 34 | } 35 | 36 | 37 | class Filter(BaseFilter, Choices): 38 | """Multi choice options filter. 39 | 40 | For CharField and IntegerField fields with 'choices' option. 41 | 42 | https://stackoverflow.com/questions/39790087/is-multi-choice-django-admin-filters-possible 43 | https://stackoverflow.com/questions/38508672/django-admin-filter-multiple-select 44 | https://github.com/ctxis/django-admin-multiple-choice-list-filter 45 | https://github.com/modlinltd/django-advanced-filters 46 | """ 47 | 48 | parameter_name_mask = 'mchoice_' 49 | 50 | def __init__(self, field, request, params, model, model_admin, field_path): 51 | """Extend base functionality.""" 52 | super().__init__(field, request, params, model, model_admin, field_path) 53 | self.set_selected(self.value(), self.title) 54 | if self.field.get_internal_type() in ['IntegerField']: 55 | self.selected = [int(i) for i in self.selected] 56 | self.lookup_choices = self.field.flatchoices 57 | 58 | def choices(self, changelist): 59 | """Call shared implementation.""" 60 | return Choices.choices(self, changelist) 61 | 62 | def queryset(self, request, queryset): 63 | """Return the filtered by selected options queryset.""" 64 | if self.selected: 65 | params = { 66 | "{}__in".format(self.field_path): self.selected, 67 | } 68 | return queryset.filter(**params) 69 | 70 | return queryset 71 | 72 | 73 | class FilterExt(BaseFilterSimple, Choices): 74 | """Allows filtering by custom defined properties.""" 75 | 76 | options = [] 77 | 78 | def __init__(self, request, params, model, model_admin): 79 | """Combine parents init.""" 80 | super().__init__(request, params, model, model_admin) 81 | self.set_selected(self.value(), self.title) 82 | 83 | def choices(self, changelist): 84 | """Call shared implementation.""" 85 | return Choices.choices(self, changelist) 86 | 87 | def lookups(self, request, model_admin): 88 | """Return filter choices.""" 89 | return [i[:2] for i in self.options] 90 | 91 | def queryset(self, request, queryset): 92 | """Return the filtered by selected options queryset.""" 93 | if not self.selected: 94 | return queryset 95 | 96 | filters = {i[0]: i[2] for i in self.options} 97 | qflt = filters[self.selected[0]] 98 | for item in self.selected[1:]: 99 | qflt |= filters[item] 100 | 101 | return queryset.filter(qflt) 102 | -------------------------------------------------------------------------------- /django_admin_filters/static/css/datetimepicker.css: -------------------------------------------------------------------------------- 1 | .cursorily{ cursor: pointer;} 2 | .hov:hover{ color: #000;} 3 | .ico-size{font-size: 16px;} 4 | .ico-size-month{font-size: 26px!important; line-height: 26px!important;} 5 | .ico-size-large{ font-size: 40px!important; line-height: 30px;} 6 | .dtp_main{ border: solid 1px #eee; border-radius: 3px; background-color: #fff; padding: 8px 0 8px 8px;} 7 | .dtp_main span, .dtp_main i{ display: inline-block; padding-right: 8px;} 8 | .dtp_modal-win{position: fixed;left: 0; top: 0; width: 100%; height: 100%; 9 | z-index: 999; background-color: #eeeeee; opacity: 0.6;} 10 | .dtp_modal-content{ background-color: #fff; border-radius: 10px; width: 624px; 11 | position: absolute; z-index: 1000; top: 100px; left: 100px; font-size: 16px;font-weight: normal;} 12 | .dtp_modal-content-no-time{ background-color: #fff; border-radius: 10px; width: 312px; 13 | position: absolute; z-index: 1000; top: 100px; left: 100px; font-size: 16px;font-weight: normal;} 14 | .dtp_modal-title{ border-bottom: solid 3px #54646b; padding: 16px 36px; margin-bottom: 16px; font-size: 22px; } 15 | .dtp_modal-cell-date{ width: 312px; float: right; margin-bottom: 22px; margin-top: 6px;} 16 | .dtp_modal-cell-time{width: 311px; float: left; direction: ltr; border-right: solid 1px #000;} 17 | .dtp_modal-months{ color: #7d7d7d; text-align: center; font-size: 20px; padding: 0 20px;} 18 | .dtp_modal-months span{ display: inline-block; padding: 10px 20px; width: 182px;} 19 | .dtp_modal-calendar{ width: 266px; margin-left: 22px; } 20 | .dtp_modal-calendar-cell{ width: 38px; padding: 7px 0; display: inline-block; text-align: center;} 21 | .dtp_modal-colored{ color: #54646b; } 22 | .dtp_modal-grey{ color: #7d7d7d; } 23 | .dtp_modal-cell-selected{ background-color: #54646b; border-radius: 48%; transition: background-color 1s ease-out;} 24 | .dtp_modal-time-block{ height: 212px; width: 310px; } 25 | .dpt_modal-button{ background-color: #54646b; color: #fff; font-size: 24px; padding: 8px 40px; float: left; 26 | text-align: center; display: inline-block; margin-left: 22px; cursor: pointer; border: solid 1px #fff; 27 | border-radius: 3px; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);} 28 | .dtp_modal-time-line{ text-align: center; color: #7d7d7d; font-size: 20px; padding-top: 15px; } 29 | .dtp_modal-time-mechanic{ padding-top: 16px;} 30 | .dtp_modal-append{ color: #7d7d7d; padding-left: 108px; font-weight: normal; } 31 | .dtp_modal-midle{ display: inline-block; width: 40px; } 32 | .dtp_modal-midle-dig{display: inline-block; width: 16px; text-align: center; } 33 | .dtp_modal-digits{ font-size: 50px; padding-left: 96px;} 34 | .dtp_modal-digit{ } 35 | -------------------------------------------------------------------------------- /django_admin_filters/static/js/datetimepicker.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | 'use strict'; 3 | $.fn.dateTimePicker = function (options) { 4 | 5 | var settings = $.extend({ 6 | selectData: "now", 7 | dateFormat: "YYYY-MM-DD HH:mm", 8 | showTime: true, 9 | locale: 'en', 10 | positionShift: { top: 20, left: 0}, 11 | title: "Select Date and Time", 12 | buttonTitle: "Select", 13 | allowBackwards: false 14 | }, options); 15 | moment.locale(settings.locale); 16 | var elem = this; 17 | var limitation = {"hour": 23, "minute": 59}; 18 | var mousedown = false; 19 | var timeout = 800; 20 | var selectDate = settings.selectData == "now" ? moment() : moment(settings.selectData, settings.dateFormat); 21 | if (selectDate < moment() && (settings.allowBackwards == false)) { 22 | selectDate = moment(); 23 | } 24 | var startDate = copyDate(moment()); 25 | var lastSelected = copyDate(selectDate); 26 | return this.each(function () { 27 | if (lastSelected != selectDate) { 28 | selectDate = copyDate(lastSelected); 29 | } 30 | elem.addClass("dtp_main"); 31 | updateMainElemGlobal(); 32 | // elem.text(selectDate.format(settings.dateFormat)); 33 | function updateMainElemGlobal() { 34 | var arrF = settings.dateFormat.split(' '); 35 | if (settings.showTime && arrF.length != 2) { 36 | arrF.length = 2; 37 | arrF[0] = 'DD/MM/YY'; 38 | arrF[1] = 'HH:mm'; 39 | } 40 | var $s = $(''); 41 | $s.text(lastSelected.format(arrF[0])); 42 | elem.empty(); 43 | elem.append($s); 44 | $s = $(''); 45 | $s.addClass('fa fa-calendar ico-size'); 46 | elem.append($s); 47 | if (settings.showTime) { 48 | $s = $(''); 49 | $s.text(lastSelected.format(arrF[1])); 50 | elem.append($s); 51 | $s = $(''); 52 | $s.addClass('fa fa-clock-o ico-size'); 53 | elem.append($s); 54 | } 55 | } 56 | elem.on('click', function () { 57 | var $win = $('
'); 58 | $win.addClass("dtp_modal-win"); 59 | var $body = $('body'); 60 | $body.append($win); 61 | var $content = createContent(); 62 | $body.append($content); 63 | var offset = elem.offset(); 64 | $content.css({top: (offset.top + settings.positionShift.top) + "px", left: (offset.left + settings.positionShift.left) + "px"}); 65 | feelDates(selectDate); 66 | $win.on('click', function () { 67 | $content.remove(); 68 | $win.remove(); 69 | }) 70 | if (settings.showTime) { 71 | attachChangeTime(); 72 | var $fieldTime = $('#field-time'); 73 | var $hour = $fieldTime.find('#d-hh'); 74 | var $minute = $fieldTime.find('#d-mm'); 75 | } 76 | 77 | function feelDates(selectM) { 78 | var $fDate = $content.find('#field-data'); 79 | $fDate.empty(); 80 | $fDate.append(createMonthPanel(selectM)); 81 | $fDate.append(createCalendar(selectM)); 82 | } 83 | 84 | function createCalendar(selectedMonth) { 85 | var $c = $('
'); 86 | $c.addClass('dtp_modal-calendar'); 87 | for (var i = 0; i < 7; i++) { 88 | var $e = $('
'); 89 | $e.addClass('dtp_modal-calendar-cell dtp_modal-colored'); 90 | $e.text(moment().weekday(i).format('ddd')); 91 | $c.append($e); 92 | } 93 | var m = copyDate(selectedMonth); 94 | m.date(1); 95 | // console.log(m.format('DD--MM--YYYY')); 96 | // console.log(selectData.format('DD--MM--YYYY')); 97 | // console.log(m.weekday()); 98 | var flagStart = totalMonths(selectedMonth) === totalMonths(startDate); 99 | var flagSelect = totalMonths(lastSelected) === totalMonths(selectedMonth); 100 | var cerDay = parseInt(selectedMonth.format('D')); 101 | var dayNow = parseInt(startDate.format('D')); 102 | for (var i = 0; i < 6; i++) { 103 | for (var j = 0; j < 7; j++) { 104 | var $b = $('
'); 105 | $b.html(' '); 106 | $b.addClass('dtp_modal-calendar-cell'); 107 | if (m.month() == selectedMonth.month() && m.weekday() == j) { 108 | var day = parseInt(m.format('D')); 109 | $b.text(day); 110 | if (flagStart && day < dayNow) { 111 | 112 | if (settings.allowBackwards == false) { 113 | $b.addClass('dtp_modal-grey'); 114 | } else { 115 | $b.addClass('cursorily'); 116 | $b.bind('click', changeDate); 117 | } 118 | 119 | } 120 | else if (flagSelect && day == cerDay) { 121 | $b.addClass('dtp_modal-cell-selected'); 122 | } 123 | else { 124 | $b.addClass('cursorily'); 125 | $b.bind('click', changeDate); 126 | } 127 | m.add(1, 'days'); 128 | } 129 | $c.append($b); 130 | } 131 | } 132 | return $c; 133 | } 134 | 135 | function changeDate() { 136 | 137 | var $div = $(this); 138 | selectDate.date($div.text()); 139 | lastSelected = copyDate(selectDate); 140 | updateDate(); 141 | var $fDate = $content.find('#field-data'); 142 | var old = $fDate.find('.dtp_modal-cell-selected'); 143 | old.removeClass('dtp_modal-cell-selected'); 144 | old.addClass('cursorily'); 145 | $div.addClass('dtp_modal-cell-selected'); 146 | $div.removeClass('cursorily'); 147 | old.bind('click', changeDate); 148 | $div.unbind('click'); 149 | // console.log(selectDate.format('DD-MM-YYYY')); 150 | } 151 | 152 | function createMonthPanel(selectMonth) { 153 | var $d = $('
'); 154 | $d.addClass('dtp_modal-months'); 155 | var $s = $(''); 156 | $s.addClass('fa fa-angle-left cursorily ico-size-month hov'); 157 | //$s.attr('data-fa-mask', 'fas fa-circle'); 158 | $s.bind('click', prevMonth); 159 | $d.append($s); 160 | $s = $(''); 161 | $s.text(selectMonth.format("MMMM YYYY")); 162 | $d.append($s); 163 | $s = $(''); 164 | $s.addClass('fa fa-angle-right cursorily ico-size-month hov'); 165 | $s.bind('click', nextMonth); 166 | $d.append($s); 167 | return $d; 168 | } 169 | 170 | function close() { 171 | if (settings.showTime) { 172 | lastSelected.hour(parseInt($hour.text())); 173 | lastSelected.minute(parseInt($minute.text())); 174 | selectDate.hour(parseInt($hour.text())); 175 | selectDate.minute(parseInt($minute.text())); 176 | } 177 | updateDate(); 178 | $content.remove(); 179 | $win.remove(); 180 | } 181 | 182 | function nextMonth() { 183 | selectDate.add(1, 'month'); 184 | feelDates(selectDate); 185 | } 186 | 187 | function prevMonth() { 188 | if (totalMonths(selectDate) > totalMonths(startDate) || settings.allowBackwards == true) { 189 | selectDate.add(-1, 'month'); 190 | feelDates(selectDate); 191 | } 192 | } 193 | 194 | function attachChangeTime() { 195 | var $angles = $($content).find('i[id^="angle-"]'); 196 | // $angles.bind('click', changeTime); 197 | $angles.bind('mouseup', function () { 198 | mousedown = false; 199 | timeout = 800; 200 | }); 201 | $angles.bind('mousedown', function () { 202 | mousedown = true; 203 | changeTime(this); 204 | }); 205 | } 206 | 207 | function changeTime(el) { 208 | var $el = this || el; 209 | $el = $($el); 210 | ///angle-up-hour angle-up-minute angle-down-hour angle-down-minute 211 | var arr = $el.attr('id').split('-'); 212 | var increment = 1; 213 | if (arr[1] == 'down') { 214 | increment = -1; 215 | } 216 | appendIncrement(arr[2], increment); 217 | setTimeout(function () { 218 | autoIncrement($el); 219 | }, timeout); 220 | } 221 | 222 | function autoIncrement(el) { 223 | if (mousedown) { 224 | if (timeout > 200) { 225 | timeout -= 200; 226 | } 227 | changeTime(el); 228 | } 229 | } 230 | 231 | function appendIncrement(typeDigits, increment) { 232 | 233 | var $i = typeDigits == "hour" ? $hour : $minute; 234 | var val = parseInt($i.text()) + increment; 235 | if (val < 0) { 236 | val = limitation[typeDigits]; 237 | } 238 | else if (val > limitation[typeDigits]) { 239 | val = 0; 240 | } 241 | $i.text(formatDigits(val)); 242 | } 243 | 244 | function formatDigits(val) { 245 | 246 | if (val < 10) { 247 | return '0' + val; 248 | } 249 | return val; 250 | } 251 | 252 | function createTimer() { 253 | var $div = $('
'); 254 | $div.addClass('dtp_modal-time-mechanic'); 255 | var $panel = $('
'); 256 | $panel.addClass('dtp_modal-append'); 257 | var $i = $(''); 258 | $i.attr('id', 'angle-up-hour'); 259 | $i.addClass('fa fa-angle-up ico-size-large cursorily hov'); 260 | $panel.append($i); 261 | var $m = $(''); 262 | $m.addClass('dtp_modal-midle'); 263 | $panel.append($m); 264 | $i = $(''); 265 | $i.attr('id', 'angle-up-minute'); 266 | $i.addClass('fa fa-angle-up ico-size-large cursorily hov'); 267 | $panel.append($i); 268 | $div.append($panel); 269 | 270 | $panel = $('
'); 271 | $panel.addClass('dtp_modal-digits'); 272 | var $d = $(''); 273 | $d.addClass('dtp_modal-digit'); 274 | $d.attr('id', 'd-hh'); 275 | $d.text(lastSelected.format('HH')); 276 | $panel.append($d); 277 | $m = $(''); 278 | $m.addClass('dtp_modal-midle-dig'); 279 | $m.html(':'); 280 | $panel.append($m); 281 | $d = $(''); 282 | $d.addClass('dtp_modal-digit'); 283 | $d.attr('id', 'd-mm'); 284 | $d.text(lastSelected.format('mm')); 285 | $panel.append($d); 286 | $div.append($panel); 287 | 288 | $panel = $('
'); 289 | $panel.addClass('dtp_modal-append'); 290 | $i = $(''); 291 | $i.attr('id', 'angle-down-hour'); 292 | $i.addClass('fa fa-angle-down ico-size-large cursorily hov'); 293 | $panel.append($i); 294 | $m = $(''); 295 | $m.addClass('dtp_modal-midle'); 296 | $panel.append($m); 297 | $i = $(''); 298 | $i.attr('id', 'angle-down-minute'); 299 | $i.addClass('fa fa-angle-down ico-size-large cursorily hov'); 300 | $panel.append($i); 301 | $div.append($panel); 302 | return $div; 303 | } 304 | 305 | function createContent() { 306 | var $c = $('
'); 307 | if (settings.showTime) { 308 | $c.addClass("dtp_modal-content"); 309 | } 310 | else { 311 | $c.addClass("dtp_modal-content-no-time"); 312 | } 313 | var $el = $('
'); 314 | $el.addClass("dtp_modal-title"); 315 | $el.text(settings.title); 316 | $c.append($el); 317 | $el = $('
'); 318 | $el.addClass('dtp_modal-cell-date'); 319 | $el.attr('id', 'field-data'); 320 | $c.append($el); 321 | if (settings.showTime) { 322 | $el = $('
'); 323 | $el.addClass('dtp_modal-cell-time'); 324 | var $a = $('
'); 325 | $a.addClass('dtp_modal-time-block'); 326 | $a.attr('id', 'field-time'); 327 | $el.append($a); 328 | var $line = $('
'); 329 | $line.attr('id', 'time-line'); 330 | $line.addClass('dtp_modal-time-line'); 331 | $line.text(lastSelected.format(settings.dateFormat)); 332 | 333 | $a.append($line); 334 | $a.append(createTimer()); 335 | var $but = $('
'); 336 | $but.addClass('dpt_modal-button'); 337 | $but.text(settings.buttonTitle); 338 | $but.bind('click', close); 339 | $el.append($but); 340 | $c.append($el); 341 | } 342 | return $c; 343 | } 344 | function updateDate() { 345 | if (settings.showTime) { 346 | $('#time-line').text(lastSelected.format(settings.dateFormat)); 347 | } 348 | updateMainElem(); 349 | elem.next().val(selectDate.format(settings.dateFormat)); 350 | if (!settings.showTime) { 351 | $content.remove(); 352 | $win.remove(); 353 | } 354 | } 355 | 356 | function updateMainElem() { 357 | var arrF = settings.dateFormat.split(' '); 358 | if (settings.showTime && arrF.length != 2) { 359 | arrF.length = 2; 360 | arrF[0] = 'DD/MM/YY'; 361 | arrF[1] = 'HH:mm'; 362 | } 363 | var $s = $(''); 364 | $s.text(lastSelected.format(arrF[0])); 365 | elem.empty(); 366 | elem.append($s); 367 | $s = $(''); 368 | $s.addClass('fa fa-calendar ico-size'); 369 | elem.append($s); 370 | if (settings.showTime) { 371 | $s = $(''); 372 | $s.text(lastSelected.format(arrF[1])); 373 | elem.append($s); 374 | $s = $(''); 375 | $s.addClass('fa fa-clock-o ico-size'); 376 | elem.append($s); 377 | } 378 | } 379 | 380 | }); 381 | 382 | }); 383 | 384 | }; 385 | 386 | function copyDate(d) { 387 | return moment(d.toDate()); 388 | } 389 | 390 | function totalMonths(m) { 391 | var r = m.format('YYYY') * 12 + parseInt(m.format('MM')); 392 | return r; 393 | } 394 | 395 | }(jQuery)); 396 | // fa-caret-down -------------------------------------------------------------------------------- /django_admin_filters/templates/base_admin_list_filter.html: -------------------------------------------------------------------------------- 1 |
2 | {{ title.filter_name }} 3 |
    4 | {% block choices_list %} 5 | {% endblock %} 6 |
7 | {% block after_choices %} 8 | {% endblock %} 9 | 10 |
11 | 12 | {% block javascript_code %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /django_admin_filters/templates/daterange.html: -------------------------------------------------------------------------------- 1 | {% extends "./base_admin_list_filter.html" %} 2 | 3 | {% block choices_list %} 4 | 5 | {% for choice in choices %} 6 | 7 | {{ choice.display }} 8 | {% endfor %} 9 | 10 | {% endblock %} 11 | 12 | {% block after_choices %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
{{ title.date_format }}
{{ title.start_label }}
{{ title.end_label }}
34 | 35 | {% endblock %} 36 | 37 | {% block javascript_code %} 38 | 39 | 50 | 51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /django_admin_filters/templates/daterange_picker.html: -------------------------------------------------------------------------------- 1 | {% extends "./daterange.html" %} 2 | {% load static %} 3 | 4 | {% block after_choices %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 26 |
{{ title.start_label }} 12 |
13 | 14 |
{{ title.end_label }} 20 |
21 | 22 |
27 | 28 | {% endblock %} 29 | 30 | {% block javascript_code %} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 72 | 73 | {% endblock %} 74 | -------------------------------------------------------------------------------- /django_admin_filters/templates/multi_choice.html: -------------------------------------------------------------------------------- 1 | {% extends "./base_admin_list_filter.html" %} 2 | 3 | {% block choices_list %} 4 | 5 | {% for choice in choices %} 6 |
  • 7 | 8 | 9 |
  • 10 | {% endfor %} 11 | 12 | {% endblock %} 13 | 14 | {% block javascript_code %} 15 | 16 | 32 | 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/example/__init__.py -------------------------------------------------------------------------------- /example/admin.py: -------------------------------------------------------------------------------- 1 | """Admin site.""" 2 | from django.contrib import admin 3 | from django.db.models import Q 4 | from django_admin_filters import DateRange, DateRangePicker, MultiChoice, MultiChoiceExt 5 | from .models import Log 6 | 7 | 8 | class StatusFilter(MultiChoice): 9 | """Field status filter.""" 10 | 11 | FILTER_LABEL = "By status" 12 | 13 | 14 | class NumberFilter(MultiChoice): 15 | """Field number filter.""" 16 | 17 | FILTER_LABEL = "By number" 18 | 19 | 20 | class ColorFilter(MultiChoiceExt): 21 | """Property color filter.""" 22 | 23 | parameter_name = "color" 24 | FILTER_LABEL = "By color" 25 | 26 | # https://docs.djangoproject.com/en/4.1/topics/db/queries/#complex-lookups-with-q-objects 27 | options = [ 28 | ('red', 'Red', Q(is_online=False)), 29 | ('yellow', 'Yellow', Q(is_online=True) & (Q(is_trouble1=True) | Q(is_trouble2=True))), 30 | ('green', 'Green', Q(is_online=True) & Q(is_trouble1=False) & Q(is_trouble2=False)), 31 | ] 32 | 33 | 34 | class Timestamp1Filter(DateRange): 35 | """Field timestamp1 filter.""" 36 | 37 | FILTER_LABEL = "By timestamp1" 38 | parameter_start_mask = "{}_gte" 39 | parameter_end_mask = "{}_lte" 40 | 41 | 42 | class Timestamp2Filter(DateRangePicker): 43 | """Field timestamp2 filter.""" 44 | 45 | FILTER_LABEL = "By timestamp2" 46 | 47 | 48 | class Admin(admin.ModelAdmin): 49 | """Admin site customization.""" 50 | 51 | list_display = [ 52 | 'text', 53 | 'status', 54 | 'timestamp1', 'timestamp2', 55 | 'is_online', 'is_trouble1', 'is_trouble2', 'color' 56 | ] 57 | list_filter = ( 58 | ('status', StatusFilter), 59 | ('timestamp1', Timestamp1Filter), 60 | ('timestamp2', Timestamp2Filter), 61 | ('number', NumberFilter), 62 | ColorFilter 63 | ) 64 | 65 | 66 | admin.site.register(Log, Admin) 67 | -------------------------------------------------------------------------------- /example/models.py: -------------------------------------------------------------------------------- 1 | """Models definition.""" 2 | from django.db import models 3 | 4 | STATUS_CHOICES = ( 5 | ('P', 'Pending'), 6 | ('A', 'Approved'), 7 | ('R', 'Rejected'), 8 | ) 9 | 10 | NUM_CHOICES = ( 11 | (0, 'Zero'), 12 | (1, 'One'), 13 | (2, 'Two'), 14 | ) 15 | 16 | 17 | class Log(models.Model): 18 | """Log entry with timestamp.""" 19 | 20 | text = models.CharField(max_length=100) 21 | timestamp1 = models.DateTimeField(default=None, null=True) 22 | timestamp2 = models.DateTimeField(default=None, null=True) 23 | status = models.CharField(max_length=1, default='P', choices=STATUS_CHOICES) 24 | number = models.IntegerField(default=0, choices=NUM_CHOICES) 25 | is_online = models.BooleanField(default=False) 26 | is_trouble1 = models.BooleanField(default=False) 27 | is_trouble2 = models.BooleanField(default=False) 28 | 29 | @property 30 | def color(self): 31 | """Color for object state.""" 32 | status = 'red' 33 | if self.is_online: 34 | status = 'green' 35 | if self.is_trouble1 or self.is_trouble2: 36 | status = 'yellow' 37 | 38 | return status 39 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings.""" 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | import os 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | # SECURITY WARNING: keep the secret key used in production secret! 9 | SECRET_KEY = 'XXX' 10 | 11 | # SECURITY WARNING: don't run with debug turned on in production! 12 | DEBUG = True 13 | ALLOWED_HOSTS = ['*'] 14 | DEFAULT_AUTO_FIELD='django.db.models.AutoField' 15 | 16 | # Application definition 17 | INSTALLED_APPS = ( 18 | 'django.contrib.admin', 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.sessions', 22 | 'django.contrib.messages', 23 | 'django.contrib.staticfiles', 24 | 'django_admin_filters', 25 | 'example', 26 | ) 27 | 28 | MIDDLEWARE = [ 29 | 'django.middleware.security.SecurityMiddleware', 30 | 'django.contrib.sessions.middleware.SessionMiddleware', 31 | 'django.middleware.common.CommonMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.contrib.messages.middleware.MessageMiddleware', 35 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 36 | 'django.middleware.locale.LocaleMiddleware', 37 | ] 38 | 39 | ROOT_URLCONF = 'example.urls' 40 | 41 | TEMPLATES = [ 42 | { 43 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 44 | 'APP_DIRS': True, 45 | 'OPTIONS': { 46 | 'context_processors': [ 47 | 'django.template.context_processors.debug', 48 | 'django.template.context_processors.request', 49 | 'django.contrib.auth.context_processors.auth', 50 | 'django.contrib.messages.context_processors.messages', 51 | ], 52 | }, 53 | }, 54 | ] 55 | 56 | WSGI_APPLICATION = 'example.wsgi.application' 57 | 58 | # Database 59 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 60 | 61 | DATABASES = { 62 | 'default': { 63 | 'ENGINE': 'django.db.backends.sqlite3', 64 | 'NAME': os.path.join(BASE_DIR, 'example', 'example.sqlite3'), 65 | } 66 | } 67 | 68 | # Internationalization 69 | LANGUAGE_CODE = 'en-us' 70 | TIME_ZONE = 'UTC' 71 | USE_I18N = False 72 | USE_TZ = False 73 | 74 | STATIC_URL = '/static/' 75 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') 76 | 77 | STATICFILES_FINDERS = ( 78 | 'django.contrib.staticfiles.finders.FileSystemFinder', 79 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 80 | ) 81 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | """Django url router.""" 2 | from django.urls import path 3 | from django.contrib import admin 4 | from .views import home 5 | 6 | urlpatterns = [ # pylint: disable=invalid-name 7 | path('', home, name='home'), 8 | path('admin/', admin.site.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | """Django url views.""" 2 | from django.http import HttpResponse 3 | 4 | 5 | def home(request): 6 | """Main page.""" 7 | return HttpResponse("Pls, go to admin section.") 8 | -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for gpoint project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 6 | """ 7 | import os 8 | from django.core.wsgi import get_wsgi_application 9 | 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 11 | application = get_wsgi_application() # pylint: disable=invalid-name 12 | -------------------------------------------------------------------------------- /history.txt: -------------------------------------------------------------------------------- 1 | 13.02.2025 ver.1.3.1 2 | -------------------- 3 | 4 | - Fix daterange filter url parameter name. 5 | 6 | 12.02.2025 ver.1.3 7 | ------------------ 8 | 9 | + Custom url parameters names. 10 | 11 | * Removed Python 3.7 support. 12 | 13 | * Added Python 3.13 support for Django5. 14 | 15 | 20.02.2024 ver.1.2 16 | ------------------ 17 | 18 | + Django5 support 19 | 20 | 11.10.2022 ver.1.1 21 | ------------------ 22 | 23 | + MultiChoice: multi choice selection with checkboxes for CharField and IntegerField fields with 'choices' option. 24 | 25 | + MultiChoiceExt: another version of previous filter, that allows filtering by custom defined properties. 26 | 27 | 13.09.2022 ver.1.0 28 | ------------------ 29 | 30 | + Initial release. 31 | -------------------------------------------------------------------------------- /img/color_filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/color_filter.png -------------------------------------------------------------------------------- /img/daterange_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/daterange_en.png -------------------------------------------------------------------------------- /img/daterange_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/daterange_ru.png -------------------------------------------------------------------------------- /img/multi_choice_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/multi_choice_en.png -------------------------------------------------------------------------------- /img/picker_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/picker_en.png -------------------------------------------------------------------------------- /img/picker_ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/img/picker_ru.png -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all setup static db example superuser 2 | # make tests >debug.log 2>&1 3 | ifeq ($(OS),Windows_NT) 4 | PYTHON = venv/Scripts/python.exe 5 | PTEST = venv/Scripts/pytest.exe 6 | COVERAGE = venv/Scripts/coverage.exe 7 | else 8 | PYTHON = ./venv/bin/python 9 | PTEST = ./venv/bin/pytest 10 | COVERAGE = ./venv/bin/coverage 11 | endif 12 | 13 | DJANGO_VER = 5 14 | 15 | SOURCE = django_admin_filters 16 | TESTS = tests 17 | CFG_TEST = example.settings 18 | 19 | FLAKE8 = $(PYTHON) -m flake8 --max-line-length=120 20 | PYLINT = $(PYTHON) -m pylint 21 | PYTEST = $(PTEST) -c pytest$(DJANGO_VER).ini --cov=$(SOURCE) --cov-report term:skip-covered 22 | MANAGE = $(PYTHON) manage.py 23 | PIP = $(PYTHON) -m pip install 24 | SETTINGS = --settings $(CFG_TEST) 25 | MIGRATE = $(MANAGE) makemigrations $(SETTINGS) 26 | 27 | all: tests 28 | 29 | test: 30 | $(PTEST) -s $(TESTS)/test/$(T) 31 | 32 | flake8: 33 | $(FLAKE8) $(SOURCE) 34 | $(FLAKE8) $(TESTS)/test 35 | 36 | lint: 37 | $(PYLINT) $(TESTS)/test 38 | $(PYLINT) $(SOURCE) 39 | 40 | pep257: 41 | $(PYTHON) -m pydocstyle $(SOURCE) 42 | $(PYTHON) -m pydocstyle --match='.*\.py' $(TESTS)/test 43 | 44 | tests: flake8 pep257 lint static db 45 | $(PYTEST) --durations=5 $(TESTS) 46 | $(COVERAGE) html --skip-covered 47 | 48 | example: 49 | $(MANAGE) runserver $(SETTINGS) 50 | 51 | superuser: 52 | $(MANAGE) createsuperuser $(SETTINGS) 53 | 54 | db: 55 | $(MIGRATE) example 56 | $(MANAGE) migrate $(SETTINGS) 57 | 58 | static: 59 | $(MANAGE) collectstatic --noinput $(SETTINGS) 60 | 61 | package: 62 | $(PYTHON) -m build -n 63 | 64 | pypitest: package 65 | $(PYTHON) -m twine upload --config-file .pypirc --repository testpypi dist/* 66 | 67 | pypi: package 68 | $(PYTHON) -m twine upload --config-file .pypirc dist/* 69 | 70 | setup: setup_python setup_pip 71 | 72 | setup_pip: 73 | $(PIP) --upgrade pip 74 | $(PIP) -r $(TESTS)/requirements.txt 75 | $(PIP) -r $(TESTS)/django$(DJANGO_VER).txt 76 | $(PIP) -r deploy.txt 77 | 78 | setup_python: 79 | $(PYTHON_BIN) -m venv ./venv 80 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | cur_dir = os.getcwd() 7 | sys.path.insert(1, cur_dir) 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment?" 21 | ) 22 | raise 23 | execute_from_command_line(sys.argv) 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /pytest3.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::django.utils.deprecation.RemovedInDjango40Warning 4 | -------------------------------------------------------------------------------- /pytest4.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::django.utils.deprecation.RemovedInDjango50Warning 4 | -------------------------------------------------------------------------------- /pytest5.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore::django.utils.deprecation.RemovedInDjango60Warning 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-admin-list-filters 3 | version = 1.3.1 4 | author = Vitaly Bogomolov 5 | author_email = mail@vitaly-bogomolov.ru 6 | description = Embedded filters for Django Admin site 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/vb64/django.admin.filters 10 | project_urls = 11 | Bug Tracker = https://github.com/vb64/django.admin.filters/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | package_dir = 19 | packages = django_admin_filters 20 | python_requires = >=3.7 21 | include_package_data=True 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vb64/django.admin.filters/e9d6feedab90eb284b7f0811b95006a1e10fa79c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest session setup.""" 2 | import sys 3 | import os 4 | import pytest 5 | import django 6 | 7 | 8 | def path_setup(): 9 | """Setup sys.path.""" 10 | test_dir = os.path.dirname(os.path.abspath(__file__)) 11 | sys.path.insert(1, test_dir) 12 | 13 | 14 | @pytest.fixture(scope="session", autouse=True) 15 | def session_setup(request): 16 | """Auto session resource fixture.""" 17 | path_setup() 18 | os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' 19 | django.setup() 20 | -------------------------------------------------------------------------------- /tests/django3.txt: -------------------------------------------------------------------------------- 1 | Django==3.2.25 2 | -------------------------------------------------------------------------------- /tests/django4.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.19 2 | -------------------------------------------------------------------------------- /tests/django5.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.6 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pylint 3 | pylint-django 4 | pydocstyle 5 | pytest 6 | codacy-coverage 7 | pytest-cov 8 | -------------------------------------------------------------------------------- /tests/test/__init__.py: -------------------------------------------------------------------------------- 1 | """Root class for testing.""" 2 | from django.test import TestCase, Client, RequestFactory 3 | from django.contrib.admin import site 4 | from django.urls import reverse 5 | 6 | 7 | class TestBase(TestCase): 8 | """Base class for tests.""" 9 | 10 | request_factory = RequestFactory() 11 | 12 | def setUp(self): 13 | """Set up Django client.""" 14 | super().setUp() 15 | self.client = Client() 16 | self.admin_pass = 'password' 17 | 18 | from django.contrib.auth import get_user_model 19 | 20 | self.admin = get_user_model().objects.create_superuser( 21 | username='superuser', 22 | email='mail@example.com', 23 | password=self.admin_pass 24 | ) 25 | 26 | from example.admin import Admin 27 | from example.models import Log 28 | 29 | self.modeladmin = Admin(Log, site) 30 | self.url = reverse('admin:example_log_changelist') 31 | self.queryset = Log.objects.all() 32 | 33 | def admin_get(self, params): 34 | """Get request from admin.""" 35 | request = self.request_factory.get(self.url, params) 36 | request.user = self.admin 37 | return request 38 | 39 | def login_admin(self): 40 | """Login as admin.""" 41 | self.client.login(username=self.admin.username, password=self.admin_pass) 42 | -------------------------------------------------------------------------------- /tests/test/test_base.py: -------------------------------------------------------------------------------- 1 | """Base filter tests. 2 | 3 | make test T=test_base.py 4 | """ 5 | import pytest 6 | from . import TestBase 7 | 8 | 9 | class TestsBaseFilter(TestBase): 10 | """BaseFilter filter tests.""" 11 | 12 | def test_choices(self): 13 | """Check 'choices' method.""" 14 | from django_admin_filters.base import Filter 15 | 16 | with pytest.raises(NotImplementedError) as err: 17 | Filter.choices(None, None) 18 | assert 'choices' in str(err.value) 19 | 20 | def test_lookups(self): 21 | """Check 'lookups' method.""" 22 | from django_admin_filters.base import FilterSimple 23 | 24 | with pytest.raises(NotImplementedError) as err: 25 | FilterSimple.lookups(None, None, None) 26 | assert 'lookups' in str(err.value) 27 | 28 | def test_queryset(self): 29 | """Check 'queryset' method.""" 30 | from django_admin_filters.base import FilterSimple 31 | 32 | with pytest.raises(NotImplementedError) as err: 33 | FilterSimple.queryset(None, None, None) 34 | assert 'queryset' in str(err.value) 35 | -------------------------------------------------------------------------------- /tests/test/test_daterange.py: -------------------------------------------------------------------------------- 1 | """Daterange filter tests. 2 | 3 | make test T=test_daterange.py 4 | """ 5 | from datetime import datetime 6 | from . import TestBase 7 | 8 | 9 | class TestsDaterange(TestBase): 10 | """Daterange filter tests. 11 | 12 | https://github.com/django/django/blob/main/tests/admin_filters/tests.py 13 | """ 14 | 15 | def setUp(self): 16 | """Set up Daterange filter tests.""" 17 | super().setUp() 18 | from django_admin_filters import DateRange 19 | from example.models import Log 20 | 21 | self.log = Log(text="text1") 22 | self.log.save() 23 | self.field_path = 'timestamp1' 24 | self.pname = DateRange.parameter_name_mask + self.field_path 25 | 26 | @staticmethod 27 | def test_to_dtime(): 28 | """Method to_dtime.""" 29 | from django_admin_filters import DateRange 30 | 31 | assert DateRange.to_dtime('xxx') is None 32 | assert DateRange.to_dtime('2022-09-01 00:00') == datetime(2022, 9, 1) 33 | 34 | def test_is_null_option(self): 35 | """Filter with is_null_option option.""" 36 | request = self.admin_get({}) 37 | changelist = self.modeladmin.get_changelist_instance(request) 38 | 39 | flt = changelist.get_filters(request)[0][1] 40 | 41 | flt.is_null_option = True 42 | assert len(list(flt.choices(changelist))) == 5 43 | flt.is_null_option = False 44 | assert len(list(flt.choices(changelist))) == 4 45 | 46 | def test_queryset_null(self): 47 | """Filter queryset null option.""" 48 | from django_admin_filters import DateRange 49 | 50 | request = self.admin_get({self.pname: DateRange.option_null}) 51 | changelist = self.modeladmin.get_changelist_instance(request) 52 | flt_null = changelist.get_filters(request)[0][0] 53 | flt_null.is_null_option = True 54 | assert flt_null.queryset(request, self.queryset) 55 | 56 | def test_queryset_option(self): 57 | """Filter queryset shortcut option.""" 58 | from example import admin 59 | 60 | admin.DateRange.options = ( 61 | ('1h', "1 hour", 60 * 60), 62 | ) 63 | request = self.admin_get({self.pname: '1h'}) 64 | 65 | changelist = self.modeladmin.get_changelist_instance(request) 66 | flt_future = changelist.get_filters(request)[0][1] 67 | assert not flt_future.queryset(request, self.queryset) 68 | 69 | admin.DateRange.options = ( 70 | ('1h', "-1 hour", -60 * 60), 71 | ) 72 | changelist = self.modeladmin.get_changelist_instance(request) 73 | flt_past = changelist.get_filters(request)[0][1] 74 | assert flt_past.queryset(request, self.queryset) is not None 75 | 76 | def test_queryset_custom(self): 77 | """Filter queryset custom option.""" 78 | from example import admin 79 | 80 | request = self.admin_get({ 81 | self.pname: admin.Timestamp1Filter.option_custom, 82 | admin.Timestamp1Filter.parameter_start_mask.format(self.field_path): '2022-01-01 00:00', 83 | admin.Timestamp1Filter.parameter_end_mask.format(self.field_path): '2022-01-02 00:00', 84 | }) 85 | 86 | changelist = self.modeladmin.get_changelist_instance(request) 87 | 88 | flt_custom = changelist.get_filters(request)[0][1] 89 | assert not flt_custom.queryset(request, self.queryset) 90 | 91 | def test_queryset_custom_wrong(self): 92 | """Filter queryset wrong custom option.""" 93 | from example import admin 94 | 95 | request = self.admin_get({ 96 | admin.Timestamp1Filter.parameter_start_mask.format(self.field_path): 'xxx', 97 | admin.Timestamp1Filter.parameter_end_mask.format(self.field_path): 'xxx', 98 | self.pname: admin.Timestamp1Filter.option_custom, 99 | }) 100 | 101 | changelist = self.modeladmin.get_changelist_instance(request) 102 | flt = changelist.get_filters(request)[0][0] 103 | assert flt.queryset(request, self.queryset) 104 | 105 | def test_queryset_custom_empty(self): 106 | """Filter queryset empty custom option.""" 107 | from example import admin 108 | 109 | request = self.admin_get({self.pname: admin.DateRange.option_custom}) 110 | changelist = self.modeladmin.get_changelist_instance(request) 111 | flt = changelist.get_filters(request)[0][0] 112 | assert flt.queryset(request, self.queryset) 113 | -------------------------------------------------------------------------------- /tests/test/test_multi_choice.py: -------------------------------------------------------------------------------- 1 | """Multi choice filter tests. 2 | 3 | make test T=test_multi_choice.py 4 | """ 5 | from . import TestBase 6 | 7 | 8 | class TestsMultiChoice(TestBase): 9 | """MultiChoice filter tests.""" 10 | 11 | def setUp(self): 12 | """Set up MultiChoice filter tests.""" 13 | super().setUp() 14 | 15 | from django_admin_filters import MultiChoice 16 | self.field_path = 'status' 17 | self.pname = MultiChoice.parameter_name_mask + self.field_path 18 | 19 | def test_queryset(self): 20 | """Filter queryset with checkbox set.""" 21 | from example.models import STATUS_CHOICES 22 | 23 | request = self.admin_get({ 24 | self.pname: STATUS_CHOICES[0][0], 25 | }) 26 | 27 | changelist = self.modeladmin.get_changelist_instance(request) 28 | 29 | flt_choice = changelist.get_filters(request)[0][0] 30 | assert flt_choice.queryset(request, self.queryset) is not None 31 | assert not flt_choice.get_facet_counts(None, None) 32 | 33 | def test_queryset_ext(self): 34 | """Filter queryset with MultiChoiceExt.""" 35 | from example.admin import ColorFilter 36 | 37 | pname = ColorFilter.parameter_name 38 | request = self.admin_get({pname: 'green' + ColorFilter.CHOICES_SEPARATOR + 'red'}) 39 | 40 | changelist = self.modeladmin.get_changelist_instance(request) 41 | flt_color = changelist.get_filters(request)[0][4] 42 | assert flt_color.queryset(request, self.queryset) is not None 43 | -------------------------------------------------------------------------------- /tests/test/test_urls.py: -------------------------------------------------------------------------------- 1 | """Test urls. 2 | 3 | make test T=test_urls.py 4 | """ 5 | from django.urls import reverse 6 | from . import TestBase 7 | 8 | 9 | class TestsUrls(TestBase): 10 | """Url tests. 11 | 12 | https://docs.djangoproject.com/en/3.2/ref/contrib/admin/#admin-reverse-urls 13 | """ 14 | 15 | def test_home(self): 16 | """Root page.""" 17 | response = self.client.get(reverse('home')) 18 | assert response.status_code == 200 19 | 20 | def test_admin_views(self): 21 | """Admin view pages.""" 22 | self.login_admin() 23 | 24 | response = self.client.get(reverse('admin:example_log_changelist')) 25 | assert response.status_code == 200 26 | 27 | response = self.client.get(reverse('admin:example_log_add')) 28 | assert response.status_code == 200 29 | --------------------------------------------------------------------------------