Merge pull request #165 from jazzband/jazzband-update

Jazzband updates for issue #164
This commit is contained in:
Corey Oordt 2021-12-23 18:34:35 -06:00 committed by GitHub
commit 0f09d7d22b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
128 changed files with 4134 additions and 2551 deletions

23
.editorconfig Normal file
View file

@ -0,0 +1,23 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{py,rst,ini}]
indent_style = space
indent_size = 4
[*.{html,css,scss,json,yml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

311
.gitchangelog.rc Normal file
View file

@ -0,0 +1,311 @@
# -*- coding: utf-8; mode: python -*-
#
# Format
#
# ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...]
#
# Description
#
# ACTION is one of 'chg', 'fix', 'new'
#
# Is WHAT the change is about.
#
# 'chg' is for refactor, small improvement, cosmetic changes...
# 'fix' is for bug fixes
# 'new' is for new features, big improvement
#
# AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc'
#
# Is WHO is concerned by the change.
#
# 'dev' is for developpers (API changes, refactors...)
# 'usr' is for final users (UI changes)
# 'pkg' is for packagers (packaging changes)
# 'test' is for testers (test only related changes)
# 'doc' is for doc guys (doc only changes)
#
# COMMIT_MSG is ... well ... the commit message itself.
#
# TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic'
#
# They are preceded with a '!' or a '@' (prefer the former, as the
# latter is wrongly interpreted in github.) Commonly used tags are:
#
# 'refactor' is obviously for refactoring code only
# 'minor' is for a very meaningless change (a typo, adding a comment)
# 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...)
# 'wip' is for partial functionality but complete subfunctionality.
#
# Example:
#
# new: usr: support of bazaar implemented
# chg: re-indentend some lines !cosmetic
# new: dev: updated code to be compatible with last version of killer lib.
# fix: pkg: updated year of licence coverage.
# new: test: added a bunch of test around user usability of feature X.
# fix: typo in spelling my name in comment. !minor
#
# Please note that multi-line commit message are supported, and only the
# first line will be considered as the "summary" of the commit message. So
# tags, and other rules only applies to the summary. The body of the commit
# message will be displayed in the changelog without reformatting.
#
# ``ignore_regexps`` is a line of regexps
#
# Any commit having its full commit message matching any regexp listed here
# will be ignored and won't be reported in the changelog.
#
ignore_regexps = [
r'@minor', r'!minor',
r'@cosmetic', r'!cosmetic',
r'@refactor', r'!refactor',
r'@wip', r'!wip',
r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:',
r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:',
r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$',
r'^Initial Commit.$',
r'^Version updated.+$',
r'^$', # ignore commits with empty messages
r'^Merge branch .+',
r'^Merge pull .+',
]
# ``section_regexps`` is a list of 2-tuples associating a string label and a
# list of regexp
#
# Commit messages will be classified in sections thanks to this. Section
# titles are the label, and a commit is classified under this section if any
# of the regexps associated is matching.
#
# Please note that ``section_regexps`` will only classify commits and won't
# make any changes to the contents. So you'll probably want to go check
# ``subject_process`` (or ``body_process``) to do some changes to the subject,
# whenever you are tweaking this variable.
#
section_regexps = [
('New', [
r'^\[?[nN][eE][wW]\]?\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
r'^\[?[aA][dD][dD]\]?\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Updates', [
r'^\[?[uU][pP][dD][aA][tT][eE]\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
r'^\[?[cC][hH][aA][nN][gG][eE][dD]?\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Fix', [
r'^\[?[fF][iI][xX]\]?\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$',
]),
('Other', None # Match all lines
),
]
# ``body_process`` is a callable
#
# This callable will be given the original body and result will
# be used in the changelog.
#
# Available constructs are:
#
# - any python callable that take one txt argument and return txt argument.
#
# - ReSub(pattern, replacement): will apply regexp substitution.
#
# - Indent(chars=" "): will indent the text with the prefix
# Please remember that template engines gets also to modify the text and
# will usually indent themselves the text if needed.
#
# - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns
#
# - noop: do nothing
#
# - ucfirst: ensure the first letter is uppercase.
# (usually used in the ``subject_process`` pipeline)
#
# - final_dot: ensure text finishes with a dot
# (usually used in the ``subject_process`` pipeline)
#
# - strip: remove any spaces before or after the content of the string
#
# - SetIfEmpty(msg="No commit message."): will set the text to
# whatever given ``msg`` if the current text is empty.
#
# Additionally, you can `pipe` the provided filters, for instance:
# body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ")
# body_process = Wrap(regexp=r'\n(?=\w+\s*:)')
# body_process = noop
body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip
# ``subject_process`` is a callable
#
# This callable will be given the original subject and result will
# be used in the changelog.
#
# Available constructs are those listed in ``body_process`` doc.
subject_process = (strip |
ReSub(r'^(\[\w+\])\s*:?\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') |
SetIfEmpty("No commit message.") | ucfirst | final_dot)
# ``tag_filter_regexp`` is a regexp
#
# Tags that will be used for the changelog must match this regexp.
#
tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$'
# ``unreleased_version_label`` is a string or a callable that outputs a string
#
# This label will be used as the changelog Title of the last set of changes
# between last valid tag and HEAD if any.
def unreleased_date():
import datetime
return 'Unreleased ({})'.format(datetime.datetime.now().strftime('%Y-%m-%d'))
unreleased_version_label = unreleased_date
# ``output_engine`` is a callable
#
# This will change the output format of the generated changelog file
#
# Available choices are:
#
# - rest_py
#
# Legacy pure python engine, outputs ReSTructured text.
# This is the default.
#
# - mustache(<template_name>)
#
# Template name could be any of the available templates in
# ``templates/mustache/*.tpl``.
# Requires python package ``pystache``.
# Examples:
# - mustache("markdown")
# - mustache("restructuredtext")
#
# - makotemplate(<template_name>)
#
# Template name could be any of the available templates in
# ``templates/mako/*.tpl``.
# Requires python package ``mako``.
# Examples:
# - makotemplate("restructuredtext")
#
# output_engine = rest_py
# output_engine = mustache("restructuredtext")
output_engine = mustache("markdown")
# output_engine = makotemplate("restructuredtext")
# ``include_merge`` is a boolean
#
# This option tells git-log whether to include merge commits in the log.
# The default is to include them.
include_merge = True
# ``log_encoding`` is a string identifier
#
# This option tells gitchangelog what encoding is outputed by ``git log``.
# The default is to be clever about it: it checks ``git config`` for
# ``i18n.logOutputEncoding``, and if not found will default to git's own
# default: ``utf-8``.
log_encoding = 'utf-8'
OUTPUT_FILE = "CHANGELOG.md"
INSERT_POINT_REGEX = r'''(?isxu)
^
(
\s*\#\s+Changelog\s*(\n|\r\n|\r) ## ``Changelog`` line
)
( ## Match all between changelog and release rev
(
(?!
(?<=(\n|\r)) ## look back for newline
\#\#\s+%(rev)s ## revision
\s+
\([0-9]+-[0-9]{2}-[0-9]{2}\)(\n|\r\n|\r) ## date
)
.
)*
)
(?P<tail>\#\#\s+(?P<rev>%(rev)s))
''' % {'rev': r"[0-9]+\.[0-9]+(\.[0-9]+)?"}
# ``publish`` is a callable
#
# Sets what ``gitchangelog`` should do with the output generated by
# the output engine. ``publish`` is a callable taking one argument
# that is an interator on lines from the output engine.
#
# Some helper callable are provided:
#
# Available choices are:
#
# - stdout
#
# Outputs directly to standard output
# (This is the default)
#
# - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start())
#
# Creates a callable that will parse given file for the given
# regex pattern and will insert the output in the file.
# ``idx`` is a callable that receive the matching object and
# must return a integer index point where to insert the
# the output in the file. Default is to return the position of
# the start of the matched string.
#
# - FileRegexSubst(file, pattern, replace, flags)
#
# Apply a replace inplace in the given file. Your regex pattern must
# take care of everything and might be more complex. Check the README
# for a complete copy-pastable example.
#
publish = FileRegexSubst(OUTPUT_FILE, INSERT_POINT_REGEX, r"\1\o\n\g<tail>")
# ``revs`` is a list of callable or a list of string
#
# callable will be called to resolve as strings and allow dynamical
# computation of these. The result will be used as revisions for
# gitchangelog (as if directly stated on the command line). This allows
# to filter exaclty which commits will be read by gitchangelog.
#
# To get a full documentation on the format of these strings, please
# refer to the ``git rev-list`` arguments. There are many examples.
#
# Using callables is especially useful, for instance, if you
# are using gitchangelog to generate incrementally your changelog.
#
# Some helpers are provided, you can use them::
#
# - FileFirstRegexMatch(file, pattern): will return a callable that will
# return the first string match for the given pattern in the given file.
# If you use named sub-patterns in your regex pattern, it'll output only
# the string matching the regex pattern named "rev".
#
# - Caret(rev): will return the rev prefixed by a "^", which is a
# way to remove the given revision and all its ancestor.
#
# Please note that if you provide a rev-list on the command line, it'll
# replace this value (which will then be ignored).
#
# If empty, then ``gitchangelog`` will act as it had to generate a full
# changelog.
#
# The default is to use all commits to make the changelog.
# revs = ["^1.0.3", ]
revs = [
Caret(FileFirstRegexMatch(OUTPUT_FILE, INSERT_POINT_REGEX)),
"HEAD"
]

41
.github/workflows/publish-docs.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Build and publish documentation
on:
push:
tags: [ "*" ]
permissions:
contents: write
pull-requests: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements/dev.txt
- name: Build documentation
run: |
make docs
- name: github pages deploy
uses: peaceiris/actions-gh-pages@v3.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: docs
force_orphan: true

48
.github/workflows/publish-package.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: Release package on PyPI
on:
push:
tags: [ "*" ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build-n-publish:
name: Build package and publish to TestPyPI and PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
name: Use Python 3.9
with:
python-version: 3.9
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
- name: Publish package to Test PyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/
- name: Publish package
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}

View file

@ -1,39 +0,0 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python package
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Sphinx Pages
# You may pin to the exact commit or the version.
# uses: seanzhengw/sphinx-pages@70dd0557fc226cfcd41c617aec5e9ee4fce4afe2
uses: seanzhengw/sphinx-pages@d29427677b3b89c1b5311d9eb135fb4168f4ba4a
with:
# Token for the repo. Can be passed in using $\{{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
# Auto create a README.md file at branch gh-pages root with repo/branch/commit links
source_dir: "doc_src"

47
.github/workflows/run-tests.yaml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Run Tox tests
on:
- push
- pull_request
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ 3.6, 3.7, 3.8, 3.9 ]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }}-${{ hashFiles('**/tox.ini') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
name: Python ${{ matrix.python-version }}

193
.gitignore vendored
View file

@ -1,20 +1,181 @@
*.pyc
dev.db
local_settings.py
media/ugc
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
src/
pip-log.txt
media/js/*.r*.js
media/css/*.r*.css
*DS_Store
*.egg-info
doc_src/_build
MANIFEST
develop-eggs/
dist/
.venv
example/media/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
htmlcov
.nox/
.coverage
.python-version
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
dev.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
*/.ipynb_checkpoints/*
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Pycharm/Intellij
.idea
# VSCode
.vscode
# Docker
docker-compose*
**/docker-compose*
# Complexity
output/*.html
output/*/index.html
# Testing artifacts
junit-*.xml
flake8-errors.txt
example/media/
# Documentation building
_build
doc_src/api/categories*.rst
docs
RELEASE.txt
site-packages
reports
test-reports/*

53
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,53 @@
repos:
- repo: https://github.com/timothycrosley/isort
rev: 5.10.1
hooks:
- id: isort
additional_dependencies: [toml]
- repo: https://github.com/python/black
rev: 21.11b1
hooks:
- id: black
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
- id: check-added-large-files
- id: check-case-conflict
- id: check-executables-have-shebangs
- id: check-json
- id: check-merge-conflict
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-toml
- id: check-yaml
- id: debug-statements
- id: end-of-file-fixer
exclude: "^tests/resources/"
- id: fix-byte-order-marker
- id: fix-encoding-pragma
args: ["--remove"]
- id: requirements-txt-fixer
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8
- repo: https://github.com/pycqa/pydocstyle
rev: 6.1.1
hooks:
- id: pydocstyle
exclude: test.*|custom_.*|\d\d\d\d_.*
additional_dependencies: [toml]
- repo: https://github.com/terrencepreilly/darglint
rev: v1.8.1
hooks:
- id: darglint
args:
- -v 2
- "--message-template={path}:{line} in `{obj}`:\n {msg_id}: {msg}"
- --strictness=short
exclude: test.*|custom_.*|\d\d\d\d_.*
- repo: https://github.com/econchick/interrogate
rev: 1.5.0 # or master if you're bold
hooks:
- id: interrogate

View file

@ -1,12 +0,0 @@
sudo: false
dist: xenial
language: python
python:
- "3.6"
install: pip install tox-travis
script: tox

1228
CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

3
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,3 @@
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/)
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).

View file

@ -199,4 +199,3 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -9,10 +9,11 @@ recursive-include categories/static *.html *.gif *.png *.css *.txt *.js
recursive-include categories/editor *.html *.gif *.png *.css *.js
recursive-include doc_src *.rst *.txt *.png *.css *.html *.js
recursive-exclude doc_src/_build *.*
include doc_src/Makefile
include doc_src/make.bat
recursive-include categories/locale *.mo *.po
recursive-include categories/editor/locale *.mo *.po
recursive-include requirements *.txt
prune example

View file

@ -1,20 +1,96 @@
help:
@echo ""
@echo "Available make commands:"
@echo ""
@echo "deps Install development dependencies"
@echo "test Run tests"
@echo "publish Publish a release to PyPi (requires permissions)"
@echo ""
.PHONY: clean clean-test clean-pyc clean-build docs help
.DEFAULT_GOAL := help
deps:
RELEASE_KIND := patch
SOURCE_DIR := categories
BRANCH_NAME := $(shell echo $$(git rev-parse --abbrev-ref HEAD))
SHORT_BRANCH_NAME := $(shell echo $(BRANCH_NAME) | cut -c 1-20)
PRIMARY_BRANCH_NAME := master
EDIT_CHANGELOG_IF_EDITOR_SET := @bash -c "$(shell if [[ -n $$EDITOR ]] ; then echo "$$EDITOR CHANGELOG.md" ; else echo "" ; fi)"
help:
@grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}'
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
clean-build: ## remove build artifacts
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +
clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.pyo' -exec rm -f {} +
find . -name '*~' -exec rm -f {} +
find . -name '__pycache__' -exec rm -fr {} +
clean-test: ## remove test and coverage artifacts
rm -fr .tox/
rm -f .coverage
rm -fr htmlcov/
rm -fr .pytest_cache
deps: ## Install development dependencies
pip install -r requirements.txt
pip install tox sphinx sphinx-autobuild twine
test:
test: ## Run tests
tox
publish:
publish: ## Publish a release to PyPi (requires permissions)
rm -fr build dist
python setup.py sdist bdist_wheel
twine upload dist/*
release-helper:
## DO NOT CALL DIRECTLY. It is used by release-{patch,major,minor,dev}
@echo "Branch In Use: $(BRANCH_NAME) $(SHORT_BRANCH_NAME)"
ifeq ($(BRANCH_NAME), $(PRIMARY_BRANCH_NAME))
ifeq ($(RELEASE_KIND), dev)
@echo "Error! Can't bump $(RELEASE_KIND) while on the $(PRIMARY_BRANCH_NAME) branch."
exit
else ifneq ($(RELEASE_KIND), dev)
@echo "Error! Must be on the $(PRIMARY_BRANCH_NAME) branch to bump $(RELEASE_KIND)."
exit
endif
git fetch -p --all
gitchangelog
$(EDIT_CHANGELOG_IF_EDITOR_SET)
export BRANCH_NAME=$(SHORT_BRANCH_NAME);bumpversion $(RELEASE_KIND) --allow-dirty --tag
git push origin $(BRANCH_NAME)
git push --tags
set-release-major-env-var:
$(eval RELEASE_KIND := major)
set-release-minor-env-var:
$(eval RELEASE_KIND := minor)
set-release-patch-env-var:
$(eval RELEASE_KIND := patch)
set-release-dev-env-var:
$(eval RELEASE_KIND := dev)
release-dev: set-release-dev-env-var release-helper ## Release a new development version: 1.1.1 -> 1.1.1+branchname-0
release-patch: set-release-patch-env-var release-helper ## Release a new patch version: 1.1.1 -> 1.1.2
release-minor: set-release-minor-env-var release-helper ## Release a new minor version: 1.1.1 -> 1.2.0
release-major: set-release-major-env-var release-helper ## release a new major version: 1.1.1 -> 2.0.0
docs: ## generate Sphinx HTML documentation, including API docs
mkdir -p docs
rm -f doc_src/api/$(SOURCE_DIR)*.rst
ls -A1 docs | xargs -I {} rm -rf docs/{}
$(MAKE) -C doc_src clean html
cp -a doc_src/_build/html/. docs
pubdocs: docs ## Publish the documentation to GitHub
ghp-import -op docs

178
README.md Normal file
View file

@ -0,0 +1,178 @@
# Django Categories
[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/)
[![codecov](https://codecov.io/gh/jazzband/django-categories/branch/master/graph/badge.svg?token=rW8mpdZqWQ)](https://codecov.io/gh/jazzband/django-categories)
Django Categories grew out of our need to provide a basic hierarchical taxonomy management system that multiple applications could use independently or in concert.
As a news site, our stories, photos, and other content get divided into "sections" and we wanted all the apps to use the same set of sections. As our needs grew, the Django Categories grew in the functionality it gave to category handling within web pages.
## Features of the project
Currently incompatible with Python 3.10.
**Multiple trees, or a single tree.**
You can treat all the records as a single tree, shared by all the applications. You can also treat each of the top level records as individual trees, for different apps or uses.
**Easy handling of hierarchical data.**
We use [Django MPTT](http://pypi.python.org/pypi/django-mptt) to manage the data efficiently and provide the extra access functions.
**Easy importation of data.**
Import a tree or trees of space- or tab-indented data with a Django management command.
**Metadata for better SEO on web pages**
Include all the metadata you want for easy inclusion on web pages.
**Link uncategorized objects to a category**
Attach any number of objects to a category, even if the objects themselves aren't categorized.
**Hierarchical Admin**
Shows the data in typical tree form with disclosure triangles
**Template Helpers**
Easy ways for displaying the tree data in templates:
- **Show one level of a tree.** All root categories or just children of a specified category
- **Show multiple levels.** Ancestors of category, category and all children of category or a category and its children
## Changes
### New in 1.8
* Support for Django 3.1
* Removed support for Python 2.7
### New in 1.7
* Support for Django 3
### New in 1.6
* Support for Django 2
### New in 1.5
* Support for Django 1.10
### New in 1.4
* Support for Python 2.7, 3.4 and 3.5
* Support for Django 1.9
* Dropped support for Django 1.7 and older
### New in 1.3
* Support for Django 1.6, 1.7 and 1.8
* Dropped support for Django versions 1.5 and below
### New in 1.2
* Support for Django 1.5
* Dropped support for Django 1.2
* Dropped caching within the app
* Removed the old settings compatibility layer. *Must use new dictionary-based settings!*
### New in 1.1
* Fixed a cosmetic bug in the Django 1.4 admin. Action checkboxes now only appear once.
* Template tags are refactored to allow easy use of any model derived from ``CategoryBase``.
* Improved test suite.
* Improved some of the documentation.
### Upgrade path from 1.0.2 to 1.0.3
Due to some data corruption with 1.0.2 migrations, a partially new set of migrations has been written in 1.0.3; and this will cause issues for users on 1.0.2 version. There is also an issue with South version 0.7.4. South version 0.7.3 or 0.7.5 or greater works fine.
For a clean upgrade from 1.0.2 to 1.0.3 you have to delete previous version of 0010 migration (named 0010_changed_category_relation.py) and fakes the new 00010, 0011 and 0012.
Therefore after installing new version of django-categories, for each project to upgrade you should execute the following commans in order
python manage.py migrate categories 0010_add_field_categoryrelation_category --fake --delete-ghost-migrations
python manage.py migrate categories 0011_move_category_fks --fake
python manage.py migrate categories 0012_remove_story_field --fake
python manage.py migrate categories 0013_null_category_id
This way both the exact database layout and migration history is restored between the two installation paths (new installation from 1.0.3 and upgrade from 1.0.2 to 1.0.3).
Last migration is needed to set the correct null value for `category_id` field when upgrading from 1.0.2 while is a noop for 1.0.3.
### New in 1.0
**Abstract Base Class for generic hierarchical category models.**
When you want a multiple types of categories and don't want them all part of the same model, you can now easily create new models by subclassing `CategoryBase`. You can also add additional metadata as necessary.
Your model's can subclass `CategoryBaseAdminForm` and `CategoryBaseAdmin` to get the hierarchical management in the admin.
See the docs for more information.
**Increased the default caching time on views.**
The default setting for `CACHE_VIEW_LENGTH` was `0`, which means it would tell the browser to *never* cache the page. It is now `600`, which is the default for [CACHE_MIDDLEWARE_SECONDS](https://docs.djangoproject.com/en/1.3/ref/settings/#cache-middleware-seconds)
**Updated for use with Django-MPTT 0.5.**
Just a few tweaks.
**Initial compatibility with Django 1.4.**
More is coming, but at least it works.
**Slug transliteration for non-ASCII characters.**
A new setting, ``SLUG_TRANSLITERATOR``, allows you to specify a function for converting the non-ASCII characters to ASCII characters before the slugification. Works great with [Unidecode](http://pypi.python.org/pypi/Unidecode).
### Updated in 0.8.8
The `editor` app was placed inside the categories app, `categories.editor`, to avoid any name clashes.
#### Upgrading
A setting change is all that is needed:
INSTALLED_APPS = (
'categories',
'categories.editor',
)
### New in 0.8
**Added an active field.** As an alternative to deleting categories, you can make them inactive.
Also added a manager method ``active()`` to query only the active categories and added Admin Actions to activate or deactivate an item.
**Improved import.** Previously the import saved items in the reverse order to the imported file. Now them import in order.
### New in 0.7
**Added South migrations.** All the previous SQL scripts have been converted to South migrations.
**Can add category fields via management command (and South).**
The new ability to setup category relationships in ``settings.py`` works fine if you are starting from scratch, but not if you want to add it after you have set up the database. Now there is a management command to make sure all the correct fields and tables are created.
**Added an alternate_url field.**
This allows the specification of a URL that is not derived from the category hierarchy.
**New JAVASCRIPT_URL setting.**
This allows some customization of the `genericcollections.js` file.
**New get_latest_objects_by_category template tag.** This will do pretty much what it says.
### New in 0.6
**Class-based views.**
Works great with Django 1.3 or [django-cbv](http://pypi.python.org/pypi/django-cbv)
**New Settings infrastructure.**
To be more like the Django project, we are migrating from individual `CATEGORIES_*` settings to a dictionary named `CATEGORIES_SETTINGS`. Use of the previous settings will still work but will generate a `DeprecationError`.
**The tree's initially expanded state is now configurable.**
`EDITOR_TREE_INITIAL_STATE` allows a `collapsed` or `expanded` value. The default is `collapsed`.
**Optional Thumbnail field.**
Have a thumbnail for each category!
**"Categorize" models in settings.**
Now you don't have to modify the model to add a `Category` relationship. Use the new settings to "wire" categories to different models.

View file

@ -1,203 +0,0 @@
=================
Django Categories
=================
|BUILD|_
.. |BUILD| image::
https://travis-ci.org/callowayproject/django-categories.svg?branch=master
.. _BUILD: https://travis-ci.org/callowayproject/django-categories
Django Categories grew out of our need to provide a basic hierarchical taxonomy management system that multiple applications could use independently or in concert.
As a news site, our stories, photos, and other content get divided into "sections" and we wanted all the apps to use the same set of sections. As our needs grew, the Django Categories grew in the functionality it gave to category handling within web pages.
New in 1.8
==========
* Support for Django 3.1
* Removed support for Python 2.7
New in 1.7
==========
* Support for Django 3
New in 1.6
==========
* Support for Django 2
New in 1.5
==========
* Support for Django 1.10
New in 1.4
==========
* Support for Python 2.7, 3.4 and 3.5
* Support for Django 1.9
* Dropped support for Django 1.7 and older
New in 1.3
==========
* Support for Django 1.6, 1.7 and 1.8
* Dropped support for Django versions 1.5 and below
New in 1.2
==========
* Support for Django 1.5
* Dropped support for Django 1.2
* Dropped caching within the app
* Removed the old settings compatibility layer. *Must use new dictionary-based settings!*
New in 1.1
==========
* Fixed a cosmetic bug in the Django 1.4 admin. Action checkboxes now only appear once.
* Template tags are refactored to allow easy use of any model derived from ``CategoryBase``.
* Improved test suite.
* Improved some of the documentation.
Upgrade path from 1.0.2 to 1.0.3
================================
Due to some data corruption with 1.0.2 migrations, a partially new set of migrations has been written in 1.0.3; and this will cause issues for users on 1.0.2 version. There is also an issue with South version 0.7.4. South version 0.7.3 or 0.7.5 or greater works fine.
For a clean upgrade from 1.0.2 to 1.0.3 you have to delete previous version of 0010 migration (named 0010_changed_category_relation.py) and fakes the new 00010, 0011 and 0012.
Therefore after installing new version of django-categories, for each project to upgrade you should execute the following commans in order::
python manage.py migrate categories 0010_add_field_categoryrelation_category --fake --delete-ghost-migrations
python manage.py migrate categories 0011_move_category_fks --fake
python manage.py migrate categories 0012_remove_story_field --fake
python manage.py migrate categories 0013_null_category_id
This way both the exact database layout and migration history is restored between the two installation paths (new installation from 1.0.3 and upgrade from 1.0.2 to 1.0.3).
Last migration is needed to set the correct null value for `category_id` field when upgrading from 1.0.2 while is a noop for 1.0.3.
New in 1.0
==========
**Abstract Base Class for generic hierarchical category models**
When you want a multiple types of categories and don't want them all part of the same model, you can now easily create new models by subclassing ``CategoryBase``. You can also add additional metadata as necessary.
Your model's can subclass ``CategoryBaseAdminForm`` and ``CategoryBaseAdmin`` to get the hierarchical management in the admin.
See the docs for more information.
**Increased the default caching time on views**
The default setting for ``CACHE_VIEW_LENGTH`` was ``0``, which means it would tell the browser to *never* cache the page. It is now ``600``, which is the default for `CACHE_MIDDLEWARE_SECONDS <https://docs.djangoproject.com/en/1.3/ref/settings/#cache-middleware-seconds>`_
**Updated for use with Django-MPTT 0.5**
Just a few tweaks.
**Initial compatibility with Django 1.4**
More is coming, but at least it works.
**Slug transliteration for non-ASCII characters**
A new setting, ``SLUG_TRANSLITERATOR``, allows you to specify a function for converting the non-ASCII characters to ASCII characters before the slugification. Works great with `Unidecode <http://pypi.python.org/pypi/Unidecode>`_.
Updated in 0.8.8
================
The `editor` app was placed inside the categories app, `categories.editor`, to avoid any name clashes.
Upgrading
---------
A setting change is all that is needed::
INSTALLED_APPS = (
'categories',
'categories.editor',
)
New in 0.8
==========
**Added an active field**
As an alternative to deleting categories, you can make them inactive.
Also added a manager method ``active()`` to query only the active categories and added Admin Actions to activate or deactivate an item.
**Improved import**
Previously the import saved items in the reverse order to the imported file. Now them import in order.
New in 0.7
==========
**Added South migrations**
All the previous SQL scripts have been converted to South migrations.
**Can add category fields via management command (and South)**
The new ability to setup category relationships in ``settings.py`` works fine if you are starting from scratch, but not if you want to add it after you have set up the database. Now there is a management command to make sure all the correct fields and tables are created.
**Added an alternate_url field**
This allows the specification of a URL that is not derived from the category hierarchy.
**New JAVASCRIPT_URL setting**
This allows some customization of the ``genericcollections.js`` file.
**New get_latest_objects_by_category template tag**
This will do pretty much what it says.
New in 0.6
==========
**Class-based views**
Works great with Django 1.3 or `django-cbv <http://pypi.python.org/pypi/django-cbv>`_
**New Settings infrastructure**
To be more like the Django project, we are migrating from individual CATEGORIES_* settings to a dictionary named ``CATEGORIES_SETTINGS``\ . Use of the previous settings will still work but will generate a ``DeprecationError``\ .
**The tree's initially expanded state is now configurable**
``EDITOR_TREE_INITIAL_STATE`` allows a ``collapsed`` or ``expanded`` value. The default is ``collapsed``\ .
**Optional Thumbnail field**
Have a thumbnail for each category!
**"Categorize" models in settings**
Now you don't have to modify the model to add a ``Category`` relationship. Use the new settings to "wire" categories to different models.
Features of the project
=======================
**Multiple trees, or a single tree**
You can treat all the records as a single tree, shared by all the applications. You can also treat each of the top level records as individual trees, for different apps or uses.
**Easy handling of hierarchical data**
We use `Django MPTT <http://pypi.python.org/pypi/django-mptt>`_ to manage the data efficiently and provide the extra access functions.
**Easy importation of data**
Import a tree or trees of space- or tab-indented data with a Django management command.
**Metadata for better SEO on web pages**
Include all the metadata you want for easy inclusion on web pages.
**Link uncategorized objects to a category**
Attach any number of objects to a category, even if the objects themselves aren't categorized.
**Hierarchical Admin**
Shows the data in typical tree form with disclosure triangles
**Template Helpers**
Easy ways for displaying the tree data in templates:
**Show one level of a tree**
All root categories or just children of a specified category
**Show multiple levels**
Ancestors of category, category and all children of category or a category and its children

View file

@ -1,23 +1,5 @@
__version_info__ = {
'major': 1,
'minor': 8,
'micro': 0,
'releaselevel': 'final',
'serial': 1
}
"""Django categories."""
__version__ = "1.8.0"
def get_version(short=False):
assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final')
vers = ["%(major)i.%(minor)i" % __version_info__, ]
if __version_info__['micro'] and not short:
vers.append(".%(micro)i" % __version_info__)
if __version_info__['releaselevel'] != 'final' and not short:
vers.append('%s%i' % (__version_info__['releaselevel'][0], __version_info__['serial']))
return ''.join(vers)
__version__ = get_version()
default_app_config = 'categories.apps.CategoriesConfig'
default_app_config = "categories.apps.CategoriesConfig"

View file

@ -1,70 +1,82 @@
from django.contrib import admin
"""Admin interface classes."""
from django import forms
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from .base import CategoryBaseAdmin, CategoryBaseAdminForm
from .genericcollection import GenericCollectionTabularInline
from .settings import RELATION_MODELS, JAVASCRIPT_URL, REGISTER_ADMIN
from .models import Category
from .base import CategoryBaseAdminForm, CategoryBaseAdmin
from .settings import MODEL_REGISTRY
from .settings import JAVASCRIPT_URL, MODEL_REGISTRY, REGISTER_ADMIN, RELATION_MODELS
class NullTreeNodeChoiceField(forms.ModelChoiceField):
"""A ModelChoiceField for tree nodes."""
def __init__(self, level_indicator='---', *args, **kwargs):
def __init__(self, level_indicator="---", *args, **kwargs):
self.level_indicator = level_indicator
super(NullTreeNodeChoiceField, self).__init__(*args, **kwargs)
def label_from_instance(self, obj):
def label_from_instance(self, obj) -> str:
"""
Creates labels which represent the tree level of each node when
generating option labels.
Creates labels which represent the tree level of each node when generating option labels.
"""
return '%s %s' % (self.level_indicator * getattr(obj, obj._mptt_meta.level_attr), obj)
return "%s %s" % (self.level_indicator * getattr(obj, obj._mptt_meta.level_attr), obj)
if RELATION_MODELS:
from .models import CategoryRelation
class InlineCategoryRelation(GenericCollectionTabularInline):
"""The inline admin panel for category relations."""
model = CategoryRelation
class CategoryAdminForm(CategoryBaseAdminForm):
"""The form for a category in the admin."""
class Meta:
model = Category
fields = '__all__'
fields = "__all__"
def clean_alternate_title(self):
if self.instance is None or not self.cleaned_data['alternate_title']:
return self.cleaned_data['name']
def clean_alternate_title(self) -> str:
"""Return either the name or alternate title for the category."""
if self.instance is None or not self.cleaned_data["alternate_title"]:
return self.cleaned_data["name"]
else:
return self.cleaned_data['alternate_title']
return self.cleaned_data["alternate_title"]
class CategoryAdmin(CategoryBaseAdmin):
"""Admin for categories."""
form = CategoryAdminForm
list_display = ('name', 'alternate_title', 'active')
list_display = ("name", "alternate_title", "active")
fieldsets = (
(None, {
'fields': ('parent', 'name', 'thumbnail', 'active')
}),
(_('Meta Data'), {
'fields': ('alternate_title', 'alternate_url', 'description',
'meta_keywords', 'meta_extra'),
'classes': ('collapse',),
}),
(_('Advanced'), {
'fields': ('order', 'slug'),
'classes': ('collapse',),
}),
(None, {"fields": ("parent", "name", "thumbnail", "active")}),
(
_("Meta Data"),
{
"fields": ("alternate_title", "alternate_url", "description", "meta_keywords", "meta_extra"),
"classes": ("collapse",),
},
),
(
_("Advanced"),
{
"fields": ("order", "slug"),
"classes": ("collapse",),
},
),
)
if RELATION_MODELS:
inlines = [InlineCategoryRelation, ]
inlines = [
InlineCategoryRelation,
]
class Media:
js = (JAVASCRIPT_URL + 'genericcollections.js',)
js = (JAVASCRIPT_URL + "genericcollections.js",)
if REGISTER_ADMIN:
@ -72,18 +84,21 @@ if REGISTER_ADMIN:
for model, modeladmin in list(admin.site._registry.items()):
if model in list(MODEL_REGISTRY.values()) and modeladmin.fieldsets:
fieldsets = getattr(modeladmin, 'fieldsets', ())
fields = [cat.split('.')[2] for cat in MODEL_REGISTRY if MODEL_REGISTRY[cat] == model]
fieldsets = getattr(modeladmin, "fieldsets", ())
fields = [cat.split(".")[2] for cat in MODEL_REGISTRY if MODEL_REGISTRY[cat] == model]
# check each field to see if already defined
for cat in fields:
for k, v in fieldsets:
if cat in v['fields']:
if cat in v["fields"]:
fields.remove(cat)
# if there are any fields left, add them under the categories fieldset
if len(fields) > 0:
admin.site.unregister(model)
admin.site.register(model, type('newadmin', (modeladmin.__class__,), {
'fieldsets': fieldsets + (('Categories', {
'fields': fields
}),)
}))
admin.site.register(
model,
type(
"newadmin",
(modeladmin.__class__,),
{"fieldsets": fieldsets + (("Categories", {"fields": fields}),)},
),
)

View file

@ -1,17 +1,23 @@
"""Django application setup."""
from django.apps import AppConfig
class CategoriesConfig(AppConfig):
name = 'categories'
"""Application configuration for categories."""
name = "categories"
verbose_name = "Categories"
def __init__(self, *args, **kwargs):
super(CategoriesConfig, self).__init__(*args, **kwargs)
from django.db.models.signals import class_prepared
class_prepared.connect(handle_class_prepared)
def ready(self):
"""Migrate the app after it is ready."""
from django.db.models.signals import post_migrate
from .migration import migrate_app
post_migrate.connect(migrate_app)
@ -19,19 +25,20 @@ class CategoriesConfig(AppConfig):
def handle_class_prepared(sender, **kwargs):
"""
See if this class needs registering of fields
See if this class needs registering of fields.
"""
from .settings import M2M_REGISTRY, FK_REGISTRY
from .registration import registry
from .settings import FK_REGISTRY, M2M_REGISTRY
sender_app = sender._meta.app_label
sender_name = sender._meta.model_name
for key, val in list(FK_REGISTRY.items()):
app_name, model_name = key.split('.')
app_name, model_name = key.split(".")
if app_name == sender_app and sender_name == model_name:
registry.register_model(app_name, sender, 'ForeignKey', val)
registry.register_model(app_name, sender, "ForeignKey", val)
for key, val in list(M2M_REGISTRY.items()):
app_name, model_name = key.split('.')
app_name, model_name = key.split(".")
if app_name == sender_app and sender_name == model_name:
registry.register_model(app_name, sender, 'ManyToManyField', val)
registry.register_model(app_name, sender, "ManyToManyField", val)

View file

@ -1,69 +1,67 @@
"""
This is the base class on which to build a hierarchical category-like model
with customizable metadata and its own name space.
"""
import sys
This is the base class on which to build a hierarchical category-like model.
It provides customizable metadata and its own name space.
"""
from django import forms
from django.contrib import admin
from django.db import models
from django import forms
from django.utils.encoding import force_text
from mptt.models import MPTTModel
from django.utils.translation import ugettext_lazy as _
from mptt.fields import TreeForeignKey
from mptt.managers import TreeManager
from mptt.models import MPTTModel
from slugify import slugify
from .editor.tree_editor import TreeEditor
from .settings import ALLOW_SLUG_CHANGE, SLUG_TRANSLITERATOR
from django.utils.translation import ugettext_lazy as _
if sys.version_info[0] < 3: # Remove this after dropping support of Python 2
from django.utils.encoding import python_2_unicode_compatible
else:
def python_2_unicode_compatible(x):
return x
class CategoryManager(models.Manager):
"""
A manager that adds an "active()" method for all active categories
A manager that adds an "active()" method for all active categories.
"""
def active(self):
"""
Only categories that are active
Only categories that are active.
"""
return self.get_queryset().filter(active=True)
@python_2_unicode_compatible
class CategoryBase(MPTTModel):
"""
This base model includes the absolute bare bones fields and methods. One
could simply subclass this model and do nothing else and it should work.
This base model includes the absolute bare-bones fields and methods.
One could simply subclass this model, do nothing else, and it should work.
"""
parent = TreeForeignKey(
'self',
"self",
on_delete=models.CASCADE,
blank=True,
null=True,
related_name='children',
verbose_name=_('parent'),
related_name="children",
verbose_name=_("parent"),
)
name = models.CharField(max_length=100, verbose_name=_('name'))
slug = models.SlugField(verbose_name=_('slug'))
active = models.BooleanField(default=True, verbose_name=_('active'))
name = models.CharField(max_length=100, verbose_name=_("name"))
slug = models.SlugField(verbose_name=_("slug"))
active = models.BooleanField(default=True, verbose_name=_("active"))
objects = CategoryManager()
tree = TreeManager()
def save(self, *args, **kwargs):
"""
Save the category.
While you can activate an item without activating its descendants,
It doesn't make sense that you can deactivate an item and have its
decendants remain active.
Args:
args: generic args
kwargs: generic keyword arguments
"""
if not self.slug:
self.slug = slugify(SLUG_TRANSLITERATOR(self.name))[:50]
@ -78,26 +76,33 @@ class CategoryBase(MPTTModel):
def __str__(self):
ancestors = self.get_ancestors()
return ' > '.join([force_text(i.name) for i in ancestors] + [self.name, ])
return " > ".join(
[force_text(i.name) for i in ancestors]
+ [
self.name,
]
)
class Meta:
abstract = True
unique_together = ('parent', 'name')
ordering = ('tree_id', 'lft')
unique_together = ("parent", "name")
ordering = ("tree_id", "lft")
class MPTTMeta:
order_insertion_by = 'name'
order_insertion_by = "name"
class CategoryBaseAdminForm(forms.ModelForm):
"""Base admin form for categories."""
def clean_slug(self):
if not self.cleaned_data.get('slug', None):
if self.instance is None or not ALLOW_SLUG_CHANGE:
self.cleaned_data['slug'] = slugify(SLUG_TRANSLITERATOR(self.cleaned_data['name']))
return self.cleaned_data['slug'][:50]
"""Prune and transliterate the slug."""
if not self.cleaned_data.get("slug", None) and (self.instance is None or not ALLOW_SLUG_CHANGE):
self.cleaned_data["slug"] = slugify(SLUG_TRANSLITERATOR(self.cleaned_data["name"]))
return self.cleaned_data["slug"][:50]
def clean(self):
"""Clean the data passed from the admin interface."""
super(CategoryBaseAdminForm, self).clean()
if not self.is_valid():
@ -107,72 +112,74 @@ class CategoryBaseAdminForm(forms.ModelForm):
# Validate slug is valid in that level
kwargs = {}
if self.cleaned_data.get('parent', None) is None:
kwargs['parent__isnull'] = True
if self.cleaned_data.get("parent", None) is None:
kwargs["parent__isnull"] = True
else:
kwargs['parent__pk'] = int(self.cleaned_data['parent'].id)
this_level_slugs = [c['slug'] for c in opts.model.objects.filter(**kwargs).values('id', 'slug') if c['id'] != self.instance.id]
if self.cleaned_data['slug'] in this_level_slugs:
raise forms.ValidationError(_('The slug must be unique among '
'the items at its level.'))
kwargs["parent__pk"] = int(self.cleaned_data["parent"].id)
this_level_slugs = [
c["slug"] for c in opts.model.objects.filter(**kwargs).values("id", "slug") if c["id"] != self.instance.id
]
if self.cleaned_data["slug"] in this_level_slugs:
raise forms.ValidationError(_("The slug must be unique among " "the items at its level."))
# Validate Category Parent
# Make sure the category doesn't set itself or any of its children as
# its parent.
if self.cleaned_data.get('parent', None) is None or self.instance.id is None:
if self.cleaned_data.get("parent", None) is None or self.instance.id is None:
return self.cleaned_data
if self.instance.pk:
decendant_ids = self.instance.get_descendants().values_list('id', flat=True)
decendant_ids = self.instance.get_descendants().values_list("id", flat=True)
else:
decendant_ids = []
if self.cleaned_data['parent'].id == self.instance.id:
raise forms.ValidationError(_("You can't set the parent of the "
"item to itself."))
elif self.cleaned_data['parent'].id in decendant_ids:
raise forms.ValidationError(_("You can't set the parent of the "
"item to a descendant."))
if self.cleaned_data["parent"].id == self.instance.id:
raise forms.ValidationError(_("You can't set the parent of the " "item to itself."))
elif self.cleaned_data["parent"].id in decendant_ids:
raise forms.ValidationError(_("You can't set the parent of the " "item to a descendant."))
return self.cleaned_data
class CategoryBaseAdmin(TreeEditor, admin.ModelAdmin):
form = CategoryBaseAdminForm
list_display = ('name', 'active')
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)}
"""Base admin class for categories."""
actions = ['activate', 'deactivate']
form = CategoryBaseAdminForm
list_display = ("name", "active")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
actions = ["activate", "deactivate"]
def get_actions(self, request):
"""Get available actions for the admin interface."""
actions = super(CategoryBaseAdmin, self).get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
if "delete_selected" in actions:
del actions["delete_selected"]
return actions
def deactivate(self, request, queryset):
def deactivate(self, request, queryset): # NOQA: queryset is not used.
"""
Set active to False for selected items
Set active to False for selected items.
"""
selected_cats = self.model.objects.filter(
pk__in=[int(x) for x in request.POST.getlist('_selected_action')])
selected_cats = self.model.objects.filter(pk__in=[int(x) for x in request.POST.getlist("_selected_action")])
for item in selected_cats:
if item.active:
item.active = False
item.save()
item.children.all().update(active=False)
deactivate.short_description = _('Deactivate selected categories and their children')
def activate(self, request, queryset):
deactivate.short_description = _("Deactivate selected categories and their children")
def activate(self, request, queryset): # NOQA: queryset is not used.
"""
Set active to True for selected items
Set active to True for selected items.
"""
selected_cats = self.model.objects.filter(
pk__in=[int(x) for x in request.POST.getlist('_selected_action')])
selected_cats = self.model.objects.filter(pk__in=[int(x) for x in request.POST.getlist("_selected_action")])
for item in selected_cats:
item.active = True
item.save()
item.children.all().update(active=True)
activate.short_description = _('Activate selected categories and their children')
activate.short_description = _("Activate selected categories and their children")

View file

@ -1 +1 @@
# Placeholder for Django
"""Placeholder for Django."""

View file

@ -1,13 +1,14 @@
from django.conf import settings
"""Settings management for the editor."""
import django
from django.conf import settings
DJANGO10_COMPAT = django.VERSION[0] < 1 or (django.VERSION[0] == 1 and django.VERSION[1] < 1)
STATIC_URL = getattr(settings, 'STATIC_URL', settings.MEDIA_URL)
STATIC_URL = getattr(settings, "STATIC_URL", settings.MEDIA_URL)
if STATIC_URL is None:
STATIC_URL = settings.MEDIA_URL
MEDIA_PATH = getattr(settings, 'EDITOR_MEDIA_PATH', '%seditor/' % STATIC_URL)
MEDIA_PATH = getattr(settings, "EDITOR_MEDIA_PATH", "%seditor/" % STATIC_URL)
TREE_INITIAL_STATE = getattr(settings, 'EDITOR_TREE_INITIAL_STATE', 'collapsed')
TREE_INITIAL_STATE = getattr(settings, "EDITOR_TREE_INITIAL_STATE", "collapsed")
IS_GRAPPELLI_INSTALLED = 'grappelli' in settings.INSTALLED_APPS
IS_GRAPPELLI_INSTALLED = "grappelli" in settings.INSTALLED_APPS

View file

@ -66,4 +66,4 @@
.treeTable .ui-draggable-dragging {
color: #000;
z-index: 1;
}
}

View file

@ -460,4 +460,4 @@
function parentOf(node) {
return $(node).parentOf();
}
})(django.jQuery);
})(django.jQuery);

View file

@ -25,4 +25,4 @@
</tbody>
</table>
</div>
{% endif %}
{% endif %}

View file

@ -36,4 +36,4 @@
{% if action_form and actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
{% result_tree_list cl %}
{% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
{% endblock %}
{% endblock %}

View file

@ -1,30 +1,33 @@
"""Template tags used to render the tree editor."""
import django
from django.contrib.admin.templatetags.admin_list import _boolean_icon, result_headers
from django.contrib.admin.utils import lookup_field
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.template import Library
from django.contrib.admin.templatetags.admin_list import result_headers, _boolean_icon
from django.contrib.admin.utils import lookup_field
from categories.editor.utils import display_for_field
from django.core.exceptions import ObjectDoesNotExist
from django.utils.encoding import smart_text, force_text
from django.utils.html import escape, conditional_escape, escapejs, format_html
from django.utils.encoding import force_text, smart_text
from django.utils.html import conditional_escape, escape, escapejs, format_html
from django.utils.safestring import mark_safe
from categories.editor import settings
from categories.editor.utils import display_for_field
register = Library()
TREE_LIST_RESULTS_TEMPLATE = 'admin/editor/tree_list_results.html'
TREE_LIST_RESULTS_TEMPLATE = "admin/editor/tree_list_results.html"
if settings.IS_GRAPPELLI_INSTALLED:
TREE_LIST_RESULTS_TEMPLATE = 'admin/editor/grappelli_tree_list_results.html'
TREE_LIST_RESULTS_TEMPLATE = "admin/editor/grappelli_tree_list_results.html"
def get_empty_value_display(cl):
if hasattr(cl.model_admin, 'get_empty_value_display'):
"""Get the value to display when empty."""
if hasattr(cl.model_admin, "get_empty_value_display"):
return cl.model_admin.get_empty_value_display()
else:
# Django < 1.9
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
return EMPTY_CHANGELIST_VALUE
# Django < 1.9
from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
return EMPTY_CHANGELIST_VALUE
def items_for_tree_result(cl, result, form):
@ -34,7 +37,7 @@ def items_for_tree_result(cl, result, form):
first = True
pk = cl.lookup_opts.pk.attname
for field_name in cl.list_display:
row_class = ''
row_class = ""
try:
f, attr, value = lookup_field(field_name, result, cl.model_admin)
except (AttributeError, ObjectDoesNotExist):
@ -42,10 +45,10 @@ def items_for_tree_result(cl, result, form):
else:
if f is None:
if django.VERSION[0] == 1 and django.VERSION[1] == 4:
if field_name == 'action_checkbox':
if field_name == "action_checkbox":
row_class = ' class="action-checkbox disclosure"'
allow_tags = getattr(attr, 'allow_tags', False)
boolean = getattr(attr, 'boolean', False)
allow_tags = getattr(attr, "allow_tags", False)
boolean = getattr(attr, "boolean", False)
if boolean:
allow_tags = True
result_repr = _boolean_icon(value)
@ -60,16 +63,16 @@ def items_for_tree_result(cl, result, form):
else:
if value is None:
result_repr = get_empty_value_display(cl)
if hasattr(f, 'rel') and isinstance(f.rel, models.ManyToOneRel):
if hasattr(f, "rel") and isinstance(f.rel, models.ManyToOneRel):
result_repr = escape(getattr(result, f.name))
else:
result_repr = display_for_field(value, f, '')
result_repr = display_for_field(value, f, "")
if isinstance(f, models.DateField) or isinstance(f, models.TimeField):
row_class = ' class="nowrap"'
if first:
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
try:
f, attr, checkbox_value = lookup_field('action_checkbox', result, cl.model_admin)
f, attr, checkbox_value = lookup_field("action_checkbox", result, cl.model_admin)
if row_class:
row_class = "%s%s" % (row_class[:-1], ' disclosure"')
else:
@ -77,14 +80,14 @@ def items_for_tree_result(cl, result, form):
except (AttributeError, ObjectDoesNotExist):
pass
if force_text(result_repr) == '':
result_repr = mark_safe('&nbsp;')
if force_text(result_repr) == "":
result_repr = mark_safe("&nbsp;")
# If list_display_links not defined, add the link tag to the first field
if (first and not cl.list_display_links) or field_name in cl.list_display_links:
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
table_tag = 'td' # {True:'th', False:'td'}[first]
table_tag = "td" # {True:'th', False:'td'}[first]
else:
table_tag = {True: 'th', False: 'td'}[first]
table_tag = {True: "th", False: "td"}[first]
url = cl.url_for_result(result)
# Convert the pk to something that can be used in Javascript.
@ -104,9 +107,14 @@ def items_for_tree_result(cl, result, form):
row_class,
url,
format_html(
' onclick="opener.dismissRelatedLookupPopup(window, '
'&#39;{}&#39;); return false;"', result_id
) if cl.is_popup else '', result_repr, table_tag)
' onclick="opener.dismissRelatedLookupPopup(window, ' '&#39;{}&#39;); return false;"',
result_id,
)
if cl.is_popup
else "",
result_repr,
table_tag,
)
)
else:
@ -118,20 +126,23 @@ def items_for_tree_result(cl, result, form):
result_repr = mark_safe(force_text(bf.errors) + force_text(bf))
else:
result_repr = conditional_escape(result_repr)
yield mark_safe(smart_text('<td%s>%s</td>' % (row_class, result_repr)))
yield mark_safe(smart_text("<td%s>%s</td>" % (row_class, result_repr)))
if form and not form[cl.model._meta.pk.name].is_hidden:
yield mark_safe(smart_text('<td>%s</td>' % force_text(form[cl.model._meta.pk.name])))
yield mark_safe(smart_text("<td>%s</td>" % force_text(form[cl.model._meta.pk.name])))
class TreeList(list):
"""A list subclass for tree result."""
pass
def tree_results(cl):
"""Generates a list of results for the tree."""
if cl.formset:
for res, form in zip(cl.result_list, cl.formset.forms):
result = TreeList(items_for_tree_result(cl, res, form))
if hasattr(res, 'pk'):
if hasattr(res, "pk"):
result.pk = res.pk
if res.parent:
result.parent_pk = res.parent.pk
@ -141,7 +152,7 @@ def tree_results(cl):
else:
for res in cl.result_list:
result = TreeList(items_for_tree_result(cl, res, None))
if hasattr(res, 'pk'):
if hasattr(res, "pk"):
result.pk = res.pk
if res.parent:
result.parent_pk = res.parent.pk
@ -152,17 +163,15 @@ def tree_results(cl):
def result_tree_list(cl):
"""
Displays the headers and data list together
Displays the headers and data list together.
"""
import django
result = {
'cl': cl,
'result_headers': list(result_headers(cl)),
'results': list(tree_results(cl))
}
result = {"cl": cl, "result_headers": list(result_headers(cl)), "results": list(tree_results(cl))}
if django.VERSION[0] == 1 and django.VERSION[1] > 2:
from django.contrib.admin.templatetags.admin_list import result_hidden_fields
result['result_hidden_fields'] = list(result_hidden_fields(cl))
result["result_hidden_fields"] = list(result_hidden_fields(cl))
return result

View file

@ -1,21 +1,23 @@
from django.contrib import admin
from django.db.models.query import QuerySet
from django.contrib.admin.views.main import ChangeList
from django.http import HttpResponseRedirect
from django.utils.translation import ugettext_lazy as _
from django.contrib.admin.options import IncorrectLookupParameters
from django.shortcuts import render
"""Classes for representing tree structures in Django's admin."""
from typing import Any
import django
from django.contrib import admin
from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.views.main import ChangeList
from django.db.models.query import QuerySet
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import ugettext_lazy as _
from . import settings
class TreeEditorQuerySet(QuerySet):
"""
The TreeEditorQuerySet is a special query set used only in the TreeEditor
ChangeList page. The only difference to a regular QuerySet is that it
will enforce:
A special query set used only in the TreeEditor ChangeList page.
The only difference to a regular QuerySet is that it will enforce:
(a) The result is ordered in correct tree order so that
the TreeAdmin works all right.
@ -24,7 +26,9 @@ class TreeEditorQuerySet(QuerySet):
in the result set, so the resulting tree display actually
makes sense.
"""
def iterator(self):
"""Iterates through the items in thee query set."""
qs = self
# Reaching into the bowels of query sets to find out whether the qs is
# actually filtered and we need to do the INCLUDE_ANCESTORS dance at all.
@ -36,9 +40,9 @@ class TreeEditorQuerySet(QuerySet):
# this cuts down the number of queries considerably since all ancestors
# will already be in include_pages when they are checked, thus not
# trigger additional queries.
for p in super(TreeEditorQuerySet, self.order_by('rght')).iterator():
for p in super(TreeEditorQuerySet, self.order_by("rght")).iterator():
if p.parent_id and p.parent_id not in include_pages and p.id not in include_pages:
ancestor_id_list = p.get_ancestors().values_list('id', flat=True)
ancestor_id_list = p.get_ancestors().values_list("id", flat=True)
include_pages.update(ancestor_id_list)
if include_pages:
@ -54,52 +58,75 @@ class TreeEditorQuerySet(QuerySet):
# def __getitem__(self, index):
# return self # Don't even try to slice
def get(self, *args, **kwargs):
def get(self, *args, **kwargs) -> Any:
"""
Quick and dirty hack to fix change_view and delete_view; they use
self.queryset(request).get(...) to get the object they should work
with. Our modifications to the queryset when INCLUDE_ANCESTORS is
enabled make get() fail often with a MultipleObjectsReturned
exception.
Quick and dirty hack to fix change_view and delete_view.
They use ``self.queryset(request).get(...)`` to get the object they should work
with. Our modifications to the queryset when ``INCLUDE_ANCESTORS`` is enabled make ``get()``
fail often with a ``MultipleObjectsReturned`` exception.
Args:
args: generic arguments
kwargs: generic keyword arguments
Returns:
The object they should work with.
"""
return self.model._default_manager.get(*args, **kwargs)
class TreeChangeList(ChangeList):
"""A change list for a tree."""
def _get_default_ordering(self):
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
return '', '' # ('tree_id', 'lft')
return "", "" # ('tree_id', 'lft')
else:
return []
def get_ordering(self, request=None, queryset=None):
"""
Return ordering information for the change list.
Always returns empty/default ordering.
Args:
request: The incoming request.
queryset: The current queryset
Returns:
Either a tuple of empty strings or an empty list.
"""
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
return '', '' # ('tree_id', 'lft')
return "", "" # ('tree_id', 'lft')
else:
return []
def get_queryset(self, *args, **kwargs):
qs = super(TreeChangeList, self).get_queryset(*args, **kwargs).order_by('tree_id', 'lft')
return qs
"""Return a queryset."""
return super(TreeChangeList, self).get_queryset(*args, **kwargs).order_by("tree_id", "lft")
class TreeEditor(admin.ModelAdmin):
"""A tree editor view for Django's admin."""
list_per_page = 999999999 # We can't have pagination
list_max_show_all = 200 # new in django 1.4
class Media:
css = {'all': (settings.MEDIA_PATH + "jquery.treeTable.css", )}
css = {"all": (settings.MEDIA_PATH + "jquery.treeTable.css",)}
js = []
js.extend((settings.MEDIA_PATH + "jquery.treeTable.js", ))
js.extend((settings.MEDIA_PATH + "jquery.treeTable.js",))
def __init__(self, *args, **kwargs):
super(TreeEditor, self).__init__(*args, **kwargs)
self.list_display = list(self.list_display)
if 'action_checkbox' in self.list_display:
self.list_display.remove('action_checkbox')
if "action_checkbox" in self.list_display:
self.list_display.remove("action_checkbox")
opts = self.model._meta
@ -108,9 +135,9 @@ class TreeEditor(admin.ModelAdmin):
grappelli_prefix = "grappelli_"
self.change_list_template = [
'admin/%s/%s/editor/%stree_editor.html' % (opts.app_label, opts.object_name.lower(), grappelli_prefix),
'admin/%s/editor/%stree_editor.html' % (opts.app_label, grappelli_prefix),
'admin/editor/%stree_editor.html' % grappelli_prefix,
"admin/%s/%s/editor/%stree_editor.html" % (opts.app_label, opts.object_name.lower(), grappelli_prefix),
"admin/%s/editor/%stree_editor.html" % (opts.app_label, grappelli_prefix),
"admin/editor/%stree_editor.html" % grappelli_prefix,
]
def get_changelist(self, request, **kwargs):
@ -120,11 +147,12 @@ class TreeEditor(admin.ModelAdmin):
return TreeChangeList
def old_changelist_view(self, request, extra_context=None):
"The 'change list' admin view for this model."
"""The 'change list' admin view for this model."""
from django.contrib.admin.views.main import ERROR_FLAG
from django.core.exceptions import PermissionDenied
from django.utils.encoding import force_text
from django.utils.translation import ungettext
opts = self.model._meta
app_label = opts.app_label
if not self.has_change_permission(request, None):
@ -137,31 +165,56 @@ class TreeEditor(admin.ModelAdmin):
list_display = list(self.list_display)
if not actions:
try:
list_display.remove('action_checkbox')
list_display.remove("action_checkbox")
except ValueError:
pass
try:
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
params = (
request, self.model, list_display,
self.list_display_links, self.list_filter, self.date_hierarchy,
self.search_fields, self.list_select_related,
self.list_per_page, self.list_editable, self)
request,
self.model,
list_display,
self.list_display_links,
self.list_filter,
self.date_hierarchy,
self.search_fields,
self.list_select_related,
self.list_per_page,
self.list_editable,
self,
)
elif django.VERSION[0] == 1 or (django.VERSION[0] == 2 and django.VERSION[1] < 1):
params = (
request, self.model, list_display,
self.list_display_links, self.list_filter, self.date_hierarchy,
self.search_fields, self.list_select_related,
self.list_per_page, self.list_max_show_all,
self.list_editable, self)
request,
self.model,
list_display,
self.list_display_links,
self.list_filter,
self.date_hierarchy,
self.search_fields,
self.list_select_related,
self.list_per_page,
self.list_max_show_all,
self.list_editable,
self,
)
else:
params = (
request, self.model, list_display,
self.list_display_links, self.list_filter, self.date_hierarchy,
self.search_fields, self.list_select_related,
self.list_per_page, self.list_max_show_all,
self.list_editable, self, self.sortable_by)
request,
self.model,
list_display,
self.list_display_links,
self.list_filter,
self.date_hierarchy,
self.search_fields,
self.list_select_related,
self.list_per_page,
self.list_max_show_all,
self.list_editable,
self,
self.sortable_by,
)
cl = TreeChangeList(*params)
except IncorrectLookupParameters:
# Wacky lookup parameters were given, so redirect to the main
@ -170,15 +223,13 @@ class TreeEditor(admin.ModelAdmin):
# the 'invalid=1' parameter was already in the query string, something
# is screwed up with the database, so display an error page.
if ERROR_FLAG in list(request.GET.keys()):
return render(
request,
'admin/invalid_setup.html', {'title': _('Database error')})
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
return render(request, "admin/invalid_setup.html", {"title": _("Database error")})
return HttpResponseRedirect(request.path + "?" + ERROR_FLAG + "=1")
# If the request was POSTed, this might be a bulk action or a bulk edit.
# Try to look up an action first, but if this isn't an action the POST
# will fall through to the bulk edit check, below.
if actions and request.method == 'POST':
if actions and request.method == "POST":
response = self.response_action(request, queryset=cl.get_queryset())
if response:
return response
@ -191,9 +242,7 @@ class TreeEditor(admin.ModelAdmin):
# Handle POSTed bulk-edit data.
if request.method == "POST" and self.list_editable:
FormSet = self.get_changelist_formset(request)
formset = cl.formset = FormSet(
request.POST, request.FILES, queryset=cl.result_list
)
formset = cl.formset = FormSet(request.POST, request.FILES, queryset=cl.result_list)
if formset.is_valid():
changecount = 0
for form in formset.forms:
@ -210,12 +259,14 @@ class TreeEditor(admin.ModelAdmin):
name = force_text(opts.verbose_name)
else:
name = force_text(opts.verbose_name_plural)
msg = ungettext(
"%(count)s %(name)s was changed successfully.",
"%(count)s %(name)s were changed successfully.",
changecount) % {'count': changecount,
'name': name,
'obj': force_text(obj)}
msg = (
ungettext(
"%(count)s %(name)s was changed successfully.",
"%(count)s %(name)s were changed successfully.",
changecount,
)
% {"count": changecount, "name": name, "obj": force_text(obj)}
)
self.message_user(request, msg)
return HttpResponseRedirect(request.get_full_path())
@ -234,57 +285,70 @@ class TreeEditor(admin.ModelAdmin):
# Build the action form and populate it with available actions.
if actions:
action_form = self.action_form(auto_id=None)
action_form.fields['action'].choices = self.get_action_choices(request)
action_form.fields["action"].choices = self.get_action_choices(request)
else:
action_form = None
context = {
'title': cl.title,
'is_popup': cl.is_popup,
'cl': cl,
'media': media,
'has_add_permission': self.has_add_permission(request),
'app_label': app_label,
'action_form': action_form,
'actions_on_top': self.actions_on_top,
'actions_on_bottom': self.actions_on_bottom,
"title": cl.title,
"is_popup": cl.is_popup,
"cl": cl,
"media": media,
"has_add_permission": self.has_add_permission(request),
"app_label": app_label,
"action_form": action_form,
"actions_on_top": self.actions_on_top,
"actions_on_bottom": self.actions_on_bottom,
}
if django.VERSION[0] == 1 and django.VERSION[1] < 4:
context['root_path'] = self.admin_site.root_path
context["root_path"] = self.admin_site.root_path
elif django.VERSION[0] == 1 or (django.VERSION[0] == 2 and django.VERSION[1] < 1):
selection_note_all = ungettext('%(total_count)s selected', 'All %(total_count)s selected', cl.result_count)
selection_note_all = ungettext("%(total_count)s selected", "All %(total_count)s selected", cl.result_count)
context.update({
'module_name': force_text(opts.verbose_name_plural),
'selection_note': _('0 of %(cnt)s selected') % {'cnt': len(cl.result_list)},
'selection_note_all': selection_note_all % {'total_count': cl.result_count},
})
context.update(
{
"module_name": force_text(opts.verbose_name_plural),
"selection_note": _("0 of %(cnt)s selected") % {"cnt": len(cl.result_list)},
"selection_note_all": selection_note_all % {"total_count": cl.result_count},
}
)
else:
context['opts'] = self.model._meta
context["opts"] = self.model._meta
context.update(extra_context or {})
return render(request, self.change_list_template or [
'admin/%s/%s/change_list.html' % (app_label, opts.object_name.lower()),
'admin/%s/change_list.html' % app_label,
'admin/change_list.html'
], context=context)
return render(
request,
self.change_list_template
or [
"admin/%s/%s/change_list.html" % (app_label, opts.object_name.lower()),
"admin/%s/change_list.html" % app_label,
"admin/change_list.html",
],
context=context,
)
def changelist_view(self, request, extra_context=None, *args, **kwargs):
"""
Handle the changelist view, the django view for the model instances
change list/actions page.
Handle the changelist view, the django view for the model instances change list/actions page.
"""
extra_context = extra_context or {}
extra_context['EDITOR_MEDIA_PATH'] = settings.MEDIA_PATH
extra_context['EDITOR_TREE_INITIAL_STATE'] = settings.TREE_INITIAL_STATE
extra_context["EDITOR_MEDIA_PATH"] = settings.MEDIA_PATH
extra_context["EDITOR_TREE_INITIAL_STATE"] = settings.TREE_INITIAL_STATE
# FIXME
return self.old_changelist_view(request, extra_context)
def get_queryset(self, request):
def get_queryset(self, request) -> TreeEditorQuerySet:
"""
Returns a QuerySet of all model instances that can be edited by the
admin site. This is used by changelist_view.
Returns a QuerySet of all model instances that can be edited by the admin site.
This is used by changelist_view.
Args:
request: the incoming request.
Returns:
A QuerySet of editable model instances
"""
qs = self.model._default_manager.get_queryset()
qs.__class__ = TreeEditorQuerySet

View file

@ -1,10 +1,11 @@
"""
Provides compatibility with Django 1.8
Provides compatibility with Django 1.8.
"""
from django.contrib.admin.utils import display_for_field as _display_for_field
def display_for_field(value, field, empty_value_display=None):
"""Compatility for displaying a field in Django 1.8."""
try:
return _display_for_field(value, field, empty_value_display)
except TypeError:

View file

@ -1,25 +1,24 @@
"""Custom category fields for other models."""
from django.db.models import ForeignKey, ManyToManyField
class CategoryM2MField(ManyToManyField):
"""A many to many field to a Category model."""
def __init__(self, **kwargs):
from .models import Category
if 'to' in kwargs:
kwargs.pop('to')
if "to" in kwargs:
kwargs.pop("to")
super(CategoryM2MField, self).__init__(to=Category, **kwargs)
class CategoryFKField(ForeignKey):
"""A foreign key to the Category model."""
def __init__(self, **kwargs):
from .models import Category
if 'to' in kwargs:
kwargs.pop('to')
if "to" in kwargs:
kwargs.pop("to")
super(CategoryFKField, self).__init__(to=Category, **kwargs)
try:
from south.modelsinspector import add_introspection_rules
add_introspection_rules([], [r"^categories\.fields\.CategoryFKField"])
add_introspection_rules([], [r"^categories\.fields\.CategoryM2MField"])
except ImportError:
pass

View file

@ -4414,4 +4414,4 @@
}
}
]
]

View file

@ -5,4 +5,4 @@ Category 2
Category 2-1
Category 2-1-1
Category 3
Category 3-1
Category 3-1

View file

@ -5,4 +5,4 @@ Category 2
Category 2-1
Category 2-1-1
Category 3
Category 3-1
Category 3-1

View file

@ -1,15 +1,20 @@
"""Special helpers for generic collections."""
import json
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, NoReverseMatch
import json
from django.urls import NoReverseMatch, reverse
class GenericCollectionInlineModelAdmin(admin.options.InlineModelAdmin):
"""Inline admin for generic model collections."""
ct_field = "content_type"
ct_fk_field = "object_id"
def get_content_types(self):
ctypes = ContentType.objects.all().order_by('id').values_list('id', 'app_label', 'model')
"""Get the content types supported by this collection."""
ctypes = ContentType.objects.all().order_by("id").values_list("id", "app_label", "model")
elements = {}
for x, y, z in ctypes:
try:
@ -19,18 +24,23 @@ class GenericCollectionInlineModelAdmin(admin.options.InlineModelAdmin):
return json.dumps(elements)
def get_formset(self, request, obj=None, **kwargs):
"""Get the formset for the generic collection."""
result = super(GenericCollectionInlineModelAdmin, self).get_formset(request, obj, **kwargs)
result.content_types = self.get_content_types()
result.ct_fk_field = self.ct_fk_field
return result
class Media:
js = ('contentrelations/js/genericlookup.js', )
js = ("contentrelations/js/genericlookup.js",)
class GenericCollectionTabularInline(GenericCollectionInlineModelAdmin):
template = 'admin/edit_inline/gen_coll_tabular.html'
"""Tabular model admin for a generic collection."""
template = "admin/edit_inline/gen_coll_tabular.html"
class GenericCollectionStackedInline(GenericCollectionInlineModelAdmin):
template = 'admin/edit_inline/gen_coll_stacked.html'
"""Stacked model admin for a generic collection."""
template = "admin/edit_inline/gen_coll_stacked.html"

View file

@ -181,4 +181,3 @@ msgstr "Cancella?"
#: templates/admin/edit_inline/gen_coll_tabular.html:24
msgid "View on site"
msgstr "Vedi sul sito"

View file

@ -1,27 +1,30 @@
"""The add_category_fields command."""
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""
Alter one or more models' tables with the registered attributes
Alter one or more models' tables with the registered attributes.
"""
help = "Alter the tables for all registered models, or just specified models"
args = "[appname ...]"
can_import_settings = True
requires_system_checks = False
def add_arguments(self, parser):
parser.add_argument('app_names', nargs='*')
"""Add app_names argument to the command."""
parser.add_argument("app_names", nargs="*")
def handle(self, *args, **options):
"""
Alter the tables
Alter the tables.
"""
from categories.migration import migrate_app
from categories.settings import MODEL_REGISTRY
if options['app_names']:
for app in options['app_names']:
if options["app_names"]:
for app in options["app_names"]:
migrate_app(None, app)
else:
for app in MODEL_REGISTRY:

View file

@ -1,27 +1,30 @@
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
"""Alter one or more models' tables with the registered attributes."""
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
"""
Alter one or more models' tables with the registered attributes
Alter one or more models' tables with the registered attributes.
"""
help = "Drop the given field from the given model's table"
args = "appname modelname fieldname"
can_import_settings = True
requires_system_checks = False
def add_arguments(self, parser):
parser.add_argument('app_name')
parser.add_argument('model_name')
parser.add_argument('field_name')
"""Add app_name, model_name, and field_name arguments to the command."""
parser.add_argument("app_name")
parser.add_argument("model_name")
parser.add_argument("field_name")
def handle(self, *args, **options):
"""
Alter the tables
Alter the tables.
"""
from categories.migration import drop_field
if 'app_name' not in options or 'model_name' not in options or 'field_name' not in options:
if "app_name" not in options or "model_name" not in options or "field_name" not in options:
raise CommandError("You must specify an Application name, a Model name and a Field name")
drop_field(options['app_name'], options['model_name'], options['field_name'])
drop_field(options["app_name"], options["model_name"], options["field_name"])

View file

@ -1,6 +1,7 @@
"""Import category trees from a file."""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from slugify import slugify
from categories.models import Category
@ -10,35 +11,37 @@ from categories.settings import SLUG_TRANSLITERATOR
class Command(BaseCommand):
"""Import category trees from a file."""
help = "Imports category tree(s) from a file. Sub categories must be indented by the same multiple of spaces or tabs."
help = (
"Imports category tree(s) from a file. Sub categories must be indented by the same multiple of spaces or tabs."
)
args = "file_path [file_path ...]"
def get_indent(self, string):
"""
Look through the string and count the spaces
Look through the string and count the spaces.
"""
indent_amt = 0
if string[0] == '\t':
return '\t'
if string[0] == "\t":
return "\t"
for char in string:
if char == ' ':
if char == " ":
indent_amt += 1
else:
return ' ' * indent_amt
return " " * indent_amt
@transaction.atomic
def make_category(self, string, parent=None, order=1):
"""
Make and save a category object from a string
Make and save a category object from a string.
"""
cat = Category(
name=string.strip(),
slug=slugify(SLUG_TRANSLITERATOR(string.strip()))[:49],
# arent=parent,
order=order
order=order,
)
cat._tree_manager.insert_node(cat, parent, 'last-child', True)
cat._tree_manager.insert_node(cat, parent, "last-child", True)
cat.save()
if parent:
parent.rght = cat.rght + 1
@ -47,12 +50,12 @@ class Command(BaseCommand):
def parse_lines(self, lines):
"""
Do the work of parsing each line
Do the work of parsing each line.
"""
indent = ''
indent = ""
level = 0
if lines[0][0] == ' ' or lines[0][0] == '\t':
if lines[0][0] in [" ", "\t"]:
raise CommandError("The first line in the file cannot start with a space or tab.")
# This keeps track of the current parents at a given level
@ -61,10 +64,10 @@ class Command(BaseCommand):
for line in lines:
if len(line) == 0:
continue
if line[0] == ' ' or line[0] == '\t':
if indent == '':
if line[0] in [" ", "\t"]:
if indent == "":
indent = self.get_indent(line)
elif not line[0] in indent:
elif line[0] not in indent:
raise CommandError("You can't mix spaces and tabs for indents")
level = line.count(indent)
current_parents[level] = self.make_category(line, parent=current_parents[level - 1])
@ -75,7 +78,7 @@ class Command(BaseCommand):
def handle(self, *file_paths, **options):
"""
Handle the basic import
Handle the basic import.
"""
import os
@ -83,8 +86,6 @@ class Command(BaseCommand):
if not os.path.isfile(file_path):
print("File %s not found." % file_path)
continue
f = open(file_path, 'r')
data = f.readlines()
f.close()
with open(file_path, "r") as f:
data = f.readlines()
self.parse_lines(data)

View file

@ -1,14 +1,12 @@
# -*- coding: utf-8 -*-
from django.db import connection, transaction
"""Adds and removes category relations on the database."""
from django.apps import apps
from django.db import connection, transaction
from django.db.utils import ProgrammingError
def table_exists(table_name):
"""
Check if a table exists in the database
Check if a table exists in the database.
"""
pass
@ -25,7 +23,7 @@ def field_exists(app_name, model_name, field_name):
# Return True if the many to many table exists
field = model._meta.get_field(field_name)
if hasattr(field, 'm2m_db_table'):
if hasattr(field, "m2m_db_table"):
m2m_table_name = field.m2m_db_table()
m2m_field_info = connection.introspection.get_table_description(cursor, m2m_table_name)
if m2m_field_info:
@ -36,7 +34,7 @@ def field_exists(app_name, model_name, field_name):
def drop_field(app_name, model_name, field_name):
"""
Drop the given field from the app's model
Drop the given field from the app's model.
"""
app_config = apps.get_app_config(app_name)
model = app_config.get_model(model_name)
@ -47,12 +45,13 @@ def drop_field(app_name, model_name, field_name):
def migrate_app(sender, *args, **kwargs):
"""
Migrate all models of this app registered
Migrate all models of this app registered.
"""
from .registration import registry
if 'app_config' not in kwargs:
if "app_config" not in kwargs:
return
app_config = kwargs['app_config']
app_config = kwargs["app_config"]
app_name = app_config.label
@ -60,7 +59,7 @@ def migrate_app(sender, *args, **kwargs):
sid = transaction.savepoint()
for fld in fields:
model_name, field_name = fld.split('.')[1:]
model_name, field_name = fld.split(".")[1:]
if field_exists(app_name, model_name, field_name):
continue
model = app_config.get_model(model_name)

View file

@ -1,59 +1,121 @@
# -*- coding: utf-8 -*-
from django.db import models, migrations
import django.core.files.storage
import mptt.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0001_initial'),
("contenttypes", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Category',
name="Category",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=100, verbose_name='name')),
('slug', models.SlugField(verbose_name='slug')),
('active', models.BooleanField(default=True, verbose_name='active')),
('thumbnail', models.FileField(storage=django.core.files.storage.FileSystemStorage(), null=True, upload_to='uploads/categories/thumbnails', blank=True)),
('thumbnail_width', models.IntegerField(null=True, blank=True)),
('thumbnail_height', models.IntegerField(null=True, blank=True)),
('order', models.IntegerField(default=0)),
('alternate_title', models.CharField(default='', help_text='An alternative title to use on pages with this category.', max_length=100, blank=True)),
('alternate_url', models.CharField(help_text='An alternative URL to use instead of the one derived from the category hierarchy.', max_length=200, blank=True)),
('description', models.TextField(null=True, blank=True)),
('meta_keywords', models.CharField(default='', help_text='Comma-separated keywords for search engines.', max_length=255, blank=True)),
('meta_extra', models.TextField(default='', help_text='(Advanced) Any additional HTML to be placed verbatim in the &lt;head&gt;', blank=True)),
('lft', models.PositiveIntegerField(editable=False, db_index=True)),
('rght', models.PositiveIntegerField(editable=False, db_index=True)),
('tree_id', models.PositiveIntegerField(editable=False, db_index=True)),
('level', models.PositiveIntegerField(editable=False, db_index=True)),
('parent', mptt.fields.TreeForeignKey(related_name='children', verbose_name='parent', blank=True, to='categories.Category', on_delete=models.CASCADE, null=True)),
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
("name", models.CharField(max_length=100, verbose_name="name")),
("slug", models.SlugField(verbose_name="slug")),
("active", models.BooleanField(default=True, verbose_name="active")),
(
"thumbnail",
models.FileField(
storage=django.core.files.storage.FileSystemStorage(),
null=True,
upload_to="uploads/categories/thumbnails",
blank=True,
),
),
("thumbnail_width", models.IntegerField(null=True, blank=True)),
("thumbnail_height", models.IntegerField(null=True, blank=True)),
("order", models.IntegerField(default=0)),
(
"alternate_title",
models.CharField(
default="",
help_text="An alternative title to use on pages with this category.",
max_length=100,
blank=True,
),
),
(
"alternate_url",
models.CharField(
help_text="An alternative URL to use instead of the one derived from the category hierarchy.",
max_length=200,
blank=True,
),
),
("description", models.TextField(null=True, blank=True)),
(
"meta_keywords",
models.CharField(
default="",
help_text="Comma-separated keywords for search engines.",
max_length=255,
blank=True,
),
),
(
"meta_extra",
models.TextField(
default="",
help_text="(Advanced) Any additional HTML to be placed verbatim in the &lt;head&gt;",
blank=True,
),
),
("lft", models.PositiveIntegerField(editable=False, db_index=True)),
("rght", models.PositiveIntegerField(editable=False, db_index=True)),
("tree_id", models.PositiveIntegerField(editable=False, db_index=True)),
("level", models.PositiveIntegerField(editable=False, db_index=True)),
(
"parent",
mptt.fields.TreeForeignKey(
related_name="children",
verbose_name="parent",
blank=True,
to="categories.Category",
on_delete=models.CASCADE,
null=True,
),
),
],
options={
'ordering': ('tree_id', 'lft'),
'abstract': False,
'verbose_name': 'category',
'verbose_name_plural': 'categories',
"ordering": ("tree_id", "lft"),
"abstract": False,
"verbose_name": "category",
"verbose_name_plural": "categories",
},
),
migrations.CreateModel(
name='CategoryRelation',
name="CategoryRelation",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('object_id', models.PositiveIntegerField(verbose_name='object id')),
('relation_type', models.CharField(help_text="A generic text field to tag a relation, like 'leadphoto'.", max_length='200', null=True, verbose_name='relation type', blank=True)),
('category', models.ForeignKey(verbose_name='category', to='categories.Category', on_delete=models.CASCADE)),
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType', on_delete=models.CASCADE)),
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
("object_id", models.PositiveIntegerField(verbose_name="object id")),
(
"relation_type",
models.CharField(
help_text="A generic text field to tag a relation, like 'leadphoto'.",
max_length="200",
null=True,
verbose_name="relation type",
blank=True,
),
),
(
"category",
models.ForeignKey(verbose_name="category", to="categories.Category", on_delete=models.CASCADE),
),
(
"content_type",
models.ForeignKey(
verbose_name="content type", to="contenttypes.ContentType", on_delete=models.CASCADE
),
),
],
),
migrations.AlterUniqueTogether(
name='category',
unique_together=set([('parent', 'name')]),
name="category",
unique_together=set([("parent", "name")]),
),
]

View file

@ -1,27 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-17 11:11
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('categories', '0001_initial'),
("categories", "0001_initial"),
]
operations = [
migrations.AlterModelManagers(
name='category',
name="category",
managers=[
('tree', django.db.models.manager.Manager()),
("tree", django.db.models.manager.Manager()),
],
),
migrations.AlterField(
model_name='categoryrelation',
name='relation_type',
field=models.CharField(blank=True, help_text="A generic text field to tag a relation, like 'leadphoto'.", max_length=200, null=True, verbose_name='relation type'),
model_name="categoryrelation",
name="relation_type",
field=models.CharField(
blank=True,
help_text="A generic text field to tag a relation, like 'leadphoto'.",
max_length=200,
null=True,
verbose_name="relation type",
),
),
]

View file

@ -1,35 +1,44 @@
# Generated by Django 3.0.4 on 2020-03-06 10:50
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('categories', '0002_auto_20170217_1111'),
("contenttypes", "0002_remove_content_type_name"),
("categories", "0002_auto_20170217_1111"),
]
operations = [
migrations.AlterField(
model_name='category',
name='level',
model_name="category",
name="level",
field=models.PositiveIntegerField(editable=False),
),
migrations.AlterField(
model_name='category',
name='lft',
model_name="category",
name="lft",
field=models.PositiveIntegerField(editable=False),
),
migrations.AlterField(
model_name='category',
name='rght',
model_name="category",
name="rght",
field=models.PositiveIntegerField(editable=False),
),
migrations.AlterField(
model_name='categoryrelation',
name='content_type',
field=models.ForeignKey(limit_choices_to=models.Q(models.Q(('app_label', 'simpletext'), ('model', 'simpletext')), models.Q(('app_label', 'flatpages'), ('model', 'flatpage')), _connector='OR'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type'),
model_name="categoryrelation",
name="content_type",
field=models.ForeignKey(
limit_choices_to=models.Q(
models.Q(("app_label", "simpletext"), ("model", "simpletext")),
models.Q(("app_label", "flatpages"), ("model", "flatpage")),
_connector="OR",
),
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.ContentType",
verbose_name="content type",
),
),
]

View file

@ -1,20 +1,22 @@
# Generated by Django 3.0.6 on 2020-05-17 18:32
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('categories', '0003_auto_20200306_1050'),
("contenttypes", "0002_remove_content_type_name"),
("categories", "0003_auto_20200306_1050"),
]
operations = [
migrations.AlterField(
model_name='categoryrelation',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type'),
model_name="categoryrelation",
name="content_type",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.ContentType", verbose_name="content type"
),
),
]

View file

@ -1,86 +1,95 @@
from django.core.files.images import get_image_dimensions
from django.urls import reverse
from django.db import models
from django.utils.encoding import force_text
from django.contrib.contenttypes.models import ContentType
"""Category models."""
from functools import reduce
from django.contrib.contenttypes.models import ContentType
from django.core.files.images import get_image_dimensions
from django.db import models
from django.urls import reverse
from django.utils.encoding import force_text
try:
from django.contrib.contenttypes.fields import GenericForeignKey
except ImportError:
from django.contrib.contenttypes.generic import GenericForeignKey
from django.core.files.storage import get_storage_class
from django.core.files.storage import get_storage_class
from django.utils.translation import ugettext_lazy as _
from .settings import (RELATION_MODELS, RELATIONS, THUMBNAIL_UPLOAD_PATH, THUMBNAIL_STORAGE)
from .base import CategoryBase
from .settings import (
RELATION_MODELS,
RELATIONS,
THUMBNAIL_STORAGE,
THUMBNAIL_UPLOAD_PATH,
)
STORAGE = get_storage_class(THUMBNAIL_STORAGE)
class Category(CategoryBase):
"""A basic category model."""
thumbnail = models.FileField(
upload_to=THUMBNAIL_UPLOAD_PATH,
null=True, blank=True,
storage=STORAGE(),)
null=True,
blank=True,
storage=STORAGE(),
)
thumbnail_width = models.IntegerField(blank=True, null=True)
thumbnail_height = models.IntegerField(blank=True, null=True)
order = models.IntegerField(default=0)
alternate_title = models.CharField(
blank=True,
default="",
max_length=100,
help_text="An alternative title to use on pages with this category.")
blank=True, default="", max_length=100, help_text="An alternative title to use on pages with this category."
)
alternate_url = models.CharField(
blank=True,
max_length=200,
help_text="An alternative URL to use instead of the one derived from "
"the category hierarchy.")
help_text="An alternative URL to use instead of the one derived from " "the category hierarchy.",
)
description = models.TextField(blank=True, null=True)
meta_keywords = models.CharField(
blank=True,
default="",
max_length=255,
help_text="Comma-separated keywords for search engines.")
blank=True, default="", max_length=255, help_text="Comma-separated keywords for search engines."
)
meta_extra = models.TextField(
blank=True,
default="",
help_text="(Advanced) Any additional HTML to be placed verbatim "
"in the &lt;head&gt;")
blank=True, default="", help_text="(Advanced) Any additional HTML to be placed verbatim " "in the &lt;head&gt;"
)
@property
def short_title(self):
"""Return the name."""
return self.name
def get_absolute_url(self):
"""Return a path"""
"""Return a path."""
from django.urls import NoReverseMatch
if self.alternate_url:
return self.alternate_url
try:
prefix = reverse('categories_tree_list')
prefix = reverse("categories_tree_list")
except NoReverseMatch:
prefix = '/'
ancestors = list(self.get_ancestors()) + [self, ]
return prefix + '/'.join([force_text(i.slug) for i in ancestors]) + '/'
prefix = "/"
ancestors = list(self.get_ancestors()) + [
self,
]
return prefix + "/".join([force_text(i.slug) for i in ancestors]) + "/"
if RELATION_MODELS:
def get_related_content_type(self, content_type):
"""
Get all related items of the specified content type
Get all related items of the specified content type.
"""
return self.categoryrelation_set.filter(
content_type__name=content_type)
return self.categoryrelation_set.filter(content_type__name=content_type)
def get_relation_type(self, relation_type):
"""
Get all relations of the specified relation type
Get all relations of the specified relation type.
"""
return self.categoryrelation_set.filter(relation_type=relation_type)
def save(self, *args, **kwargs):
"""Save the category."""
if self.thumbnail:
width, height = get_image_dimensions(self.thumbnail.file)
else:
@ -92,11 +101,11 @@ class Category(CategoryBase):
super(Category, self).save(*args, **kwargs)
class Meta(CategoryBase.Meta):
verbose_name = _('category')
verbose_name_plural = _('categories')
verbose_name = _("category")
verbose_name_plural = _("categories")
class MPTTMeta:
order_insertion_by = ('order', 'name')
order_insertion_by = ("order", "name")
if RELATIONS:
@ -106,6 +115,8 @@ else:
class CategoryRelationManager(models.Manager):
"""Custom access functions for category relations."""
def get_content_type(self, content_type):
"""
Get all the items of the given content type related to this item.
@ -122,18 +133,24 @@ class CategoryRelationManager(models.Manager):
class CategoryRelation(models.Model):
"""Related category item"""
category = models.ForeignKey(Category, verbose_name=_('category'), on_delete=models.CASCADE)
"""Related category item."""
category = models.ForeignKey(Category, verbose_name=_("category"), on_delete=models.CASCADE)
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, limit_choices_to=CATEGORY_RELATION_LIMITS, verbose_name=_('content type'))
object_id = models.PositiveIntegerField(verbose_name=_('object id'))
content_object = GenericForeignKey('content_type', 'object_id')
ContentType,
on_delete=models.CASCADE,
limit_choices_to=CATEGORY_RELATION_LIMITS,
verbose_name=_("content type"),
)
object_id = models.PositiveIntegerField(verbose_name=_("object id"))
content_object = GenericForeignKey("content_type", "object_id")
relation_type = models.CharField(
verbose_name=_('relation type'),
verbose_name=_("relation type"),
max_length=200,
blank=True,
null=True,
help_text=_("A generic text field to tag a relation, like 'leadphoto'."))
help_text=_("A generic text field to tag a relation, like 'leadphoto'."),
)
objects = CategoryRelationManager()

View file

@ -1,44 +1,60 @@
"""
These functions handle the adding of fields to other models
These functions handle the adding of fields to other models.
"""
from django.db.models import ForeignKey, ManyToManyField, CASCADE
from django.core.exceptions import FieldDoesNotExist
from . import fields
from typing import Optional, Type, Union
import collections
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db.models import CASCADE, ForeignKey, ManyToManyField
# from settings import self._field_registry, self._model_registry
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ImproperlyConfigured
from . import fields
FIELD_TYPES = {
'ForeignKey': ForeignKey,
'ManyToManyField': ManyToManyField,
"ForeignKey": ForeignKey,
"ManyToManyField": ManyToManyField,
}
class Registry(object):
"""Keeps track of fields and models registered."""
def __init__(self):
self._field_registry = {}
self._model_registry = {}
def register_model(self, app, model_name, field_type, field_definitions):
def register_model(
self, app: str, model_name, field_type: str, field_definitions: Union[str, collections.Iterable]
):
"""
Process for Django 1.7 +
app: app name/label
model_name: name of the model
field_definitions: a string, tuple or list of field configurations
field_type: either 'ForeignKey' or 'ManyToManyField'
Registration process for Django 1.7+.
Args:
app: app name/label
model_name: name of the model
field_definitions: a string, tuple or list of field configurations
field_type: either 'ForeignKey' or 'ManyToManyField'
Raises:
ImproperlyConfigured: For incorrect parameter types or missing model.
"""
from django.apps import apps
import collections
from django.apps import apps
app_label = app
if isinstance(field_definitions, str):
field_definitions = [field_definitions]
elif not isinstance(field_definitions, collections.Iterable):
raise ImproperlyConfigured(_('Field configuration for %(app)s should be a string or iterable') % {'app': app})
raise ImproperlyConfigured(
_("Field configuration for %(app)s should be a string or iterable") % {"app": app}
)
if field_type not in ('ForeignKey', 'ManyToManyField'):
if field_type not in ("ForeignKey", "ManyToManyField"):
raise ImproperlyConfigured(_('`field_type` must be either `"ForeignKey"` or `"ManyToManyField"`.'))
try:
@ -55,30 +71,31 @@ class Registry(object):
if model not in self._model_registry[app_label]:
self._model_registry[app_label].append(model)
except LookupError:
raise ImproperlyConfigured('Model "%(model)s" doesn\'t exist in app "%(app)s".' % {'model': model_name, 'app': app})
raise ImproperlyConfigured(
'Model "%(model)s" doesn\'t exist in app "%(app)s".' % {"model": model_name, "app": app}
)
if not isinstance(field_definitions, (tuple, list)):
field_definitions = [field_definitions]
for fld in field_definitions:
extra_params = {'to': 'categories.Category', 'blank': True}
if field_type != 'ManyToManyField':
extra_params['on_delete'] = CASCADE
extra_params['null'] = True
extra_params = {"to": "categories.Category", "blank": True}
if field_type != "ManyToManyField":
extra_params["on_delete"] = CASCADE
extra_params["null"] = True
if isinstance(fld, str):
field_name = fld
elif isinstance(fld, dict):
if 'name' in fld:
field_name = fld.pop('name')
if "name" in fld:
field_name = fld.pop("name")
else:
continue
extra_params.update(fld)
else:
raise ImproperlyConfigured(
_("%(settings)s doesn't recognize the value of %(app)s.%(model)s") % {
'settings': 'CATEGORY_SETTINGS',
'app': app,
'model': model_name})
_("%(settings)s doesn't recognize the value of %(app)s.%(model)s")
% {"settings": "CATEGORY_SETTINGS", "app": app, "model": model_name}
)
registry_name = ".".join([app_label, model_name.lower(), field_name])
if registry_name in self._field_registry:
continue
@ -89,14 +106,26 @@ class Registry(object):
self._field_registry[registry_name] = FIELD_TYPES[field_type](**extra_params)
self._field_registry[registry_name].contribute_to_class(model, field_name)
def register_m2m(self, model, field_name='categories', extra_params={}):
def register_m2m(self, model, field_name: str = "categories", extra_params: Optional[dict] = None):
"""Register a field name to the model as a many to many field."""
extra_params = extra_params or {}
return self._register(model, field_name, extra_params, fields.CategoryM2MField)
def register_fk(self, model, field_name='category', extra_params={}):
def register_fk(self, model, field_name: str = "category", extra_params: Optional[dict] = None):
"""Register a field name to the model as a foreign key."""
extra_params = extra_params or {}
return self._register(model, field_name, extra_params, fields.CategoryFKField)
def _register(self, model, field_name, extra_params={}, field=fields.CategoryFKField):
def _register(
self,
model,
field_name: str,
extra_params: Optional[dict] = None,
field: Type = fields.CategoryFKField,
):
"""Does the heavy lifting for registering a field to a model."""
app_label = model._meta.app_label
extra_params = extra_params or {}
registry_name = ".".join((app_label, model.__name__, field_name)).lower()
if registry_name in self._field_registry:
@ -118,30 +147,34 @@ registry = Registry()
def _process_registry(registry, call_func):
"""
Given a dictionary, and a registration function, process the registry
Given a dictionary, and a registration function, process the registry.
"""
from django.core.exceptions import ImproperlyConfigured
from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
for key, value in list(registry.items()):
model = apps.get_model(*key.split('.'))
model = apps.get_model(*key.split("."))
if model is None:
raise ImproperlyConfigured(_('%(key)s is not a model') % {'key': key})
raise ImproperlyConfigured(_("%(key)s is not a model") % {"key": key})
if isinstance(value, (tuple, list)):
for item in value:
if isinstance(item, str):
call_func(model, item)
elif isinstance(item, dict):
field_name = item.pop('name')
field_name = item.pop("name")
call_func(model, field_name, extra_params=item)
else:
raise ImproperlyConfigured(_("%(settings)s doesn't recognize the value of %(key)s") %
{'settings': 'CATEGORY_SETTINGS', 'key': key})
raise ImproperlyConfigured(
_("%(settings)s doesn't recognize the value of %(key)s")
% {"settings": "CATEGORY_SETTINGS", "key": key}
)
elif isinstance(value, str):
call_func(model, value)
elif isinstance(value, dict):
field_name = value.pop('name')
field_name = value.pop("name")
call_func(model, field_name, extra_params=value)
else:
raise ImproperlyConfigured(_("%(settings)s doesn't recognize the value of %(key)s") %
{'settings': 'CATEGORY_SETTINGS', 'key': key})
raise ImproperlyConfigured(
_("%(settings)s doesn't recognize the value of %(key)s")
% {"settings": "CATEGORY_SETTINGS", "key": key}
)

View file

@ -1,42 +1,47 @@
"""Manages settings for the categories application."""
import collections
from django.conf import settings
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
import collections
DEFAULT_SETTINGS = {
'ALLOW_SLUG_CHANGE': False,
'M2M_REGISTRY': {},
'FK_REGISTRY': {},
'THUMBNAIL_UPLOAD_PATH': 'uploads/categories/thumbnails',
'THUMBNAIL_STORAGE': settings.DEFAULT_FILE_STORAGE,
'JAVASCRIPT_URL': getattr(settings, 'STATIC_URL', settings.MEDIA_URL) + 'js/',
'SLUG_TRANSLITERATOR': '',
'REGISTER_ADMIN': True,
'RELATION_MODELS': [],
"ALLOW_SLUG_CHANGE": False,
"M2M_REGISTRY": {},
"FK_REGISTRY": {},
"THUMBNAIL_UPLOAD_PATH": "uploads/categories/thumbnails",
"THUMBNAIL_STORAGE": settings.DEFAULT_FILE_STORAGE,
"JAVASCRIPT_URL": getattr(settings, "STATIC_URL", settings.MEDIA_URL) + "js/",
"SLUG_TRANSLITERATOR": "",
"REGISTER_ADMIN": True,
"RELATION_MODELS": [],
}
DEFAULT_SETTINGS.update(getattr(settings, 'CATEGORIES_SETTINGS', {}))
DEFAULT_SETTINGS.update(getattr(settings, "CATEGORIES_SETTINGS", {}))
if DEFAULT_SETTINGS['SLUG_TRANSLITERATOR']:
if isinstance(DEFAULT_SETTINGS['SLUG_TRANSLITERATOR'], collections.Callable):
if DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"]:
if isinstance(DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"], collections.Callable):
pass
elif isinstance(DEFAULT_SETTINGS['SLUG_TRANSLITERATOR'], str):
elif isinstance(DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"], str):
from django.utils.importlib import import_module
bits = DEFAULT_SETTINGS['SLUG_TRANSLITERATOR'].split(".")
bits = DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"].split(".")
module = import_module(".".join(bits[:-1]))
DEFAULT_SETTINGS['SLUG_TRANSLITERATOR'] = getattr(module, bits[-1])
DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"] = getattr(module, bits[-1])
else:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured(_('%(transliterator) must be a callable or a string.') %
{'transliterator': 'SLUG_TRANSLITERATOR'})
raise ImproperlyConfigured(
_("%(transliterator) must be a callable or a string.") % {"transliterator": "SLUG_TRANSLITERATOR"}
)
else:
DEFAULT_SETTINGS['SLUG_TRANSLITERATOR'] = lambda x: x
DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"] = lambda x: x
# Add all the keys/values to the module's namespace
globals().update(DEFAULT_SETTINGS)
RELATIONS = [Q(app_label=al, model=m) for al, m in [x.split('.') for x in DEFAULT_SETTINGS['RELATION_MODELS']]]
RELATIONS = [Q(app_label=al, model=m) for al, m in [x.split(".") for x in DEFAULT_SETTINGS["RELATION_MODELS"]]]
# The field registry keeps track of the individual fields created.
# {'app.model.field': Field(**extra_params)}

View file

@ -17,4 +17,4 @@ function showGenericRelatedObjectLookupPopup(triggeringLink, ctArray) {
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
win.focus();
return false;
}
}

View file

@ -8,4 +8,4 @@
{% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a>
{% endifequal %}
{% for level in structure.closed_levels %}</li></ul>{% endfor %}
{% endfor %}</li></ul>
{% endfor %}</li></ul>

View file

@ -1,3 +1,3 @@
{% extends 'base.html' %}
{% block content %}
{% endblock %}
{% endblock %}

View file

@ -1,3 +1,3 @@
{% spaceless %}{% for item in category.get_ancestors %}
<a href="{{ item.get_absolute_url }}">{{ item.name }}</a>{{ separator }}{% endfor %}{{ category.name }}
{% endspaceless %}
{% endspaceless %}

View file

@ -14,4 +14,4 @@
{% if category.parent %}<h3>Go up to <a href="{{ category.parent.get_absolute_url }}">{{ category.parent }}</a></h3>{% endif %}
{% if category.description %}<p>{{ category.description }}</p>{% endif %}
{% if category.children.count %}<h3>Subcategories</h3><ul>{% for child in category.children.all %}<li><a href="{{ child.get_absolute_url }}">{{ child }}</a></li>{% endfor %}</ul>{% endif %}
{% endblock %}
{% endblock %}

View file

@ -2,4 +2,4 @@
{% block content %}
<h2>Categories</h2>
<ul>{% for category in object_list %}<li><a href="{{ category.get_absolute_url }}">{{ category }}</a></li>{% endfor %}</ul>
{% endblock %}
{% endblock %}

View file

@ -8,4 +8,4 @@
{% else %}<a href="{{ node.get_absolute_url }}">{{ node.name }}</a>
{% endifequal %}
{% for level in structure.closed_levels %}</li></ul>{% endfor %}
{% endfor %}</li></ul>{% endspaceless %}
{% endfor %}</li></ul>{% endspaceless %}

View file

@ -1,13 +1,20 @@
"""Template tags for categories."""
from typing import Any, Type, Union
from django import template
from django.apps import apps
from django.template import (Node, TemplateSyntaxError, VariableDoesNotExist)
from django.template import Node, TemplateSyntaxError, VariableDoesNotExist
from django.template.base import FilterExpression
from six import string_types
from mptt.templatetags.mptt_tags import (
RecurseTreeNode,
full_tree_for_model,
tree_info,
tree_path,
)
from mptt.utils import drilldown_tree_for_node
from categories.base import CategoryBase
from categories.models import Category
from mptt.utils import drilldown_tree_for_node
from mptt.templatetags.mptt_tags import (tree_path, tree_info, RecurseTreeNode,
full_tree_for_model)
register = template.Library()
@ -16,7 +23,8 @@ register.filter(tree_info)
register.tag("full_tree_for_category", full_tree_for_model)
def resolve(var, context):
def resolve(var: Any, context: dict) -> Any:
"""Aggressively resolve a variable."""
try:
return var.resolve(context)
except VariableDoesNotExist:
@ -28,10 +36,11 @@ def resolve(var, context):
def get_cat_model(model):
"""
Return a class from a string or class
Return a class from a string or class.
"""
model_class = None
try:
if isinstance(model, string_types):
if isinstance(model, str):
model_class = apps.get_model(*model.split("."))
elif issubclass(model, CategoryBase):
model_class = model
@ -42,15 +51,22 @@ def get_cat_model(model):
return model_class
def get_category(category_string, model=Category):
def get_category(category_string, model: Union[str, Type] = Category) -> Any:
"""
Convert a string, including a path, and return the Category object
Convert a string, including a path, and return the Category object.
Args:
category_string: The name or path of the category
model: The name of or Category model to search in
Returns:
The found category object or None if no category was found
"""
model_class = get_cat_model(model)
category = str(category_string).strip("'\"")
category = category.strip('/')
category = category.strip("/")
cat_list = category.split('/')
cat_list = category.split("/")
if len(cat_list) == 0:
return None
try:
@ -61,176 +77,217 @@ def get_category(category_string, model=Category):
# if the parent matches the parent passed in the string
if len(categories) == 1:
return categories[0]
else:
for item in categories:
if item.parent.name == cat_list[-2]:
return item
for item in categories:
if item.parent.name == cat_list[-2]:
return item
except model_class.DoesNotExist:
return None
class CategoryDrillDownNode(template.Node):
"""A category drill down template node."""
def __init__(self, category, varname, model):
self.category = category
self.varname = varname
self.model = model
def render(self, context):
"""Render this node."""
category = resolve(self.category, context)
if isinstance(category, CategoryBase):
cat = category
else:
cat = get_category(category, self.model)
try:
if cat is not None:
context[self.varname] = drilldown_tree_for_node(cat)
else:
context[self.varname] = []
context[self.varname] = drilldown_tree_for_node(cat) if cat is not None else []
except Exception:
context[self.varname] = []
return ''
return ""
@register.tag
def get_category_drilldown(parser, token):
"""
Retrieves the specified category, its ancestors and its immediate children
as an iterable.
Retrieves the specified category, its ancestors and its immediate children as an Iterable.
Syntax::
The basic syntax::
{% get_category_drilldown "category name" [using "app.Model"] as varname %}
Example::
Example:
Using a string for the category name::
{% get_category_drilldown "/Grandparent/Parent" [using "app.Model"] as family %}
{% get_category_drilldown "/Grandparent/Parent" as family %}
or ::
or using a variable for the category::
{% get_category_drilldown category_obj as family %}
{% get_category_drilldown category_obj as family %}
Sets family to::
Sets family to::
Grandparent, Parent, Child 1, Child 2, Child n
Grandparent, Parent, Child 1, Child 2, Child n
Args:
parser: The Django template parser.
token: The tag contents
Returns:
The recursive tree node.
Raises:
TemplateSyntaxError: If the tag is malformed.
"""
bits = token.split_contents()
error_str = '%(tagname)s tag should be in the format {%% %(tagname)s ' \
'"category name" [using "app.Model"] as varname %%} or ' \
'{%% %(tagname)s category_obj as varname %%}.'
error_str = (
"%(tagname)s tag should be in the format {%% %(tagname)s "
'"category name" [using "app.Model"] as varname %%} or '
"{%% %(tagname)s category_obj as varname %%}."
)
varname = model = ""
if len(bits) == 4:
if bits[2] != 'as':
raise template.TemplateSyntaxError(error_str % {'tagname': bits[0]})
if bits[2] == 'as':
if bits[2] != "as":
raise template.TemplateSyntaxError(error_str % {"tagname": bits[0]})
if bits[2] == "as":
varname = bits[3].strip("'\"")
model = "categories.category"
if len(bits) == 6:
if bits[2] not in ('using', 'as') or bits[4] not in ('using', 'as'):
raise template.TemplateSyntaxError(error_str % {'tagname': bits[0]})
if bits[2] == 'as':
elif len(bits) == 6:
if bits[2] not in ("using", "as") or bits[4] not in ("using", "as"):
raise template.TemplateSyntaxError(error_str % {"tagname": bits[0]})
if bits[2] == "as":
varname = bits[3].strip("'\"")
model = bits[5].strip("'\"")
if bits[2] == 'using':
if bits[2] == "using":
varname = bits[5].strip("'\"")
model = bits[3].strip("'\"")
else:
raise template.TemplateSyntaxError(error_str % {"tagname": bits[0]})
category = FilterExpression(bits[1], parser)
return CategoryDrillDownNode(category, varname, model)
@register.inclusion_tag('categories/breadcrumbs.html')
def breadcrumbs(category_string, separator=' > ', using='categories.category'):
@register.inclusion_tag("categories/breadcrumbs.html")
def breadcrumbs(category_string, separator=" > ", using="categories.category"):
"""
{% breadcrumbs category separator="::" using="categories.category" %}
Render breadcrumbs, using the ``categories/breadcrumbs.html`` template.
Render breadcrumbs, using the ``categories/breadcrumbs.html`` template,
using the optional ``separator`` argument.
The basic syntax::
{% breadcrumbs "category" [separator=" > "] [using="categories.category"] %}
Args:
category_string: A variable reference to or the name of the category to display
separator: The string to separate the categories
using: A variable reference to or the name of the category model to search for.
Returns:
The inclusion template
"""
cat = get_category(category_string, using)
return {'category': cat, 'separator': separator}
return {"category": cat, "separator": separator}
@register.inclusion_tag('categories/ul_tree.html')
def display_drilldown_as_ul(category, using='categories.Category'):
@register.inclusion_tag("categories/ul_tree.html")
def display_drilldown_as_ul(category, using="categories.Category"):
"""
Render the category with ancestors and children using the
``categories/ul_tree.html`` template.
Render the category with ancestors and children using the ``categories/ul_tree.html`` template.
Example::
Example:
The template code of::
{% display_drilldown_as_ul "/Grandparent/Parent" %}
{% display_drilldown_as_ul "/Grandparent/Parent" %}
or ::
or::
{% display_drilldown_as_ul category_obj %}
{% display_drilldown_as_ul category_obj %}
Returns::
might render as::
<ul>
<li><a href="/categories/">Top</a>
<ul>
<li><a href="/categories/grandparent/">Grandparent</a>
<ul>
<li><a href="/categories/grandparent/parent/">Parent</a>
<li><a href="/categories/">Top</a>
<ul>
<li><a href="/categories/grandparent/parent/child1">Child1</a></li>
<li><a href="/categories/grandparent/parent/child2">Child2</a></li>
<li><a href="/categories/grandparent/parent/child3">Child3</a></li>
<li><a href="/categories/grandparent/">Grandparent</a>
<ul>
<li><a href="/categories/grandparent/parent/">Parent</a>
<ul>
<li><a href="/categories/grandparent/parent/child1">Child1</a></li>
<li><a href="/categories/grandparent/parent/child2">Child2</a></li>
<li><a href="/categories/grandparent/parent/child3">Child3</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
</ul>
Args:
category: A variable reference to or the name of the category to display
using: A variable reference to or the name of the category model to search for.
Returns:
The inclusion template
"""
cat = get_category(category, using)
if cat is None:
return {'category': cat, 'path': []}
return {"category": cat, "path": []}
else:
return {'category': cat, 'path': drilldown_tree_for_node(cat)}
return {"category": cat, "path": drilldown_tree_for_node(cat)}
@register.inclusion_tag('categories/ul_tree.html')
def display_path_as_ul(category, using='categories.Category'):
@register.inclusion_tag("categories/ul_tree.html")
def display_path_as_ul(category, using="categories.Category"):
"""
Render the category with ancestors, but no children using the
``categories/ul_tree.html`` template.
Render the category with ancestors, but no children using the ``categories/ul_tree.html`` template.
Example::
Examples:
The template code of::
{% display_path_as_ul "/Grandparent/Parent" %}
{% display_path_as_ul "/Grandparent/Parent" %}
or ::
or::
{% display_path_as_ul category_obj %}
{% display_path_as_ul category_obj %}
Returns::
might render as::
<ul>
<li><a href="/categories/">Top</a>
<ul>
<li><a href="/categories/grandparent/">Grandparent</a></li>
<li><a href="/categories/">Top</a>
<ul>
<li><a href="/categories/grandparent/">Grandparent</a></li>
</ul>
</li>
</ul>
</li>
</ul>
Args:
category: A variable reference to or the name of the category to display
using: A variable reference to or the name of the category model to search for.
Returns:
The inclusion template
"""
if isinstance(category, CategoryBase):
cat = category
else:
cat = get_category(category)
cat = get_category(category, using)
return {'category': cat, 'path': cat.get_ancestors() or []}
return {"category": cat, "path": cat.get_ancestors() or []}
class TopLevelCategoriesNode(template.Node):
"""Template node for the top level categories."""
def __init__(self, varname, model):
self.varname = varname
self.model = model
def render(self, context):
"""Render this node."""
model = get_cat_model(self.model)
context[self.varname] = model.objects.filter(parent=None).order_by('name')
return ''
context[self.varname] = model.objects.filter(parent=None).order_by("name")
return ""
@register.tag
@ -238,51 +295,63 @@ def get_top_level_categories(parser, token):
"""
Retrieves an alphabetical list of all the categories that have no parents.
Syntax::
The basic syntax is::
{% get_top_level_categories [using "app.Model"] as categories %}
Returns an list of categories [<category>, <category>, <category, ...]
Args:
parser: The Django template parser.
token: The tag contents
Returns:
Returns a list of categories [<category>, <category>, <category, ...]
Raises:
TemplateSyntaxError: If a queryset isn't provided
"""
bits = token.split_contents()
usage = 'Usage: {%% %s [using "app.Model"] as <variable> %%}' % bits[0]
if len(bits) == 3:
if bits[1] != 'as':
if bits[1] != "as":
raise template.TemplateSyntaxError(usage)
varname = bits[2]
model = "categories.category"
elif len(bits) == 5:
if bits[1] not in ('as', 'using') and bits[3] not in ('as', 'using'):
if bits[1] not in ("as", "using") and bits[3] not in ("as", "using"):
raise template.TemplateSyntaxError(usage)
if bits[1] == 'using':
if bits[1] == "using":
model = bits[2].strip("'\"")
varname = bits[4].strip("'\"")
else:
model = bits[4].strip("'\"")
varname = bits[2].strip("'\"")
else:
raise template.TemplateSyntaxError(usage)
return TopLevelCategoriesNode(varname, model)
def get_latest_objects_by_category(category, app_label, model_name, set_name, date_field='pub_date', num=15):
def get_latest_objects_by_category(category, app_label, model_name, set_name, date_field="pub_date", num=15):
"""Return a queryset of the latest objects of ``app_label.model_name`` in given category."""
m = apps.get_model(app_label, model_name)
if not isinstance(category, CategoryBase):
category = Category.objects.get(slug=str(category))
children = category.children.all()
ids = []
for cat in list(children) + [category]:
if hasattr(cat, '%s_set' % set_name):
ids.extend([x.pk for x in getattr(cat, '%s_set' % set_name).all()[:num]])
if hasattr(cat, "%s_set" % set_name):
ids.extend([x.pk for x in getattr(cat, "%s_set" % set_name).all()[:num]])
return m.objects.filter(pk__in=ids).order_by('-%s' % date_field)[:num]
return m.objects.filter(pk__in=ids).order_by("-%s" % date_field)[:num]
class LatestObjectsNode(Node):
def __init__(self, var_name, category, app_label, model_name, set_name,
date_field='pub_date', num=15):
"""
Get latest objects of app_label.model_name
"""
"""
Get latest objects of app_label.model_name.
"""
def __init__(self, var_name, category, app_label, model_name, set_name, date_field="pub_date", num=15):
self.category = category
self.app_label = app_label
self.model_name = model_name
@ -293,7 +362,7 @@ class LatestObjectsNode(Node):
def render(self, context):
"""
Render this sucker
Render this sucker.
"""
category = resolve(self.category, context)
app_label = resolve(self.app_label, context)
@ -305,19 +374,35 @@ class LatestObjectsNode(Node):
result = get_latest_objects_by_category(category, app_label, model_name, set_name, date_field, num)
context[self.var_name] = result
return ''
return ""
def do_get_latest_objects_by_category(parser, token):
"""
Get the latest objects by category
Get the latest objects by category.
{% get_latest_objects_by_category category app_name model_name set_name [date_field] [number] as [var_name] %}
The basic syntax is::
{% get_latest_objects_by_category category app_name model_name set_name [date_field] [number] as [var_name] %}
Args:
parser: The Django template parser.
token: The tag contents
Returns:
The latet objects node.
Raises:
TemplateSyntaxError: If the tag is malformed
"""
proper_form = "{% get_latest_objects_by_category category app_name model_name set_name [date_field] [number] as [var_name] %}"
proper_form = (
"{% get_latest_objects_by_category category app_name model_name set_name "
"[date_field] [number] as [var_name] %}"
)
bits = token.split_contents()
if bits[-2] != 'as':
if bits[-2] != "as":
raise TemplateSyntaxError("%s tag shoud be in the form: %s" % (bits[0], proper_form))
if len(bits) < 7:
raise TemplateSyntaxError("%s tag shoud be in the form: %s" % (bits[0], proper_form))
@ -328,11 +413,11 @@ def do_get_latest_objects_by_category(parser, token):
model_name = FilterExpression(bits[3], parser)
set_name = FilterExpression(bits[4], parser)
var_name = bits[-1]
if bits[5] != 'as':
if bits[5] != "as":
date_field = FilterExpression(bits[5], parser)
else:
date_field = FilterExpression(None, parser)
if bits[6] != 'as':
if bits[6] != "as":
num = FilterExpression(bits[6], parser)
else:
num = FilterExpression(None, parser)
@ -345,11 +430,20 @@ register.tag("get_latest_objects_by_category", do_get_latest_objects_by_category
@register.filter
def tree_queryset(value):
"""
Converts a normal queryset from an MPTT model to include all the ancestors
so a filtered subset of items can be formatted correctly
Converts a normal queryset from an MPTT model to include all the ancestors.
Allows a filtered subset of items to be formatted correctly
Args:
value: The queryset to convert
Returns:
The converted queryset
"""
from django.db.models.query import QuerySet
from copy import deepcopy
from django.db.models.query import QuerySet
if not isinstance(value, QuerySet):
return value
@ -365,9 +459,9 @@ def tree_queryset(value):
# this cuts down the number of queries considerably since all ancestors
# will already be in include_pages when they are checked, thus not
# trigger additional queries.
for p in qs2.order_by('rght').iterator():
for p in qs2.order_by("rght").iterator():
if p.parent_id and p.parent_id not in include_pages and p.id not in include_pages:
ancestor_id_list = p.get_ancestors().values_list('id', flat=True)
ancestor_id_list = p.get_ancestors().values_list("id", flat=True)
include_pages.update(ancestor_id_list)
if include_pages:
@ -381,10 +475,13 @@ def tree_queryset(value):
def recursetree(parser, token):
"""
Iterates over the nodes in the tree, and renders the contained block for each node.
This tag will recursively render children into the template variable {{ children }}.
Only one database query is required (children are cached for the whole tree)
Usage:
Example:
Basic usage example::
<ul>
{% recursetree nodes %}
<li>
@ -397,13 +494,23 @@ def recursetree(parser, token):
</li>
{% endrecursetree %}
</ul>
Args:
parser: The Django template parser.
token: The tag contents
Returns:
The recursive tree node.
Raises:
TemplateSyntaxError: If a queryset isn't provided.
"""
bits = token.contents.split()
if len(bits) != 2:
raise template.TemplateSyntaxError('%s tag requires a queryset' % bits[0])
raise template.TemplateSyntaxError("%s tag requires a queryset" % bits[0])
queryset_var = FilterExpression(bits[1], parser)
template_nodes = parser.parse(('endrecursetree',))
template_nodes = parser.parse(("endrecursetree",))
parser.delete_first_token()
return RecurseTreeNode(template_nodes, queryset_var)

View file

@ -1,68 +1,67 @@
# -*- coding: utf-8 -*-
from django.contrib.auth.models import User
from django.urls import reverse
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.encoding import smart_text
from categories.models import Category
class TestCategoryAdmin(TestCase):
def setUp(self):
self.client = Client()
def test_adding_parent_and_child(self):
User.objects.create_superuser('testuser', 'testuser@example.com', 'password')
self.client.login(username='testuser', password='password')
url = reverse('admin:categories_category_add')
User.objects.create_superuser("testuser", "testuser@example.com", "password")
self.client.login(username="testuser", password="password")
url = reverse("admin:categories_category_add")
data = {
'parent': '',
'name': smart_text('Parent Catégory'),
'thumbnail': '',
'filename': '',
'active': 'on',
'alternate_title': '',
'alternate_url': '',
'description': '',
'meta_keywords': '',
'meta_extra': '',
'order': 0,
'slug': 'parent',
'_save': '_save',
'categoryrelation_set-TOTAL_FORMS': '0',
'categoryrelation_set-INITIAL_FORMS': '0',
'categoryrelation_set-MIN_NUM_FORMS': '1000',
'categoryrelation_set-MAX_NUM_FORMS': '1000',
"parent": "",
"name": smart_text("Parent Catégory"),
"thumbnail": "",
"filename": "",
"active": "on",
"alternate_title": "",
"alternate_url": "",
"description": "",
"meta_keywords": "",
"meta_extra": "",
"order": 0,
"slug": "parent",
"_save": "_save",
"categoryrelation_set-TOTAL_FORMS": "0",
"categoryrelation_set-INITIAL_FORMS": "0",
"categoryrelation_set-MIN_NUM_FORMS": "1000",
"categoryrelation_set-MAX_NUM_FORMS": "1000",
}
resp = self.client.post(url, data=data)
self.assertEqual(resp.status_code, 302)
self.assertEqual(1, Category.objects.count())
# update parent
data.update({'name': smart_text('Parent Catégory (Changed)')})
resp = self.client.post(reverse('admin:categories_category_change', args=(1,)), data=data)
data.update({"name": smart_text("Parent Catégory (Changed)")})
resp = self.client.post(reverse("admin:categories_category_change", args=(1,)), data=data)
self.assertEqual(resp.status_code, 302)
self.assertEqual(1, Category.objects.count())
# add a child
data.update({
'parent': '1',
'name': smart_text('Child Catégory'),
'slug': smart_text('child-category'),
})
data.update(
{
"parent": "1",
"name": smart_text("Child Catégory"),
"slug": smart_text("child-category"),
}
)
resp = self.client.post(url, data=data)
self.assertEqual(resp.status_code, 302)
self.assertEqual(2, Category.objects.count())
# update child
data.update({'name': 'Child (Changed)'})
resp = self.client.post(reverse('admin:categories_category_change', args=(2,)), data=data)
data.update({"name": "Child (Changed)"})
resp = self.client.post(reverse("admin:categories_category_change", args=(2,)), data=data)
self.assertEqual(resp.status_code, 302)
self.assertEqual(2, Category.objects.count())
# test the admin list view
url = reverse('admin:categories_category_changelist')
url = reverse("admin:categories_category_changelist")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)

View file

@ -4,20 +4,21 @@
import os
from django.conf import settings
from django.test import TestCase, override_settings
from categories.models import Category
from categories.management.commands.import_categories import Command
from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from categories.management.commands.import_categories import Command
from categories.models import Category
@override_settings(INSTALLED_APPS=(app for app in settings.INSTALLED_APPS if app != 'django.contrib.flatpages'))
@override_settings(INSTALLED_APPS=(app for app in settings.INSTALLED_APPS if app != "django.contrib.flatpages"))
class CategoryImportTest(TestCase):
def setUp(self):
pass
def _import_file(self, filename):
root_cats = ['Category 1', 'Category 2', 'Category 3']
testfile = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'fixtures', filename))
root_cats = ["Category 1", "Category 2", "Category 3"]
testfile = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", filename))
cmd = Command()
cmd.handle(testfile)
roots = Category.tree.root_nodes()
@ -26,31 +27,31 @@ class CategoryImportTest(TestCase):
for item in roots:
assert item.name in root_cats
cat2 = Category.objects.get(name='Category 2')
cat2 = Category.objects.get(name="Category 2")
cat21 = cat2.children.all()[0]
self.assertEqual(cat21.name, 'Category 2-1')
self.assertEqual(cat21.name, "Category 2-1")
cat211 = cat21.children.all()[0]
self.assertEqual(cat211.name, 'Category 2-1-1')
self.assertEqual(cat211.name, "Category 2-1-1")
def testImportSpaceDelimited(self):
Category.objects.all().delete()
self._import_file('test_category_spaces.txt')
self._import_file("test_category_spaces.txt")
items = Category.objects.all()
self.assertEqual(items[0].name, 'Category 1')
self.assertEqual(items[1].name, 'Category 1-1')
self.assertEqual(items[2].name, 'Category 1-2')
self.assertEqual(items[0].name, "Category 1")
self.assertEqual(items[1].name, "Category 1-1")
self.assertEqual(items[2].name, "Category 1-2")
def testImportTabDelimited(self):
Category.objects.all().delete()
self._import_file('test_category_tabs.txt')
self._import_file("test_category_tabs.txt")
items = Category.objects.all()
self.assertEqual(items[0].name, 'Category 1')
self.assertEqual(items[1].name, 'Category 1-1')
self.assertEqual(items[2].name, 'Category 1-2')
self.assertEqual(items[0].name, "Category 1")
self.assertEqual(items[1].name, "Category 1-1")
self.assertEqual(items[2].name, "Category 1-2")
def testMixingTabsSpaces(self):
"""

View file

@ -1,10 +1,11 @@
# test active returns only active items
from django.test import TestCase
from categories.models import Category
class CategoryManagerTest(TestCase):
fixtures = ['categories.json']
fixtures = ["categories.json"]
def setUp(self):
pass
@ -16,7 +17,7 @@ class CategoryManagerTest(TestCase):
all_count = Category.objects.all().count()
self.assertEqual(Category.objects.active().count(), all_count)
cat1 = Category.objects.get(name='Category 1')
cat1 = Category.objects.get(name="Category 1")
cat1.active = False
cat1.save()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
from django.core import management
from django.core.management.base import CommandError
from django.db import connection
@ -6,7 +5,6 @@ from django.test import TestCase
class TestMgmtCommands(TestCase):
@classmethod
def setUpClass(cls):
connection.disable_constraint_checking()
@ -18,13 +16,13 @@ class TestMgmtCommands(TestCase):
connection.enable_constraint_checking()
def test_add_category_fields(self):
management.call_command('add_category_fields', verbosity=0)
management.call_command("add_category_fields", verbosity=0)
def test_add_category_fields_app(self):
management.call_command('add_category_fields', 'flatpages', verbosity=0)
management.call_command("add_category_fields", "flatpages", verbosity=0)
def test_drop_category_field(self):
management.call_command('drop_category_field', 'flatpages', 'flatpage', 'category', verbosity=0)
management.call_command("drop_category_field", "flatpages", "flatpage", "category", verbosity=0)
def test_drop_category_field_error(self):
self.assertRaises(CommandError, management.call_command, 'drop_category_field', verbosity=0)
self.assertRaises(CommandError, management.call_command, "drop_category_field", verbosity=0)

View file

@ -2,20 +2,19 @@ import os
from django.core.files import File
from django.core.files.uploadedfile import UploadedFile
from django.test import TestCase
from categories.models import Category
from django.test import TestCase
class TestCategoryThumbnail(TestCase):
def test_thumbnail(self):
file_name = 'test_image.jpg'
file_name = "test_image.jpg"
with open(os.path.join(os.path.dirname(__file__), file_name), 'rb') as f:
test_image = UploadedFile(File(f), content_type='image/jpeg')
category = Category.objects.create(name='Test Category', slug='test-category', thumbnail=test_image)
with open(os.path.join(os.path.dirname(__file__), file_name), "rb") as f:
test_image = UploadedFile(File(f), content_type="image/jpeg")
category = Category.objects.create(name="Test Category", slug="test-category", thumbnail=test_image)
self.assertEqual(category.pk, 1)
self.assertEqual(category.thumbnail_width, 640)
self.assertEqual(category.thumbnail_height, 480)

View file

@ -14,43 +14,39 @@ class CategoryRegistrationTest(TestCase):
"""
def test_foreignkey_string(self):
FK_REGISTRY = {
'flatpages.flatpage': 'category'
}
FK_REGISTRY = {"flatpages.flatpage": "category"}
_process_registry(FK_REGISTRY, registry.register_fk)
from django.contrib.flatpages.models import FlatPage
self.assertTrue('category' in [f.name for f in FlatPage()._meta.get_fields()])
self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()])
def test_foreignkey_dict(self):
FK_REGISTRY = {
'flatpages.flatpage': {'name': 'category'}
}
FK_REGISTRY = {"flatpages.flatpage": {"name": "category"}}
_process_registry(FK_REGISTRY, registry.register_fk)
from django.contrib.flatpages.models import FlatPage
self.assertTrue('category' in [f.name for f in FlatPage()._meta.get_fields()])
self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()])
def test_foreignkey_list(self):
FK_REGISTRY = {
'flatpages.flatpage': (
{'name': 'category', 'related_name': 'cats'},
)
}
FK_REGISTRY = {"flatpages.flatpage": ({"name": "category", "related_name": "cats"},)}
_process_registry(FK_REGISTRY, registry.register_fk)
from django.contrib.flatpages.models import FlatPage
self.assertTrue('category' in [f.name for f in FlatPage()._meta.get_fields()])
self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()])
if django.VERSION[1] >= 7:
def test_new_foreignkey_string(self):
registry.register_model('flatpages', 'flatpage', 'ForeignKey', 'category')
registry.register_model("flatpages", "flatpage", "ForeignKey", "category")
from django.contrib.flatpages.models import FlatPage
self.assertTrue('category' in [f.name for f in FlatPage()._meta.get_fields()])
self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()])
class Categorym2mTest(TestCase):
def test_m2m_string(self):
M2M_REGISTRY = {
'flatpages.flatpage': 'categories'
}
M2M_REGISTRY = {"flatpages.flatpage": "categories"}
_process_registry(M2M_REGISTRY, registry.register_m2m)
from django.contrib.flatpages.models import FlatPage
self.assertTrue('category' in [f.name for f in FlatPage()._meta.get_fields()])
self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()])

View file

@ -1,13 +1,14 @@
from django.test import TestCase
from django import template
import re
from django import template
from django.test import TestCase
from categories.models import Category
class CategoryTagsTest(TestCase):
fixtures = ['musicgenres.json']
fixtures = ["musicgenres.json"]
def render_template(self, template_string, context={}):
"""
@ -21,7 +22,9 @@ class CategoryTagsTest(TestCase):
"""
Ensure that get_category raises an exception if there aren't enough arguments.
"""
self.assertRaises(template.TemplateSyntaxError, self.render_template, '{% load category_tags %}{% get_category %}')
self.assertRaises(
template.TemplateSyntaxError, self.render_template, "{% load category_tags %}{% get_category %}"
)
def testBasicUsage(self):
"""
@ -30,46 +33,50 @@ class CategoryTagsTest(TestCase):
# display_path_as_ul
rock_resp = '<ul><li><a href="/categories/">Top</a></li></ul>'
resp = self.render_template('{% load category_tags %}{% display_path_as_ul "/Rock" %}')
resp = re.sub(r'\n$', "", resp)
resp = re.sub(r"\n$", "", resp)
self.assertEqual(resp, rock_resp)
# display_drilldown_as_ul
expected_resp = '<ul><li><a href="/categories/">Top</a><ul><li><a href="/categories/world/">World</a><ul><li><strong>Worldbeat</strong><ul><li><a href="/categories/world/worldbeat/afrobeat/">Afrobeat</a></li></ul></li></ul></li></ul></li></ul>'
resp = self.render_template(
'{% load category_tags %}'
'{% display_drilldown_as_ul "/World/Worldbeat" "categories.category" %}')
resp = re.sub(r'\n$', "", resp)
"{% load category_tags %}" '{% display_drilldown_as_ul "/World/Worldbeat" "categories.category" %}'
)
resp = re.sub(r"\n$", "", resp)
self.assertEqual(resp, expected_resp)
# breadcrumbs
expected_resp = '<a href="/categories/world/">World</a> &gt; Worldbeat'
expected_resp = '<a href="/categories/world/">World</a> &gt; Worldbeat\n'
resp = self.render_template(
'{% load category_tags %}'
'{% breadcrumbs "/World/Worldbeat" " &gt; " "categories.category" %}')
"{% load category_tags %}" '{% breadcrumbs "/World/Worldbeat" " &gt; " "categories.category" %}'
)
self.assertEqual(resp, expected_resp)
# get_top_level_categories
expected_resp = 'Avant-garde|Blues|Country|Easy listening|Electronic|Hip hop/Rap music|Jazz|Latin|Modern folk|Pop|Reggae|Rhythm and blues|Rock|World|'
expected_resp = "Avant-garde|Blues|Country|Easy listening|Electronic|Hip hop/Rap music|Jazz|Latin|Modern folk|Pop|Reggae|Rhythm and blues|Rock|World|"
resp = self.render_template(
'{% load category_tags %}'
"{% load category_tags %}"
'{% get_top_level_categories using "categories.category" as varname %}'
'{% for item in varname %}{{ item }}|{% endfor %}')
"{% for item in varname %}{{ item }}|{% endfor %}"
)
self.assertEqual(resp, expected_resp)
# get_category_drilldown
expected_resp = "World|World &gt; Worldbeat|"
resp = self.render_template(
'{% load category_tags %}'
"{% load category_tags %}"
'{% get_category_drilldown "/World" using "categories.category" as var %}'
'{% for item in var %}{{ item }}|{% endfor %}')
"{% for item in var %}{{ item }}|{% endfor %}"
)
self.assertEqual(resp, expected_resp)
# recursetree
expected_resp = '<ul><li>Country<ul><li>Country pop<ul><li>Urban Cowboy</li></ul></li></ul></li><li>World<ul><li>Worldbeat<ul></ul></li></ul></li></ul>'
ctxt = {'nodes': Category.objects.filter(name__in=("Worldbeat", "Urban Cowboy"))}
expected_resp = "<ul><li>Country<ul><li>Country pop<ul><li>Urban Cowboy</li></ul></li></ul></li><li>World<ul><li>Worldbeat<ul></ul></li></ul></li></ul>"
ctxt = {"nodes": Category.objects.filter(name__in=("Worldbeat", "Urban Cowboy"))}
resp = self.render_template(
'{% load category_tags %}'
'<ul>{% recursetree nodes|tree_queryset %}<li>{{ node.name }}'
'{% if not node.is_leaf_node %}<ul>{{ children }}'
'</ul>{% endif %}</li>{% endrecursetree %}</ul>', ctxt)
"{% load category_tags %}"
"<ul>{% recursetree nodes|tree_queryset %}<li>{{ node.name }}"
"{% if not node.is_leaf_node %}<ul>{{ children }}"
"</ul>{% endif %}</li>{% endrecursetree %}</ul>",
ctxt,
)
self.assertEqual(resp, expected_resp)

View file

@ -1,8 +1,9 @@
from django.http import Http404
from django.contrib.auth.models import AnonymousUser
from django.test import Client, TestCase, RequestFactory
from categories.models import Category, CategoryRelation
from django.http import Http404
from django.test import Client, RequestFactory, TestCase
from categories import views
from categories.models import Category, CategoryRelation
class MyCategoryRelationView(views.CategoryRelatedDetail):
@ -10,16 +11,18 @@ class MyCategoryRelationView(views.CategoryRelatedDetail):
class TestCategoryViews(TestCase):
fixtures = ['musicgenres.json', ]
fixtures = [
"musicgenres.json",
]
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
def test_category_detail(self):
cat0 = Category.objects.get(slug='country', level=0)
cat1 = cat0.children.get(slug='country-pop')
cat2 = Category.objects.get(slug='urban-cowboy')
cat0 = Category.objects.get(slug="country", level=0)
cat1 = cat0.children.get(slug="country-pop")
cat2 = Category.objects.get(slug="urban-cowboy")
url = cat0.get_absolute_url()
response = self.client.get(url)
self.assertEquals(response.status_code, 200)
@ -33,51 +36,48 @@ class TestCategoryViews(TestCase):
self.assertEquals(response.status_code, 404)
def test_get_category_for_path(self):
cat0 = Category.objects.get(slug='country', level=0)
cat1 = cat0.children.get(slug='country-pop')
cat2 = Category.objects.get(slug='urban-cowboy')
cat0 = Category.objects.get(slug="country", level=0)
cat1 = cat0.children.get(slug="country-pop")
cat2 = Category.objects.get(slug="urban-cowboy")
result = views.get_category_for_path('/country/country-pop/urban-cowboy/')
result = views.get_category_for_path("/country/country-pop/urban-cowboy/")
self.assertEquals(result, cat2)
result = views.get_category_for_path('/country/country-pop/')
result = views.get_category_for_path("/country/country-pop/")
self.assertEquals(result, cat1)
result = views.get_category_for_path('/country/')
result = views.get_category_for_path("/country/")
self.assertEquals(result, cat0)
def test_categorydetailview(self):
request = self.factory.get('')
request = self.factory.get("")
request.user = AnonymousUser()
self.assertRaises(AttributeError, views.CategoryDetailView.as_view(), request)
request = self.factory.get('')
request = self.factory.get("")
request.user = AnonymousUser()
response = views.CategoryDetailView.as_view()(request, path='/country/country-pop/urban-cowboy/')
response = views.CategoryDetailView.as_view()(request, path="/country/country-pop/urban-cowboy/")
self.assertEquals(response.status_code, 200)
request = self.factory.get('')
request = self.factory.get("")
request.user = AnonymousUser()
self.assertRaises(Http404, views.CategoryDetailView.as_view(), request, path='/country/country-pop/foo/')
self.assertRaises(Http404, views.CategoryDetailView.as_view(), request, path="/country/country-pop/foo/")
def test_categoryrelateddetailview(self):
from simpletext.models import SimpleText
stext = SimpleText.objects.create(
name='Test',
description='test description'
)
cat = Category.objects.get(slug='urban-cowboy')
cat_rel = CategoryRelation.objects.create( # NOQA
category=cat,
content_object=stext
)
request = self.factory.get('')
stext = SimpleText.objects.create(name="Test", description="test description")
cat = Category.objects.get(slug="urban-cowboy")
cat_rel = CategoryRelation.objects.create(category=cat, content_object=stext) # NOQA
request = self.factory.get("")
request.user = AnonymousUser()
self.assertRaises(AttributeError, MyCategoryRelationView.as_view(), request)
request = self.factory.get('')
request = self.factory.get("")
request.user = AnonymousUser()
response = MyCategoryRelationView.as_view()(request, category_path='/country/country-pop/urban-cowboy/')
response = MyCategoryRelationView.as_view()(request, category_path="/country/country-pop/urban-cowboy/")
self.assertEquals(response.status_code, 200)
request = self.factory.get('')
request = self.factory.get("")
request.user = AnonymousUser()
self.assertRaises(Http404, MyCategoryRelationView.as_view(), request, category_path='/country/country-pop/foo/')
self.assertRaises(
Http404, MyCategoryRelationView.as_view(), request, category_path="/country/country-pop/foo/"
)

View file

@ -1,19 +1,12 @@
"""URL patterns for the categories app."""
from django.conf.urls import url
from django.views.generic import ListView
from .models import Category
from . import views
from .models import Category
categorytree_dict = {"queryset": Category.objects.filter(level=0)}
categorytree_dict = {
'queryset': Category.objects.filter(level=0)
}
urlpatterns = (url(r"^$", ListView.as_view(**categorytree_dict), name="categories_tree_list"),)
urlpatterns = (
url(
r'^$', ListView.as_view(**categorytree_dict), name='categories_tree_list'
),
)
urlpatterns += (
url(r'^(?P<path>.+)/$', views.category_detail, name='categories_category'),
)
urlpatterns += (url(r"^(?P<path>.+)/$", views.category_detail, name="categories_category"),)

View file

@ -1,5 +1,8 @@
"""View functions for categories."""
from typing import Optional
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.http import HttpResponse, Http404
from django.template.loader import select_template
from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView
@ -7,139 +10,143 @@ from django.views.generic import DetailView, ListView
from .models import Category
def category_detail(request, path, template_name='categories/category_detail.html', extra_context={}):
path_items = path.strip('/').split('/')
def category_detail(
request, path, template_name="categories/category_detail.html", extra_context: Optional[dict] = None
):
"""Render the detail page for a category."""
extra_context = extra_context or {}
path_items = path.strip("/").split("/")
if len(path_items) >= 2:
category = get_object_or_404(
Category,
slug__iexact=path_items[-1],
level=len(path_items) - 1,
parent__slug__iexact=path_items[-2])
Category, slug__iexact=path_items[-1], level=len(path_items) - 1, parent__slug__iexact=path_items[-2]
)
else:
category = get_object_or_404(
Category,
slug__iexact=path_items[-1],
level=len(path_items) - 1)
category = get_object_or_404(Category, slug__iexact=path_items[-1], level=len(path_items) - 1)
templates = []
while path_items:
templates.append('categories/%s.html' % '_'.join(path_items))
templates.append("categories/%s.html" % "_".join(path_items))
path_items.pop()
templates.append(template_name)
context = {'category': category}
context = {"category": category}
if extra_context:
context.update(extra_context)
return HttpResponse(select_template(templates).render(context))
def get_category_for_path(path, queryset=Category.objects.all()):
path_items = path.strip('/').split('/')
"""Return the category for a path."""
path_items = path.strip("/").split("/")
if len(path_items) >= 2:
queryset = queryset.filter(
slug__iexact=path_items[-1],
level=len(path_items) - 1,
parent__slug__iexact=path_items[-2])
slug__iexact=path_items[-1], level=len(path_items) - 1, parent__slug__iexact=path_items[-2]
)
else:
queryset = queryset.filter(
slug__iexact=path_items[-1],
level=len(path_items) - 1)
queryset = queryset.filter(slug__iexact=path_items[-1], level=len(path_items) - 1)
return queryset.get()
class CategoryDetailView(DetailView):
"""Detail view for a category."""
model = Category
path_field = 'path'
path_field = "path"
def get_object(self, **kwargs):
"""Get the category."""
if self.path_field not in self.kwargs:
raise AttributeError("Category detail view %s must be called with "
"a %s." % (self.__class__.__name__, self.path_field))
raise AttributeError(
"Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field)
)
if self.queryset is None:
queryset = self.get_queryset()
try:
return get_category_for_path(self.kwargs[self.path_field], self.model.objects.all())
except Category.DoesNotExist:
raise Http404(_("No %(verbose_name)s found matching the query") %
{'verbose_name': queryset.model._meta.verbose_name})
raise Http404(
_("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name}
)
def get_template_names(self):
"""Get the potential template names."""
names = []
path_items = self.kwargs[self.path_field].strip('/').split('/')
path_items = self.kwargs[self.path_field].strip("/").split("/")
while path_items:
names.append('categories/%s.html' % '_'.join(path_items))
names.append("categories/%s.html" % "_".join(path_items))
path_items.pop()
names.extend(super(CategoryDetailView, self).get_template_names())
return names
class CategoryRelatedDetail(DetailView):
path_field = 'category_path'
"""Detailed view for a category relation."""
path_field = "category_path"
object_name_field = None
def get_object(self, **kwargs):
"""Get the object to render."""
if self.path_field not in self.kwargs:
raise AttributeError("Category detail view %s must be called with "
"a %s." % (self.__class__.__name__, self.path_field))
raise AttributeError(
"Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field)
)
queryset = super(CategoryRelatedDetail, self).get_queryset()
try:
category = get_category_for_path(self.kwargs[self.path_field])
except Category.DoesNotExist:
raise Http404(_("No %(verbose_name)s found matching the query") %
{'verbose_name': queryset.model._meta.verbose_name})
raise Http404(
_("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name}
)
return queryset.get(category=category)
def get_template_names(self):
"""Get all template names."""
names = []
opts = self.object._meta
path_items = self.kwargs[self.path_field].strip('/').split('/')
path_items = self.kwargs[self.path_field].strip("/").split("/")
if self.object_name_field:
path_items.append(getattr(self.object, self.object_name_field))
while path_items:
names.append('%s/category_%s_%s%s.html' % (
opts.app_label,
'_'.join(path_items),
opts.object_name.lower(),
self.template_name_suffix)
names.append(
"%s/category_%s_%s%s.html"
% (opts.app_label, "_".join(path_items), opts.object_name.lower(), self.template_name_suffix)
)
path_items.pop()
names.append('%s/category_%s%s.html' % (
opts.app_label,
opts.object_name.lower(),
self.template_name_suffix)
)
names.append("%s/category_%s%s.html" % (opts.app_label, opts.object_name.lower(), self.template_name_suffix))
names.extend(super(CategoryRelatedDetail, self).get_template_names())
return names
class CategoryRelatedList(ListView):
path_field = 'category_path'
"""List related category items."""
path_field = "category_path"
def get_queryset(self):
"""Get the list of items."""
if self.path_field not in self.kwargs:
raise AttributeError("Category detail view %s must be called with "
"a %s." % (self.__class__.__name__, self.path_field))
raise AttributeError(
"Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field)
)
queryset = super(CategoryRelatedList, self).get_queryset()
category = get_category_for_path(self.kwargs[self.path_field])
return queryset.filter(category=category)
def get_template_names(self):
"""Get the template names."""
names = []
if hasattr(self.object_list, 'model'):
if hasattr(self.object_list, "model"):
opts = self.object_list.model._meta
path_items = self.kwargs[self.path_field].strip('/').split('/')
path_items = self.kwargs[self.path_field].strip("/").split("/")
while path_items:
names.append('%s/category_%s_%s%s.html' % (
opts.app_label,
'_'.join(path_items),
opts.object_name.lower(),
self.template_name_suffix)
names.append(
"%s/category_%s_%s%s.html"
% (opts.app_label, "_".join(path_items), opts.object_name.lower(), self.template_name_suffix)
)
path_items.pop()
names.append('%s/category_%s%s.html' % (
opts.app_label,
opts.object_name.lower(),
self.template_name_suffix)
names.append(
"%s/category_%s%s.html" % (opts.app_label, opts.object_name.lower(), self.template_name_suffix)
)
names.extend(super(CategoryRelatedList, self).get_template_names())
return names

View file

@ -6,7 +6,7 @@ SPHINXOPTS = -a
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = _build
DESTDIR = ..
DESTDIR = _build
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
@ -32,9 +32,9 @@ clean:
-rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(DESTDIR)/docs
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(DESTDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(DESTDIR)/docs."
@echo "Build finished. The HTML pages are in $(DESTDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 B

View file

@ -0,0 +1,32 @@
.sig-prename.descclassname {
display: none;
}
dl.attribute {
margin-bottom: 30px;
}
dl.field-list {
display: block;
}
dl.field-list > dt {
padding-left: 0;
}
dl.field-list > dd {
padding-left: 1em;
border-left: 0;
}
dl.field-list > dd > ul.simple {
list-style-type: none;
padding-left: 0;
}
dl.field-list > dd + dt {
margin-top: 0.5em;
}
dd {
margin-left: 0;
padding-left: 30px;
border-left: 1px solid #c9c9c9;
}
.table.autosummary td {
border-top: 0;
border-bottom: 1px solid #dee2e6;
}

View file

@ -1,773 +0,0 @@
/**
* Sphinx stylesheet -- basic theme
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*/
h3 {
color:#000000;
font-size: 17px;
margin-bottom:0.5em;
margin-top:2em;
}
/* -- main layout ----------------------------------------------------------- */
div.clearer {
clear: both;
}
/* -- header ---------------------------------------------------------------- */
#header #title {
background:#29334F url(title_background.png) repeat-x scroll 0 0;
border-bottom:1px solid #B6B6B6;
height:25px;
overflow:hidden;
}
#headerButtons {
position: absolute;
list-style: none outside;
top: 26px;
left: 0px;
right: 0px;
margin: 0px;
padding: 0px;
border-top: 1px solid #2B334F;
border-bottom: 1px solid #EDEDED;
height: 20px;
font-size: 8pt;
overflow: hidden;
background-color: #D8D8D8;
}
#headerButtons li {
background-repeat:no-repeat;
display:inline;
margin-top:0;
padding:0;
}
.headerButton {
display: inline;
height:20px;
}
.headerButton a {
text-decoration: none;
float: right;
height: 20px;
padding: 4px 15px;
border-left: 1px solid #ACACAC;
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
color: black;
}
.headerButton a:hover {
color: white;
background-color: #787878;
}
li#toc_button {
text-align:left;
}
li#toc_button .headerButton a {
width:198px;
padding-top: 4px;
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
color: black;
float: left;
padding-left:15px;
border-right:1px solid #ACACAC;
background:transparent url(triangle_open.png) no-repeat scroll 4px 6px;
}
li#toc_button .headerButton a:hover {
background-color: #787878;
color: white;
}
li#page_buttons {
position:absolute;
right:0;
}
#breadcrumbs {
color: black;
background-image:url(breadcrumb_background.png);
border-top:1px solid #2B334F;
bottom:0;
font-size:10px;
height:15px;
left:0;
overflow:hidden;
padding:3px 10px 0;
position:absolute;
right:0;
white-space:nowrap;
z-index:901;
}
#breadcrumbs a {
color: black;
text-decoration: none;
}
#breadcrumbs a:hover {
text-decoration: underline;
}
#breadcrumbs img {
padding-left: 3px;
}
/* -- sidebar --------------------------------------------------------------- */
#sphinxsidebar {
position: absolute;
top: 84px;
bottom: 19px;
left: 0px;
width: 229px;
background-color: #E4EBF7;
border-right: 1px solid #ACACAC;
border-top: 1px solid #2B334F;
overflow-x: hidden;
overflow-y: auto;
padding: 0px 0px 0px 0px;
font-size:11px;
}
div.sphinxsidebarwrapper {
padding: 10px 5px 0 10px;
}
#sphinxsidebar li {
margin: 0px;
padding: 0px;
font-weight: normal;
margin: 0px 0px 7px 0px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
}
#sphinxsidebar ul {
list-style: none;
margin: 0px 0px 0px 0px;
padding: 0px 5px 0px 5px;
}
#sphinxsidebar ul ul,
#sphinxsidebar ul.want-points {
list-style: square;
}
#sphinxsidebar ul ul {
margin-top: 0;
margin-bottom: 0;
}
#sphinxsidebar form {
margin-top: 10px;
}
#sphinxsidebar input {
border: 1px solid #787878;
font-family: sans-serif;
font-size: 1em;
}
img {
border: 0;
}
#sphinxsidebar li.toctree-l1 a {
font-weight: bold;
color: #000;
text-decoration: none;
}
#sphinxsidebar li.toctree-l2 a {
font-weight: bold;
color: #4f4f4f;
text-decoration: none;
}
/* -- search page ----------------------------------------------------------- */
ul.search {
margin: 10px 0 0 20px;
padding: 0;
}
ul.search li {
padding: 5px 0 5px 20px;
background-image: url(file.png);
background-repeat: no-repeat;
background-position: 0 7px;
}
ul.search li a {
font-weight: bold;
}
ul.search li div.context {
color: #888;
margin: 2px 0 0 30px;
text-align: left;
}
ul.keywordmatches li.goodmatch a {
font-weight: bold;
}
#sphinxsidebar input.prettysearch {border:none;}
input.searchbutton {
float: right;
}
.search-wrapper {width: 100%; height: 25px;}
.search-wrapper input.prettysearch { border: none; width:200px; height: 16px; background: url(searchfield_repeat.png) center top repeat-x; border: 0px; margin: 0; padding: 3px 0 0 0; font: 11px "Lucida Grande", "Lucida Sans Unicode", Arial, sans-serif; }
.search-wrapper input.prettysearch { width: 184px; margin-left: 20px; *margin-top:-1px; *margin-right:-2px; *margin-left:10px; }
.search-wrapper .search-left { display: block; position: absolute; width: 20px; height: 19px; background: url(searchfield_leftcap.png) left top no-repeat; }
.search-wrapper .search-right { display: block; position: relative; left: 204px; top: -19px; width: 10px; height: 19px; background: url(searchfield_rightcap.png) right top no-repeat; }
/* -- index page ------------------------------------------------------------ */
table.contentstable {
width: 90%;
}
table.contentstable p.biglink {
line-height: 150%;
}
a.biglink {
font-size: 1.3em;
}
span.linkdescr {
font-style: italic;
padding-top: 5px;
font-size: 90%;
}
/* -- general index --------------------------------------------------------- */
table.indextable td {
text-align: left;
vertical-align: top;
}
table.indextable dl, table.indextable dd {
margin-top: 0;
margin-bottom: 0;
}
table.indextable tr.pcap {
height: 10px;
}
table.indextable tr.cap {
margin-top: 10px;
background-color: #f2f2f2;
}
img.toggler {
margin-right: 3px;
margin-top: 3px;
cursor: pointer;
}
/* -- general body styles --------------------------------------------------- */
.document {
border-top:1px solid #2B334F;
overflow:auto;
padding-left:2em;
padding-right:2em;
position:absolute;
z-index:1;
top:84px;
bottom:19px;
right:0;
left:230px;
}
a.headerlink {
visibility: hidden;
}
h1:hover > a.headerlink,
h2:hover > a.headerlink,
h3:hover > a.headerlink,
h4:hover > a.headerlink,
h5:hover > a.headerlink,
h6:hover > a.headerlink,
dt:hover > a.headerlink {
visibility: visible;
}
div.body p.caption {
text-align: inherit;
}
div.body td {
text-align: left;
}
.field-list ul {
padding-left: 1em;
}
.first {
margin-top: 0 !important;
}
p.rubric {
margin-top: 30px;
font-weight: bold;
}
/* -- sidebars -------------------------------------------------------------- */
/*div.sidebar {
margin: 0 0 0.5em 1em;
border: 1px solid #ddb;
padding: 7px 7px 0 7px;
background-color: #ffe;
width: 40%;
float: right;
}
p.sidebar-title {
font-weight: bold;
}
*/
/* -- topics ---------------------------------------------------------------- */
div.topic {
border: 1px solid #ccc;
padding: 7px 7px 0 7px;
margin: 10px 0 10px 0;
}
p.topic-title {
font-size: 1.1em;
font-weight: bold;
margin-top: 10px;
}
/* -- admonitions ----------------------------------------------------------- */
.admonition {
border: 1px solid #a1a5a9;
background-color: #f7f7f7;
margin: 20px;
padding: 0px 8px 7px 9px;
text-align: left;
}
.warning {
background-color:#E8E8E8;
border:1px solid #111111;
margin:30px;
}
.admonition p {
font: 12px 'Lucida Grande', Geneva, Helvetica, Arial, sans-serif;
margin-top: 7px;
margin-bottom: 0px;
}
div.admonition dt {
font-weight: bold;
}
div.admonition dl {
margin-bottom: 0;
}
p.admonition-title {
margin: 0px 10px 5px 0px;
font-weight: bold;
padding-top: 3px;
}
div.body p.centered {
text-align: center;
margin-top: 25px;
}
/* -- tables ---------------------------------------------------------------- */
table.docutils {
border-collapse: collapse;
border-top: 1px solid #919699;
border-left: 1px solid #919699;
border-right: 1px solid #919699;
font-size:12px;
padding:8px;
text-align:left;
vertical-align:top;
}
table.docutils td, table.docutils th {
padding: 8px;
font-size: 12px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #919699;
}
table.docutils th {
font-weight: bold;
}
/* This alternates colors in up to six table rows (light blue for odd, white for even)*/
.docutils tr {
background: #F0F5F9;
}
.docutils tr + tr {
background: #FFFFFF;
}
.docutils tr + tr + tr {
background: #F0F5F9;
}
.docutils tr + tr + tr + tr {
background: #FFFFFF;
}
.docutils tr + tr + tr +tr + tr {
background: #F0F5F9;
}
.docutils tr + tr + tr + tr + tr + tr {
background: #FFFFFF;
}
.docutils tr + tr + tr + tr + tr + tr + tr {
background: #F0F5F9;
}
table.footnote td, table.footnote th {
border: 0 !important;
}
th {
text-align: left;
padding-right: 5px;
}
/* -- other body styles ----------------------------------------------------- */
dl {
margin-bottom: 15px;
font-size: 12px;
}
dd p {
margin-top: 0px;
font-size: 12px;
}
dd ul, dd table {
margin-bottom: 10px;
}
dd {
margin-top: 3px;
margin-bottom: 10px;
margin-left: 30px;
font-size: 12px;
}
dt:target, .highlight {
background-color: #fbe54e;
}
dl.glossary dt {
font-weight: bold;
font-size: 0.8em;
}
dl.glossary dd {
font-size:12px;
}
.field-list ul {
vertical-align: top;
margin: 0;
padding-bottom: 0;
list-style: none inside;
}
.field-list ul li {
margin-top: 0;
}
.field-list p {
margin: 0;
}
.refcount {
color: #060;
}
.optional {
font-size: 1.3em;
}
.versionmodified {
font-style: italic;
}
.system-message {
background-color: #fda;
padding: 5px;
border: 3px solid red;
}
.footnote:target {
background-color: #ffa
}
/* -- code displays --------------------------------------------------------- */
pre {
overflow: auto;
background-color:#F1F5F9;
border:1px solid #C9D1D7;
border-spacing:0;
font-family:"Bitstream Vera Sans Mono",Monaco,"Lucida Console",Courier,Consolas,monospace;
font-size:11px;
padding: 10px;
}
td.linenos {
width: 2em;
}
td.linenos pre {
padding: 5px 0px;
border: 0;
background-color: transparent;
color: #aaa;
}
td.code {
}
table.highlighttable {
margin-left: 0.5em;
width: 100%;
}
table.highlighttable td {
padding: 0 0.5em 0 0.5em;
}
table.highlighttable td.linenos {
text-align: right;
width: 1.5em;
padding-right: 0;
}
tt {
font-family:"Bitstream Vera Sans Mono",Monaco,"Lucida Console",Courier,Consolas,monospace;
}
tt.descname {
background-color: transparent;
font-weight: bold;
font-size: 1em;
}
tt.descclassname {
background-color: transparent;
}
tt.xref, a tt {
background-color: transparent;
font-weight: bold;
}
h1 tt, h2 tt, h3 tt, h4 tt, h5 tt, h6 tt {
background-color: transparent;
}
/* -- math display ---------------------------------------------------------- */
img.math {
vertical-align: middle;
}
div.body div.math p {
text-align: center;
}
span.eqno {
float: right;
}
/* -- printout stylesheet --------------------------------------------------- */
@media print {
div.document,
div.documentwrapper,
div.bodywrapper {
margin: 0;
width: 100%;
}
div.sphinxsidebar,
div.related,
div.footer,
#top-link {
display: none;
}
}
body {
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
}
dl.class dt {
padding: 3px;
/* border-top: 2px solid #999;*/
}
em.property {
font-style: normal;
}
dl.class dd p {
margin-top: 6px;
}
dl.class dd dl.exception dt {
padding: 3px;
background-color: #FFD6D6;
border-top: none;
}
dl.class dd dl.method dt {
padding: 3px;
background-color: #e9e9e9;
border-top: none;
}
dl.function dt {
padding: 3px;
border-top: 2px solid #999;
}
ul {
list-style-image:none;
list-style-position:outside;
list-style-type:square;
margin:0 0 0 30px;
padding:0 0 12px 6px;
}
#docstitle {
height: 36px;
background-image: url(header_sm_mid.png);
left: 0;
top: 0;
position: absolute;
width: 100%;
}
#docstitle p {
padding:7px 0 0 45px;
margin: 0;
color: white;
text-shadow:0 1px 0 #787878;
background: transparent url(documentation.png) no-repeat scroll 10px 3px;
height: 36px;
font-size: 15px;
}
#header {
height:45px;
left:0;
position:absolute;
right:0;
top:36px;
z-index:900;
}
#header h1 {
font-size:10pt;
margin:0;
padding:5px 0 0 10px;
text-shadow:0 1px 0 #D5D5D5;
white-space:nowrap;
}
h1 {
-x-system-font:none;
color:#000000;
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
font-size:30px;
font-size-adjust:none;
font-stretch:normal;
font-style:normal;
font-variant:normal;
font-weight:bold;
line-height:normal;
margin-bottom:25px;
margin-top:1em;
}
.footer {
border-top:1px solid #DDDDDD;
clear:both;
padding-top:9px;
width:100%;
font-size:10px;
}
p {
-x-system-font:none;
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
font-size:12px;
font-size-adjust:none;
font-stretch:normal;
font-style:normal;
font-variant:normal;
font-weight:normal;
line-height:normal;
margin-bottom:10px;
margin-top:0;
}
h2 {
border-bottom:1px solid #919699;
color:#000000;
font-size:24px;
margin-top:2.5em;
padding-bottom:2px;
}
a:link:hover {
color:#093D92;
text-decoration:underline;
}
a:link {
color:#093D92;
text-decoration:none;
}
ol {
list-style-position:outside;
list-style-type:decimal;
margin:0 0 0 30px;
padding:0 0 12px 6px;
}
li {
margin-top:7px;
font-family:'Lucida Grande',Geneva,Helvetica,Arial,sans-serif;
font-size:12px;
font-size-adjust:none;
font-stretch:normal;
font-style:normal;
font-variant:normal;
font-weight:normal;
line-height:normal;
}
li > p {
display:inline;
}
li p {
margin-top:8px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

View file

@ -1,20 +0,0 @@
var TOC = {
load: function () {
$('#toc_button').click(TOC.toggle);
},
toggle: function () {
if ($('#sphinxsidebar').toggle().is(':hidden')) {
$('div.document').css('left', "0px");
$('toc_button').removeClass("open");
} else {
$('div.document').css('left', "230px");
$('#toc_button').addClass("open");
}
return $('#sphinxsidebar');
}
};
$(document).ready(function () {
TOC.load();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 B

View file

@ -0,0 +1,10 @@
.. rst-class:: h4 text-secondary
{{ fullname }}
{{ objname | escape | underline}}
.. currentmodule:: {{ module }}
{%- if not objname.startswith("test") %}
.. auto{{ objtype }}:: {{ objname }}
{%- endif %}

View file

@ -0,0 +1,34 @@
.. rst-class:: h4 text-secondary
{{ fullname }}
{{ objname | escape | underline}}
.. currentmodule:: {{ module }}
.. autoclass:: {{ objname }}
{% block methods %}
{% if methods %}
.. rubric:: {{ _('Methods') }}
.. autosummary::
{% for item in methods %}
{%- if "__" not in item %}
~{{ name }}.{{ item }}
{% endif -%}
{%- endfor %}
{% endif %}
{% endblock %}
{% block attributes %}
{% if attributes %}
.. rubric:: {{ _('Attributes') }}
.. autosummary::
{% for item in attributes %}
{%- if "__" not in item %}
~{{ name }}.{{ item }}{% endif %}
{% endfor -%}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,70 @@
.. rst-class:: h4 text-secondary
{{ fullname }}
{{ objname | escape | underline}}
.. currentmodule:: {{ fullname }}
.. automodule:: {{ fullname }}
{% block modules -%}
{% if modules %}
.. rubric:: Submodules
.. autosummary::
:toctree:
:recursive:
{% for item in modules %}
{% if "migrations" not in item and "tests" not in item%}{{ item }}{% endif %}
{%- endfor %}
{% endif %}
{% endblock %}
{% block attributes -%}
{%- if attributes -%}
.. rubric:: {{ _('Module Attributes') }}
.. autosummary::
:toctree:
{% for item in attributes %}
{{ item }}
{%- endfor -%}
{%- endif -%}
{% endblock attributes -%}
{%- block functions -%}
{%- if functions %}
.. rubric:: {{ _('Functions') }}
.. autosummary::
:toctree:
{% for item in functions %}
{{ item }}
{%- endfor -%}
{%- endif -%}
{% endblock functions -%}
{% block classes -%}
{% if classes %}
.. rubric:: {{ _('Classes') }}
.. autosummary::
:toctree:
{% for item in classes %}
{{ item }}
{%- endfor -%}
{%- endif -%}
{% endblock classes -%}
{% block exceptions -%}
{% if exceptions %}
.. rubric:: {{ _('Exceptions') }}
.. autosummary::
:toctree:
{% for item in exceptions %}
{{ item }}
{%- endfor -%}
{%- endif -%}
{% endblock exceptions -%}

View file

@ -1,144 +0,0 @@
{% extends "basic/layout.html" %}
{%- block doctype -%}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
{%- endblock %}
{%- set reldelim1 = reldelim1 is not defined and ' &raquo;' or reldelim1 %}
{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %}
{%- block linktags %}
{%- if hasdoc('about') %}
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
{%- endif %}
{%- if hasdoc('genindex') %}
<link rel="index" title="{{ _('Index') }}" href="{{ pathto('genindex') }}" />
{%- endif %}
{%- if hasdoc('search') %}
<link rel="search" title="{{ _('Search') }}" href="{{ pathto('search') }}" />
{%- endif %}
{%- if hasdoc('copyright') %}
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
{%- endif %}
<link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" />
{%- if parents %}
<link rel="up" title="{{ parents[-1].title|striptags }}" href="{{ parents[-1].link|e }}" />
{%- endif %}
{%- if next %}
<link rel="next" title="{{ next.title|striptags }}" href="{{ next.link|e }}" />
{%- endif %}
{%- if prev %}
<link rel="prev" title="{{ prev.title|striptags }}" href="{{ prev.link|e }}" />
{%- endif %}
{%- endblock %}
{%- block extrahead %} {% endblock %}
{%- block header %}{% endblock %}
{%- block relbar1 %}
<div id="docstitle">
<p>{{docstitle}}</p>
</div>
<div id="header">
<div id="title"><h1>{{ title|striptags }}</h1></div>
<ul id="headerButtons">
<li id="toc_button"><div class="headerButton"><a href="#">Table of Contents</a></div></li>
<li id="page_buttons">
{%- for rellink in rellinks %}
<div class="headerButton"><a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags }}" {{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a></div>
{%- endfor %}
</li>
</ul>
</div>
{% endblock %}
{%- block sidebar1 %}
{%- if not embedded %}{% if not theme_nosidebar|tobool %}
<div id="sphinxsidebar">
<div class="sphinxsidebarwrapper">
{%- block sidebarlogo %}
{%- if logo %}
<p class="logo"><a href="{{ pathto(master_doc) }}">
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
</a></p>
{%- endif %}
{%- endblock %}
{%- block sidebartoc %}
<ul><li class="toctree-l1"><a href="{{ pathto(master_doc) }}">{{ _('Main Page') }}</a></li></ul>
{{ toctree() }}
{%- endblock %}
{%- block sidebarrel %}
{%- endblock %}
{%- block sidebarsourcelink %}
{%- if show_source and has_source and sourcename %}
<h3>{{ _('This Page') }}</h3>
<ul class="this-page-menu">
<li><a href="{{ pathto('_sources/' + sourcename, true)|e }}"
rel="nofollow">{{ _('Show Source') }}</a></li>
</ul>
{%- endif %}
{%- endblock %}
{%- if customsidebar %}
{% include customsidebar %}
{%- endif %}
{%- block sidebarsearch %}
{%- if pagename != "search" %}
<div id="searchbox" style="display: none">
{# <h3>{{ _('Quick search') }}</h3> #}
<form class="search" action="{{ pathto('search') }}" method="get">
<div class="search-wrapper">
<span class="search-left"></span>
<input class="prettysearch" type="text" name="q" size="18" />
<span class="search-right">&nbsp;</span>
</div>
<input type="submit" value="{{ _('Search') }}" class="searchbutton" />
<input type="hidden" name="check_keywords" value="yes" />
<input type="hidden" name="area" value="default" />
</form>
<p class="searchtip" style="font-size: 90%">
{{ _('Enter search terms or a module, class or function name.') }}
</p>
</div>
<script type="text/javascript">$('#searchbox').show(0);</script>
{%- endif %}
{%- endblock %}
</div>
</div>
{%- endif %}{% endif %}
{% endblock %}
{%- block document %}
<div class="documentwrapper">
{%- if not embedded %}{% if not theme_nosidebar|tobool %}
<div class="bodywrapper">
{%- endif %}{% endif %}
<div class="body">
{% block body %} {% endblock %}
</div>
{%- if not embedded %}{% if not theme_nosidebar|tobool %}
</div>
{%- endif %}{% endif %}
</div>
<div class="footer">
<p>{%- if hasdoc('copyright') %}
{% trans path=pathto('copyright'), copyright=copyright|e %}&copy; <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
{%- else %}
{% trans copyright=copyright|e %}&copy; Copyright {{ copyright }}.{% endtrans %}
{%- endif %}
{%- if last_updated %}
{% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %}
{%- endif %}
{%- if show_sphinx %}
{% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx.pocoo.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %}
{%- endif %}
</p>
</div>
{%- endblock %}
{%- block sidebar2 %}{% endblock %}
{%- block relbar2 %}{% endblock %}
{%- block footer %}
<div id="breadcrumbs">
{%- for parent in parents %}
<a href="{{ parent.link|e }}" {{ accesskey("U") }}>{{ parent.title|safe }}</a><img src="{{ pathto('_static/triangle_closed.png', 1) }}" height="9" width="9" alt="&gt;">
{%- endfor %}
{{ title|safe|e }}
</ul>
</div>
<script type="text/javascript" charset="utf-8" src="{{ pathto('_static/toc.js', 1) }}"></script>
{%- endblock %}

8
doc_src/api/index.rst Normal file
View file

@ -0,0 +1,8 @@
API
===
.. autosummary::
:toctree:
:recursive:
categories

2
doc_src/changelog.md Normal file
View file

@ -0,0 +1,2 @@
```{include} ../CHANGELOG.md
```

View file

@ -1,21 +0,0 @@
from categories.admin import CategoryAdminForm
from categories.base import CategoryBaseAdmin
class CategoryAdmin(CategoryBaseAdmin):
form = CategoryAdminForm
list_display = ('name', 'alternate_title', 'active')
fieldsets = (
(None, {
'fields': ('parent', 'name', 'thumbnail', 'active')
}),
('Meta Data', {
'fields': ('alternate_title', 'alternate_url', 'description',
'meta_keywords', 'meta_extra'),
'classes': ('collapse',),
}),
('Advanced', {
'fields': ('order', 'slug'),
'classes': ('collapse',),
}),
)

View file

@ -1,197 +1,85 @@
# -*- coding: utf-8 -*-
#
# Django Categories documentation build configuration file, created by
# sphinx-quickstart on Tue Oct 6 07:53:33 2009.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
"""
Sphinx configuration.
"""
import sys
import os
import sys
from datetime import date
from pathlib import Path
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.append(os.path.abspath('..'))
os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings'
project_root = Path("..").resolve()
sys.path.insert(0, str(project_root / "example"))
sys.path.insert(0, str(project_root))
os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings"
# Setup Django
import django # NOQA
django.setup()
import categories # noqa
import categories.urls # noqa
project = "Django Categories"
copyright = f"2010-{date.today():%Y}, Corey Oordt"
version = categories.__version__
release = categories.__version__
# -- General configuration -----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
"sphinx.ext.autosectionlabel",
"sphinx.ext.napoleon",
"sphinx_autodoc_typehints",
"sphinx.ext.coverage",
"sphinx.ext.githubpages",
"sphinxcontrib_django2",
]
autosectionlabel_prefix_document = True
autosectionlabel_maxdepth = 2
autosummary_generate = True
napoleon_attr_annotations = True
napoleon_include_special_with_doc = False
napoleon_include_private_with_doc = True
napoleon_include_init_with_doc = True
myst_enable_extensions = [
"amsmath",
"colon_fence",
"deflist",
"dollarmath",
"linkify",
"replacements",
"smartquotes",
"substitution",
"tasklist",
]
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"django": (
"https://docs.djangoproject.com/en/stable",
"https://docs.djangoproject.com/en/stable/_objects",
),
}
# Add any paths that contain templates here, relative to this directory.
# templates_path = ['_templates']
templates_path = ["_templates"]
source_suffix = [".rst", ".md"]
master_doc = "index"
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
# ource_encoding = 'utf-8'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = 'Django Categories'
copyright = '2010-2012, Corey Oordt'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = categories.get_version(short=True)
# The full version, including alpha/beta/rc tags.
release = categories.get_version()
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
# anguage = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
# oday = ''
# Else, today_fmt is used as the format for a strftime call.
# oday_fmt = '%B %d, %Y'
# List of documents that shouldn't be included in the build.
# nused_docs = []
# List of directories, relative to source directory, that shouldn't be searched
# for source files.
exclude_trees = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
# efault_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
# dd_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
# dd_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
# how_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
# odindex_common_prefix = []
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
pygments_style = "sphinx"
todo_include_todos = False
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. Major themes that come with
# Sphinx are currently 'default' and 'sphinxdoc'.
# html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
# tml_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
# tml_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
# tml_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
# tml_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
# tml_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
# tml_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
# tml_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
# tml_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
# tml_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
# tml_additional_pages = {}
# If false, no module index is generated.
# tml_use_modindex = True
# If false, no index is generated.
# tml_use_index = True
# If true, the index is split into individual pages for each letter.
# tml_split_index = False
# If true, links to the reST sources are added to the pages.
# tml_show_sourcelink = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
# tml_use_opensearch = ''
# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
# tml_file_suffix = ''
# Output file base name for HTML help builder.
htmlhelp_basename = 'DjangoCategoriesdoc'
# -- Options for LaTeX output --------------------------------------------------
# The paper size ('letter' or 'a4').
# atex_paper_size = 'letter'
# The font size ('10pt', '11pt' or '12pt').
# atex_font_size = '10pt'
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'DjangoCategories.tex', 'Django Categories Documentation', 'CoreyOordt', 'manual'),
html_theme = "pydata_sphinx_theme"
html_static_path = ["_static"]
html_css_files = [
"css/custom.css",
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
# atex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
# atex_use_parts = False
# Additional stuff for the LaTeX preamble.
# atex_preamble = ''
# Documents to append as an appendix to all manuals.
# atex_appendices = []
# If false, no module index is generated.
# atex_use_modindex = True

View file

@ -6,18 +6,6 @@ Django Categories grew out of our need to provide a basic hierarchical taxonomy
As a news site, our stories, photos, and other content get divided into "sections" and we wanted all the apps to use the same set of sections. As our needs grew, the Django Categories grew in the functionality it gave to category handling within web pages.
New in 1.1
==========
* Fixed a cosmetic bug in the Django 1.4 admin. Action checkboxes now only appear once.
* Template tags are refactored to allow easy use of any model derived from ``CategoryBase``.
* Improved test suite.
* Improved some of the documentation.
Contents
========
@ -28,12 +16,10 @@ Contents
installation
getting_started
usage
registering_models
adding_the_fields
admin_settings
custom_categories
user_guide/index
reference/index
api/index
changelog
Indices and tables
==================
@ -41,4 +27,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View file

@ -35,4 +35,4 @@ drop_category_field
Drop the ``field_name`` field from the ``app_name_model_name`` table, if the field is currently registered in ``CATEGORIES_SETTINGS``\ .
Requires Django South.
Requires Django South.

View file

@ -128,4 +128,4 @@ ADMIN_FIELDSETS
**Default:** ``{}``
**Description:** Allows for selective customization of the default behavior of adding the fields to the admin class. See :ref:`admin_settings` for more information.
**Description:** Allows for selective customization of the default behavior of adding the fields to the admin class. See :ref:`admin_settings` for more information.

View file

@ -20,4 +20,4 @@ Reconfiguring Fields
You can make changes to the field configurations as long as they do not change the underlying database structure. For example, adding a ``related_name`` (see :ref:`registering_a_m2one_relationship`\ ) because it only affects Django code. Changing the name of the field, however, is a different matter.
Django Categories provides a complementary management command to drop a field from the database (the field must still be in the configuration to do so): ``python manage.py drop_category_field app_name model_name field_name``
Django Categories provides a complementary management command to drop a field from the database (the field must still be in the configuration to do so): ``python manage.py drop_category_field app_name model_name field_name``

View file

@ -7,4 +7,4 @@ class SimpleCategory(CategoryBase):
"""
class Meta:
verbose_name_plural = 'simple categories'
verbose_name_plural = "simple categories"

View file

@ -5,29 +5,25 @@ from categories.base import CategoryBase
class Category(CategoryBase):
thumbnail = models.FileField(
upload_to=settings.THUMBNAIL_UPLOAD_PATH,
null=True, blank=True,
storage=settings.THUMBNAIL_STORAGE,)
null=True,
blank=True,
storage=settings.THUMBNAIL_STORAGE,
)
thumbnail_width = models.IntegerField(blank=True, null=True)
thumbnail_height = models.IntegerField(blank=True, null=True)
order = models.IntegerField(default=0)
alternate_title = models.CharField(
blank=True,
default="",
max_length=100,
help_text="An alternative title to use on pages with this category.")
blank=True, default="", max_length=100, help_text="An alternative title to use on pages with this category."
)
alternate_url = models.CharField(
blank=True,
max_length=200,
help_text="An alternative URL to use instead of the one derived from "
"the category hierarchy.")
help_text="An alternative URL to use instead of the one derived from " "the category hierarchy.",
)
description = models.TextField(blank=True, null=True)
meta_keywords = models.CharField(
blank=True,
default="",
max_length=255,
help_text="Comma-separated keywords for search engines.")
blank=True, default="", max_length=255, help_text="Comma-separated keywords for search engines."
)
meta_extra = models.TextField(
blank=True,
default="",
help_text="(Advanced) Any additional HTML to be placed verbatim "
"in the &lt;head&gt;")
blank=True, default="", help_text="(Advanced) Any additional HTML to be placed verbatim " "in the &lt;head&gt;"
)

View file

@ -3,8 +3,9 @@ from categories.models import Category
def save(self, *args, **kwargs):
if self.thumbnail:
from django.core.files.images import get_image_dimensions
import django
from django.core.files.images import get_image_dimensions
if django.VERSION[1] < 2:
width, height = get_image_dimensions(self.thumbnail.file)
else:

View file

@ -2,8 +2,8 @@ from categories.base import CategoryBase
class Meta(CategoryBase.Meta):
verbose_name_plural = 'categories'
verbose_name_plural = "categories"
class MPTTMeta:
order_insertion_by = ('order', 'name')
order_insertion_by = ("order", "name")

View file

@ -7,7 +7,7 @@ class CategoryAdminForm(CategoryBaseAdminForm):
model = Category
def clean_alternate_title(self):
if self.instance is None or not self.cleaned_data['alternate_title']:
return self.cleaned_data['name']
if self.instance is None or not self.cleaned_data["alternate_title"]:
return self.cleaned_data["name"]
else:
return self.cleaned_data['alternate_title']
return self.cleaned_data["alternate_title"]

View file

@ -0,0 +1,24 @@
from categories.admin import CategoryAdminForm
from categories.base import CategoryBaseAdmin
class CategoryAdmin(CategoryBaseAdmin):
form = CategoryAdminForm
list_display = ("name", "alternate_title", "active")
fieldsets = (
(None, {"fields": ("parent", "name", "thumbnail", "active")}),
(
"Meta Data",
{
"fields": ("alternate_title", "alternate_url", "description", "meta_keywords", "meta_extra"),
"classes": ("collapse",),
},
),
(
"Advanced",
{
"fields": ("order", "slug"),
"classes": ("collapse",),
},
),
)

Some files were not shown because too many files have changed in this diff Show more