├── example ├── __init__.py ├── simpletext │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_auto_20200306_0928.py │ │ ├── 0002_auto_20171204_0721.py │ │ └── 0001_initial.py │ ├── views.py │ ├── tests.py │ ├── admin.py │ └── models.py ├── static │ ├── editor │ │ ├── toggle-expand-dark.png │ │ ├── toggle-collapse-dark.png │ │ ├── toggle-collapse-light.png │ │ ├── toggle-expand-light.png │ │ └── jquery.treeTable.css │ └── js │ │ └── genericcollections.js ├── test_storages.py ├── manage.py ├── templates │ └── base.html ├── urls.py ├── settings.py └── settings-testing.py ├── categories ├── api │ └── __init__.py ├── tests │ ├── __init__.py │ ├── test_image.jpg │ ├── test_manager.py │ ├── test_utils.py │ ├── test_mgmt_commands.py │ ├── test_models.py │ ├── test_migrations.py │ ├── test_registration.py │ ├── test_category_import.py │ ├── test_admin.py │ ├── test_views.py │ └── test_templatetags.py ├── editor │ ├── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ └── admin_tree_list_tags.py │ ├── models.py │ ├── locale │ │ └── de │ │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── static │ │ └── editor │ │ │ ├── toggle-expand-dark.png │ │ │ ├── toggle-collapse-dark.png │ │ │ ├── toggle-collapse-light.png │ │ │ ├── toggle-expand-light.png │ │ │ └── jquery.treeTable.css │ ├── utils.py │ ├── settings.py │ └── templates │ │ └── admin │ │ └── editor │ │ ├── grappelli_tree_list_results.html │ │ ├── tree_list_results.html │ │ ├── tree_editor.html │ │ └── grappelli_tree_editor.html ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── add_category_fields.py │ │ ├── drop_category_field.py │ │ └── import_categories.py ├── migrations │ ├── __init__.py │ ├── 0004_auto_20200517_1832.py │ ├── 0002_auto_20170217_1111.py │ ├── 0005_unique_category_slug.py │ ├── 0003_auto_20200306_1050.py │ └── 0001_initial.py ├── templatetags │ └── __init__.py ├── templates │ ├── categories │ │ ├── base.html │ │ ├── breadcrumbs.html │ │ ├── category_list.html │ │ ├── ancestors_ul.html │ │ ├── ul_tree.html │ │ └── category_detail.html │ └── admin │ │ └── edit_inline │ │ └── gen_coll_tabular.html ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── it │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── __init__.py ├── fixtures │ ├── test_category_tabs.txt │ ├── test_category_spaces.txt │ ├── categories.json │ └── genres.txt ├── utils.py ├── urls.py ├── fields.py ├── static │ └── js │ │ └── genericcollections.js ├── apps.py ├── genericcollection.py ├── settings.py ├── migration.py ├── admin.py ├── models.py └── views.py ├── requirements ├── prod.txt ├── dev.txt └── test.txt ├── requirements.txt ├── doc_src ├── changelog.md ├── requirements.txt ├── api │ └── index.rst ├── user_guide │ ├── index.md │ ├── code_examples │ │ ├── custom_categories5.py │ │ ├── custom_categories1.py │ │ ├── custom_categories2.py │ │ ├── custom_categories6.py │ │ ├── custom_categories4.py │ │ ├── custom_categories7.py │ │ └── custom_categories3.py │ ├── usage_example_template.html │ ├── adding_the_fields.rst │ ├── admin_settings.rst │ ├── usage.rst │ ├── custom_categories.rst │ └── registering_models.rst ├── reference │ ├── index.rst │ ├── management_commands.rst │ ├── models.rst │ └── settings.rst ├── _templates │ └── autosummary │ │ ├── base.rst │ │ ├── class.rst │ │ └── module.rst ├── _static │ └── css │ │ └── custom.css ├── index.rst ├── installation.rst ├── conf.py ├── getting_started.rst ├── Makefile └── make.bat ├── .github ├── changelog_templates │ ├── version_heading.md.jinja │ └── commit.md.jinja └── workflows │ ├── publish-docs.yml │ ├── publish-package.yml │ └── run-tests.yaml ├── CONTRIBUTING.md ├── NOTICES.txt ├── .editorconfig ├── .readthedocs.yaml ├── CREDITS.md ├── MANIFEST.in ├── setup.py ├── tox.ini ├── setup.cfg ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md ├── .gitignore ├── Makefile └── .changelog-config.yaml /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/editor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/simpletext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/prod.txt: -------------------------------------------------------------------------------- 1 | django-mptt 2 | -------------------------------------------------------------------------------- /categories/editor/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /categories/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/simpletext/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/prod.txt 2 | -------------------------------------------------------------------------------- /categories/editor/models.py: -------------------------------------------------------------------------------- 1 | """Placeholder for Django.""" 2 | -------------------------------------------------------------------------------- /doc_src/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | ``` 3 | -------------------------------------------------------------------------------- /example/simpletext/views.py: -------------------------------------------------------------------------------- 1 | """Create your views here.""" 2 | -------------------------------------------------------------------------------- /doc_src/requirements.txt: -------------------------------------------------------------------------------- 1 | # Add any extensions for Sphinx right here 2 | -------------------------------------------------------------------------------- /categories/templates/categories/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 | {% endblock %} 4 | -------------------------------------------------------------------------------- /doc_src/api/index.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | .. autosummary:: 5 | :toctree: 6 | :recursive: 7 | 8 | categories 9 | -------------------------------------------------------------------------------- /categories/tests/test_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/tests/test_image.jpg -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r test.txt 2 | 3 | bump2version>=1.0.1 4 | generate-changelog 5 | git-fame>=1.12.2 6 | pre-commit 7 | -------------------------------------------------------------------------------- /categories/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /categories/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /categories/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /categories/__init__.py: -------------------------------------------------------------------------------- 1 | """Django categories.""" 2 | 3 | __version__ = "2.0.0" 4 | 5 | 6 | default_app_config = "categories.apps.CategoriesConfig" 7 | -------------------------------------------------------------------------------- /example/static/editor/toggle-expand-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/example/static/editor/toggle-expand-dark.png -------------------------------------------------------------------------------- /example/static/editor/toggle-collapse-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/example/static/editor/toggle-collapse-dark.png -------------------------------------------------------------------------------- /example/static/editor/toggle-collapse-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/example/static/editor/toggle-collapse-light.png -------------------------------------------------------------------------------- /example/static/editor/toggle-expand-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/example/static/editor/toggle-expand-light.png -------------------------------------------------------------------------------- /categories/editor/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/editor/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /categories/editor/static/editor/toggle-expand-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/editor/static/editor/toggle-expand-dark.png -------------------------------------------------------------------------------- /categories/editor/static/editor/toggle-collapse-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/editor/static/editor/toggle-collapse-dark.png -------------------------------------------------------------------------------- /categories/editor/static/editor/toggle-collapse-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/editor/static/editor/toggle-collapse-light.png -------------------------------------------------------------------------------- /categories/editor/static/editor/toggle-expand-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-categories/master/categories/editor/static/editor/toggle-expand-light.png -------------------------------------------------------------------------------- /categories/fixtures/test_category_tabs.txt: -------------------------------------------------------------------------------- 1 | Category 1 2 | Category 1-1 3 | Category 1-2 4 | Category 2 5 | Category 2-1 6 | Category 2-1-1 7 | Category 3 8 | Category 3-1 9 | -------------------------------------------------------------------------------- /categories/fixtures/test_category_spaces.txt: -------------------------------------------------------------------------------- 1 | Category 1 2 | Category 1-1 3 | Category 1-2 4 | Category 2 5 | Category 2-1 6 | Category 2-1-1 7 | Category 3 8 | Category 3-1 9 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r prod.txt 2 | django<4.0.0 3 | ghp-import 4 | linkify-it-py 5 | myst-parser 6 | pydata-sphinx-theme 7 | Sphinx>=4.3.0 8 | sphinx-autodoc-typehints 9 | sphinxcontrib-django2 10 | -------------------------------------------------------------------------------- /doc_src/user_guide/index.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ```{toctree} 4 | --- 5 | maxdepth: 2 6 | --- 7 | usage 8 | registering_models 9 | adding_the_fields 10 | admin_settings 11 | custom_categories 12 | ``` 13 | -------------------------------------------------------------------------------- /doc_src/reference/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Reference 3 | ========= 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | :glob: 8 | 9 | management_commands 10 | models 11 | settings 12 | templatetags 13 | -------------------------------------------------------------------------------- /categories/templates/categories/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {% spaceless %}{% for item in category.get_ancestors %} 2 | {{ item.name }}{{ separator }}{% endfor %}{{ category.name }} 3 | {% endspaceless %} 4 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories5.py: -------------------------------------------------------------------------------- 1 | from categories.base import CategoryBase 2 | 3 | 4 | class Meta(CategoryBase.Meta): 5 | verbose_name_plural = "categories" 6 | 7 | 8 | class MPTTMeta: 9 | order_insertion_by = ("order", "name") 10 | -------------------------------------------------------------------------------- /.github/changelog_templates/version_heading.md.jinja: -------------------------------------------------------------------------------- 1 | ## {{ version.label }} ({{ version.date_time.strftime("%Y-%m-%d") }}) 2 | {% if version.previous_tag %} 3 | [Compare the full difference.]({{ repo_url }}/compare/{{ version.previous_tag }}...{{ version.tag }}) 4 | {% endif %} 5 | -------------------------------------------------------------------------------- /categories/templates/categories/category_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'categories/base.html' %} 2 | {% block content %} 3 |

Categories

4 | 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /doc_src/_templates/autosummary/base.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: h4 text-secondary 2 | 3 | {{ fullname }} 4 | 5 | {{ objname | escape | underline}} 6 | 7 | .. currentmodule:: {{ module }} 8 | {%- if not objname.startswith("test") %} 9 | .. auto{{ objtype }}:: {{ objname }} 10 | {%- endif %} 11 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories1.py: -------------------------------------------------------------------------------- 1 | from categories.models import CategoryBase 2 | 3 | 4 | class SimpleCategory(CategoryBase): 5 | """ 6 | A simple of catgorizing example 7 | """ 8 | 9 | class Meta: 10 | verbose_name_plural = "simple categories" 11 | -------------------------------------------------------------------------------- /example/test_storages.py: -------------------------------------------------------------------------------- 1 | # Storages used for testing THUMBNAIL_STORAGE and THUMBNAIL_STORAGE_ALIAS 2 | from django.core.files.storage import FileSystemStorage 3 | 4 | 5 | class MyTestStorage(FileSystemStorage): 6 | pass 7 | 8 | 9 | class MyTestStorageAlias(FileSystemStorage): 10 | pass 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | 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). 4 | -------------------------------------------------------------------------------- /NOTICES.txt: -------------------------------------------------------------------------------- 1 | Django Categories 2 | Copyright 2009 The Washington Times 3 | 4 | This product includes software developed at The Washington Times. 5 | 6 | Portions of this software were developed by matthiask for feincms 7 | (http://github.com/matthiask/feincms/tree/master). Specifically 8 | the Django admin modifications and templates. 9 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories2.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from categories.admin import CategoryBaseAdmin 4 | 5 | from .models import SimpleCategory 6 | 7 | 8 | class SimpleCategoryAdmin(CategoryBaseAdmin): 9 | pass 10 | 11 | 12 | admin.site.register(SimpleCategory, SimpleCategoryAdmin) 13 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Entrypoint for custom django functions.""" 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /categories/utils.py: -------------------------------------------------------------------------------- 1 | """This module contains utility functions that are used across the project.""" 2 | 3 | from django.utils.text import slugify as django_slugify 4 | 5 | 6 | def slugify(text): 7 | """Slugify a string. This function is a wrapper to unify the slugify function across the project.""" 8 | return django_slugify(text, allow_unicode=True) 9 | -------------------------------------------------------------------------------- /.github/changelog_templates/commit.md.jinja: -------------------------------------------------------------------------------- 1 | - {{ commit.summary }} [{{ commit.short_sha }}]({{ repo_url }}/commit/{{ commit.sha }}) 2 | {{ commit.body|indent(2, first=True) }} 3 | {% for key, val in commit.metadata["trailers"].items() %} 4 | {% if key not in VALID_AUTHOR_TOKENS %} 5 | **{{ key }}:** {{ val|join(", ") }} 6 | 7 | {% endif %} 8 | {% endfor %} 9 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | Django Categories 7 | 8 | 9 | {% block content %} 10 | {% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /categories/fixtures/categories.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "level": 0, 5 | "lft": 1, 6 | "name": "Category 1", 7 | "order": 1, 8 | "parent": null, 9 | "rght": 10, 10 | "slug": "category-1", 11 | "tree_id": 1 12 | }, 13 | "model": "categories.category", 14 | "pk": 1 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /categories/editor/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides compatibility with Django 1.8. 3 | """ 4 | 5 | from django.contrib.admin.utils import display_for_field as _display_for_field 6 | 7 | 8 | def display_for_field(value, field, empty_value_display=None): 9 | """Compatility for displaying a field in Django 1.8.""" 10 | try: 11 | return _display_for_field(value, field, empty_value_display) 12 | except TypeError: 13 | return _display_for_field(value, field) 14 | -------------------------------------------------------------------------------- /categories/editor/settings.py: -------------------------------------------------------------------------------- 1 | """Settings management for the editor.""" 2 | 3 | from django.conf import settings 4 | 5 | STATIC_URL = getattr(settings, "STATIC_URL", settings.MEDIA_URL) 6 | if STATIC_URL is None: 7 | STATIC_URL = settings.MEDIA_URL 8 | MEDIA_PATH = getattr(settings, "EDITOR_MEDIA_PATH", "%seditor/" % STATIC_URL) 9 | 10 | TREE_INITIAL_STATE = getattr(settings, "EDITOR_TREE_INITIAL_STATE", "collapsed") 11 | 12 | IS_GRAPPELLI_INSTALLED = "grappelli" in settings.INSTALLED_APPS 13 | -------------------------------------------------------------------------------- /categories/urls.py: -------------------------------------------------------------------------------- 1 | """URL patterns for the categories app.""" 2 | 3 | from django.urls import path, re_path 4 | from django.views.generic import ListView 5 | 6 | from . import views 7 | from .models import Category 8 | 9 | categorytree_dict = {"queryset": Category.objects.filter(level=0)} 10 | 11 | urlpatterns = (path("", ListView.as_view(**categorytree_dict), name="categories_tree_list"),) 12 | 13 | urlpatterns += (re_path(r"^(?P.+)/$", views.category_detail, name="categories_category"),) 14 | -------------------------------------------------------------------------------- /categories/templates/categories/ancestors_ul.html: -------------------------------------------------------------------------------- 1 | {% load category_tags %} 2 | 12 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories6.py: -------------------------------------------------------------------------------- 1 | from categories.base import CategoryBaseAdminForm 2 | from categories.models import Category 3 | 4 | 5 | class CategoryAdminForm(CategoryBaseAdminForm): 6 | class Meta: 7 | model = Category 8 | 9 | def clean_alternate_title(self): 10 | if self.instance is None or not self.cleaned_data["alternate_title"]: 11 | return self.cleaned_data["name"] 12 | else: 13 | return self.cleaned_data["alternate_title"] 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | # Build documentation in the "doc_src/" directory with Sphinx 14 | sphinx: 15 | configuration: doc_src/conf.py 16 | 17 | python: 18 | install: 19 | - requirements: requirements/test.txt 20 | -------------------------------------------------------------------------------- /categories/templates/categories/ul_tree.html: -------------------------------------------------------------------------------- 1 | {% load category_tags %}{% spaceless %} 2 | {% endspaceless %} 12 | -------------------------------------------------------------------------------- /example/simpletext/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates two different styles of tests (one doctest and one 3 | unittest). These will both pass when you run "manage.py test". 4 | 5 | Replace these with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | 18 | 19 | __test__ = { 20 | "doctest": """ 21 | Another way to test that 1 + 1 is equal to 2. 22 | 23 | >>> 1 + 1 == 2 24 | True 25 | """ 26 | } 27 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | Corey Oordt github.com/coordt 5 | Erik Simmler github.com/tgecho 6 | Martin Ogden githun.com/martinogden 7 | Ramiro Morales github.com/ramiro 8 | Evan Culver github.com/eculver 9 | Andrzej Herok github.com/aherok 10 | Jonathan Hensley github.com/jhensley 11 | Justin Quick github.com/justquick 12 | Josh Ourisman github.com/joshourisman 13 | Jose Soares github.com/jsoa 14 | David Charbonnier github.com/oxys 15 | Brad Jasper github.com/bradjasper 16 | Martin Matusiak github.com/numerodix 17 | Iacopo Spalletti github.com/yakky 18 | Brent O'Connor github.com/epicserve 19 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories4.py: -------------------------------------------------------------------------------- 1 | from categories.models import Category 2 | 3 | 4 | def save(self, *args, **kwargs): 5 | if self.thumbnail: 6 | import django 7 | from django.core.files.images import get_image_dimensions 8 | 9 | if django.VERSION[1] < 2: 10 | width, height = get_image_dimensions(self.thumbnail.file) 11 | else: 12 | width, height = get_image_dimensions(self.thumbnail.file, close=True) 13 | else: 14 | width, height = None, None 15 | 16 | self.thumbnail_width = width 17 | self.thumbnail_height = height 18 | 19 | super(Category, self).save(*args, **kwargs) 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include NOTICES.txt 3 | include README.txt 4 | include CREDITS.txt 5 | include LICENSE.txt 6 | 7 | recursive-include categories *.html *.txt *.json *.html 8 | recursive-include categories/static *.html *.gif *.png *.css *.txt *.js 9 | recursive-include categories/editor *.html *.gif *.png *.css *.js 10 | 11 | recursive-include doc_src *.rst *.txt *.png *.css *.html *.js 12 | recursive-exclude doc_src/_build *.* 13 | include doc_src/Makefile 14 | include doc_src/make.bat 15 | 16 | recursive-include categories/locale *.mo *.po 17 | recursive-include categories/editor/locale *.mo *.po 18 | recursive-include requirements *.txt 19 | prune example 20 | -------------------------------------------------------------------------------- /doc_src/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .sig-prename.descclassname { 2 | display: none; 3 | } 4 | dl.attribute { 5 | margin-bottom: 30px; 6 | } 7 | dl.field-list { 8 | display: block; 9 | } 10 | dl.field-list > dt { 11 | padding-left: 0; 12 | } 13 | dl.field-list > dd { 14 | padding-left: 1em; 15 | border-left: 0; 16 | } 17 | dl.field-list > dd > ul.simple { 18 | list-style-type: none; 19 | padding-left: 0; 20 | } 21 | dl.field-list > dd + dt { 22 | margin-top: 0.5em; 23 | } 24 | dd { 25 | margin-left: 0; 26 | padding-left: 30px; 27 | border-left: 1px solid #c9c9c9; 28 | } 29 | .table.autosummary td { 30 | border-top: 0; 31 | border-bottom: 1px solid #dee2e6; 32 | } 33 | -------------------------------------------------------------------------------- /categories/fields.py: -------------------------------------------------------------------------------- 1 | """Custom category fields for other models.""" 2 | 3 | from django.db.models import ForeignKey, ManyToManyField 4 | 5 | 6 | class CategoryM2MField(ManyToManyField): 7 | """A many to many field to a Category model.""" 8 | 9 | def __init__(self, **kwargs): 10 | if "to" in kwargs: 11 | kwargs.pop("to") 12 | super(CategoryM2MField, self).__init__(to="categories.Category", **kwargs) 13 | 14 | 15 | class CategoryFKField(ForeignKey): 16 | """A foreign key to the Category model.""" 17 | 18 | def __init__(self, **kwargs): 19 | if "to" in kwargs: 20 | kwargs.pop("to") 21 | super(CategoryFKField, self).__init__(to="categories.Category", **kwargs) 22 | -------------------------------------------------------------------------------- /categories/migrations/0004_auto_20200517_1832.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-17 18:32 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("categories", "0003_auto_20200306_1050"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="categoryrelation", 16 | name="content_type", 17 | field=models.ForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, to="contenttypes.ContentType", verbose_name="content type" 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /categories/tests/test_manager.py: -------------------------------------------------------------------------------- 1 | # test active returns only active items 2 | from django.test import TestCase 3 | 4 | from categories.models import Category 5 | 6 | 7 | class CategoryManagerTest(TestCase): 8 | fixtures = ["categories.json"] 9 | 10 | def setUp(self): 11 | pass 12 | 13 | def testActive(self): 14 | """ 15 | Should raise an exception. 16 | """ 17 | all_count = Category.objects.all().count() 18 | self.assertEqual(Category.objects.active().count(), all_count) 19 | 20 | cat1 = Category.objects.get(name="Category 1") 21 | cat1.active = False 22 | cat1.save() 23 | 24 | active_count = all_count - cat1.get_descendants(True).count() 25 | self.assertEqual(Category.objects.active().count(), active_count) 26 | -------------------------------------------------------------------------------- /categories/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from ..utils import slugify 4 | 5 | 6 | class TestSlugify(TestCase): 7 | def test_slugify(self): 8 | string_dict = { 9 | "naïve café": "naïve-café", 10 | "spaced out": "spaced-out", 11 | "user@domain.com": "userdomaincom", 12 | "100% natural": "100-natural", 13 | "über-cool": "über-cool", 14 | "façade élégant": "façade-élégant", 15 | "北京大学": "北京大学", 16 | "Толстой": "толстой", 17 | "ñoño": "ñoño", 18 | "سلام": "سلام", 19 | "Αθήνα": "αθήνα", 20 | "こんにちは": "こんにちは", 21 | "˚č$'\\*>%ˇ'!/": "čˇ", 22 | } 23 | for key, value in string_dict.items(): 24 | self.assertEqual(slugify(key), value) 25 | -------------------------------------------------------------------------------- /doc_src/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: h4 text-secondary 2 | 3 | {{ fullname }} 4 | 5 | {{ objname | escape | underline}} 6 | 7 | .. currentmodule:: {{ module }} 8 | 9 | .. autoclass:: {{ objname }} 10 | 11 | {% block methods %} 12 | {% if methods %} 13 | .. rubric:: {{ _('Methods') }} 14 | 15 | .. autosummary:: 16 | {% for item in methods %} 17 | {%- if "__" not in item %} 18 | ~{{ name }}.{{ item }} 19 | {% endif -%} 20 | {%- endfor %} 21 | {% endif %} 22 | {% endblock %} 23 | 24 | {% block attributes %} 25 | {% if attributes %} 26 | .. rubric:: {{ _('Attributes') }} 27 | 28 | .. autosummary:: 29 | {% for item in attributes %} 30 | {%- if "__" not in item %} 31 | ~{{ name }}.{{ item }}{% endif %} 32 | {% endfor -%} 33 | {% endif %} 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories7.py: -------------------------------------------------------------------------------- 1 | from categories.admin import CategoryAdminForm 2 | from categories.base import CategoryBaseAdmin 3 | 4 | 5 | class CategoryAdmin(CategoryBaseAdmin): 6 | form = CategoryAdminForm 7 | list_display = ("name", "alternate_title", "active") 8 | fieldsets = ( 9 | (None, {"fields": ("parent", "name", "thumbnail", "active")}), 10 | ( 11 | "Meta Data", 12 | { 13 | "fields": ("alternate_title", "alternate_url", "description", "meta_keywords", "meta_extra"), 14 | "classes": ("collapse",), 15 | }, 16 | ), 17 | ( 18 | "Advanced", 19 | { 20 | "fields": ("order", "slug"), 21 | "classes": ("collapse",), 22 | }, 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /doc_src/index.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Django Categories v |version| 3 | ============================= 4 | 5 | Django Categories grew out of our need to provide a basic hierarchical taxonomy management system that multiple applications could use independently or in concert. 6 | 7 | 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. 8 | 9 | 10 | Contents 11 | ======== 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | :glob: 16 | 17 | installation 18 | getting_started 19 | user_guide/index 20 | reference/index 21 | api/index 22 | changelog 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` 30 | -------------------------------------------------------------------------------- /example/simpletext/migrations/0003_auto_20200306_0928.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-06 09:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("simpletext", "0002_auto_20171204_0721"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="simplecategory", 14 | name="level", 15 | field=models.PositiveIntegerField(editable=False), 16 | ), 17 | migrations.AlterField( 18 | model_name="simplecategory", 19 | name="lft", 20 | field=models.PositiveIntegerField(editable=False), 21 | ), 22 | migrations.AlterField( 23 | model_name="simplecategory", 24 | name="rght", 25 | field=models.PositiveIntegerField(editable=False), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /example/static/js/genericcollections.js: -------------------------------------------------------------------------------- 1 | function showGenericRelatedObjectLookupPopup(triggeringLink, ctArray) { 2 | var realName = triggeringLink.id.replace(/^lookup_/, ''); 3 | var name = id_to_windowname(realName); 4 | realName = realName.replace(/object_id/, 'content_type'); 5 | var select = document.getElementById(realName); 6 | if (select.selectedIndex === 0) { 7 | alert("Select a content type first."); 8 | return false; 9 | } 10 | var selectedItem = select.item(select.selectedIndex).value; 11 | var href = triggeringLink.href.replace(/#/,'../../../'+ctArray[selectedItem]+"/?t=id"); 12 | if (href.search(/\?/) >= 0) { 13 | href = href + '&pop=1'; 14 | } else { 15 | href = href + '?pop=1'; 16 | } 17 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 18 | win.focus(); 19 | return false; 20 | } 21 | -------------------------------------------------------------------------------- /categories/static/js/genericcollections.js: -------------------------------------------------------------------------------- 1 | function showGenericRelatedObjectLookupPopup(triggeringLink, ctArray) { 2 | var realName = triggeringLink.id.replace(/^lookup_/, ''); 3 | var name = id_to_windowname(realName); 4 | realName = realName.replace(/object_id/, 'content_type'); 5 | var select = document.getElementById(realName); 6 | if (select.selectedIndex === 0) { 7 | alert("Select a content type first."); 8 | return false; 9 | } 10 | var selectedItem = select.item(select.selectedIndex).value; 11 | var href = triggeringLink.href.replace(/#/,'../../../'+ctArray[selectedItem]+"/?t=id"); 12 | if (href.search(/\?/) >= 0) { 13 | href = href + '&pop=1'; 14 | } else { 15 | href = href + '?pop=1'; 16 | } 17 | var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); 18 | win.focus(); 19 | return false; 20 | } 21 | -------------------------------------------------------------------------------- /doc_src/user_guide/usage_example_template.html: -------------------------------------------------------------------------------- 1 | {% extends 'categories/base.html' %} 2 | {% block content %} 3 |

{{ category }}

4 | {% if category.parent %} 5 |

Go up to 6 | 7 | {{ category.parent }} 8 |

9 | {% endif %} 10 | {% if category.description %}

{{ category.description }}

{% endif %} 11 | {% if category.children.count %} 12 |

Subcategories

13 | 18 | {% endif %} 19 |

Entries

20 | {% if category.entries_set.all %} 21 | {% for entry in category.entries_set.all %} 22 |

{{ entry.headline }}

23 | {% endfor %} 24 | {% else %} 25 |

No entries for {{ category }}

26 | {% endif %} 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /categories/migrations/0002_auto_20170217_1111.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.5 on 2017-02-17 11:11 2 | from __future__ import unicode_literals 3 | 4 | import django.db.models.manager 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("categories", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelManagers( 15 | name="category", 16 | managers=[ 17 | ("tree", django.db.models.manager.Manager()), 18 | ], 19 | ), 20 | migrations.AlterField( 21 | model_name="categoryrelation", 22 | name="relation_type", 23 | field=models.CharField( 24 | blank=True, 25 | help_text="A generic text field to tag a relation, like 'leadphoto'.", 26 | max_length=200, 27 | null=True, 28 | verbose_name="relation type", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /categories/templates/categories/category_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'categories/base.html' %} 2 | {% load category_tags %} 3 | {% block content %} 4 |

breadcrumbs category result:

5 | {% breadcrumbs category %} 6 |

breadcrumbs category ">" result:

7 | {% breadcrumbs category ">" %} 8 |

display_path_as_ul category result:

9 | {% display_path_as_ul category %} 10 |

display_drilldown_as_ul category result:

11 | {% display_drilldown_as_ul category %} 12 |

category.get_ancestors|category_path filter result: {{ category.get_ancestors|category_path }}

13 |

{{ category }}

14 | {% if category.parent %}

Go up to {{ category.parent }}

{% endif %} 15 | {% if category.description %}

{{ category.description }}

{% endif %} 16 | {% if category.children.count %}

Subcategories

{% endif %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /example/simpletext/admin.py: -------------------------------------------------------------------------------- 1 | """Admin interface for simple text.""" 2 | 3 | from django.contrib import admin 4 | 5 | from categories.admin import CategoryBaseAdmin, CategoryBaseAdminForm 6 | 7 | from .models import SimpleCategory, SimpleText 8 | 9 | 10 | class SimpleTextAdmin(admin.ModelAdmin): 11 | """Admin for simple text model.""" 12 | 13 | fieldsets = ( 14 | ( 15 | None, 16 | { 17 | "fields": ( 18 | "name", 19 | "description", 20 | ) 21 | }, 22 | ), 23 | ) 24 | 25 | 26 | class SimpleCategoryAdminForm(CategoryBaseAdminForm): 27 | """Admin form for simple category.""" 28 | 29 | class Meta: 30 | model = SimpleCategory 31 | fields = "__all__" 32 | 33 | 34 | class SimpleCategoryAdmin(CategoryBaseAdmin): 35 | """Admin for simple category.""" 36 | 37 | form = SimpleCategoryAdminForm 38 | 39 | 40 | admin.site.register(SimpleText, SimpleTextAdmin) 41 | admin.site.register(SimpleCategory, SimpleCategoryAdmin) 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish documentation 2 | 3 | on: 4 | push: 5 | tags: [ "*" ] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.10"] 17 | fail-fast: false 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install -r requirements/dev.txt 31 | 32 | - name: Build documentation 33 | run: | 34 | make docs 35 | 36 | - name: github pages deploy 37 | uses: peaceiris/actions-gh-pages@v3.8.0 38 | with: 39 | github_token: ${{ secrets.GITHUB_TOKEN }} 40 | publish_branch: gh-pages 41 | publish_dir: docs 42 | force_orphan: true 43 | -------------------------------------------------------------------------------- /categories/management/commands/add_category_fields.py: -------------------------------------------------------------------------------- 1 | """The add_category_fields command.""" 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | 6 | class Command(BaseCommand): 7 | """ 8 | Alter one or more models' tables with the registered attributes. 9 | """ 10 | 11 | help = "Alter the tables for all registered models, or just specified models" 12 | args = "[appname ...]" 13 | can_import_settings = True 14 | requires_system_checks = [] 15 | 16 | def add_arguments(self, parser): 17 | """Add app_names argument to the command.""" 18 | parser.add_argument("app_names", nargs="*") 19 | 20 | def handle(self, *args, **options): 21 | """ 22 | Alter the tables. 23 | """ 24 | from categories.migration import migrate_app 25 | from categories.settings import MODEL_REGISTRY 26 | 27 | if options["app_names"]: 28 | for app in options["app_names"]: 29 | migrate_app(None, app) 30 | else: 31 | for app in MODEL_REGISTRY: 32 | migrate_app(None, app) 33 | -------------------------------------------------------------------------------- /categories/tests/test_mgmt_commands.py: -------------------------------------------------------------------------------- 1 | from django.core import management 2 | from django.core.management.base import CommandError 3 | from django.db import connection 4 | from django.test import TestCase 5 | 6 | 7 | class TestMgmtCommands(TestCase): 8 | @classmethod 9 | def setUpClass(cls): 10 | connection.disable_constraint_checking() 11 | super(TestMgmtCommands, cls).setUpClass() 12 | 13 | @classmethod 14 | def tearDownClass(cls): 15 | super(TestMgmtCommands, cls).tearDownClass() 16 | connection.enable_constraint_checking() 17 | 18 | def test_add_category_fields(self): 19 | management.call_command("add_category_fields", verbosity=0) 20 | 21 | def test_add_category_fields_app(self): 22 | management.call_command("add_category_fields", "flatpages", verbosity=0) 23 | 24 | def test_drop_category_field(self): 25 | management.call_command("drop_category_field", "flatpages", "flatpage", "category", verbosity=0) 26 | 27 | def test_drop_category_field_error(self): 28 | self.assertRaises(CommandError, management.call_command, "drop_category_field", verbosity=0) 29 | -------------------------------------------------------------------------------- /categories/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.core.files import File 5 | from django.core.files.uploadedfile import UploadedFile 6 | from django.test import TestCase 7 | 8 | from categories.models import Category 9 | from example.test_storages import MyTestStorage, MyTestStorageAlias 10 | 11 | 12 | class TestCategoryThumbnail(TestCase): 13 | def test_thumbnail(self): 14 | file_name = "test_image.jpg" 15 | with open(os.path.join(os.path.dirname(__file__), file_name), "rb") as f: 16 | test_image = UploadedFile(File(f), content_type="image/jpeg") 17 | category = Category.objects.create(name="Test Category", slug="test-category", thumbnail=test_image) 18 | 19 | self.assertEqual(category.pk, 1) 20 | self.assertEqual(category.thumbnail_width, 640) 21 | self.assertEqual(category.thumbnail_height, 480) 22 | 23 | if django.VERSION >= (4, 2): 24 | self.assertEqual(category.thumbnail.storage.__class__, MyTestStorageAlias) 25 | else: 26 | self.assertEqual(category.thumbnail.storage.__class__, MyTestStorage) 27 | -------------------------------------------------------------------------------- /doc_src/reference/management_commands.rst: -------------------------------------------------------------------------------- 1 | .. _management-commands: 2 | 3 | =================== 4 | Management Commands 5 | =================== 6 | 7 | .. _import_categories: 8 | 9 | import_categories 10 | ================= 11 | 12 | **Usage:** ``./manage.py import_categories /path/to/file.txt [/path/to/file2.txt]`` 13 | 14 | Imports category tree(s) from a file. Sub categories must be indented by the same multiple of spaces or tabs. The first line in the file cannot start with a space or tab and you can't mix spaces and tabs. 15 | 16 | 17 | .. _add_category_fields: 18 | 19 | add_category_fields 20 | =================== 21 | 22 | **Usage:** ``./manage.py add_category_fields [app1 app2 ...]`` 23 | 24 | Add missing registered category fields to the database table of a specified application or all registered applications. 25 | 26 | Requires Django South. 27 | 28 | 29 | .. _drop_category_field: 30 | 31 | drop_category_field 32 | =================== 33 | 34 | **Usage:** ``./manage.py drop_category_field app_name model_name field_name`` 35 | 36 | Drop the ``field_name`` field from the ``app_name_model_name`` table, if the field is currently registered in ``CATEGORIES_SETTINGS``\ . 37 | 38 | Requires Django South. 39 | -------------------------------------------------------------------------------- /categories/management/commands/drop_category_field.py: -------------------------------------------------------------------------------- 1 | """Alter one or more models' tables with the registered attributes.""" 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | 6 | class Command(BaseCommand): 7 | """ 8 | Alter one or more models' tables with the registered attributes. 9 | """ 10 | 11 | help = "Drop the given field from the given model's table" 12 | args = "appname modelname fieldname" 13 | can_import_settings = True 14 | requires_system_checks = [] 15 | 16 | def add_arguments(self, parser): 17 | """Add app_name, model_name, and field_name arguments to the command.""" 18 | parser.add_argument("app_name") 19 | parser.add_argument("model_name") 20 | parser.add_argument("field_name") 21 | 22 | def handle(self, *args, **options): 23 | """ 24 | Alter the tables. 25 | """ 26 | from categories.migration import drop_field 27 | 28 | if "app_name" not in options or "model_name" not in options or "field_name" not in options: 29 | raise CommandError("You must specify an Application name, a Model name and a Field name") 30 | 31 | drop_field(options["app_name"], options["model_name"], options["field_name"]) 32 | -------------------------------------------------------------------------------- /doc_src/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | To use the Category model 6 | ========================= 7 | 8 | 1. Install django-categories:: 9 | 10 | pip install django-categories 11 | 12 | 2. Add ``"categories"`` and ``"categories.editor"`` to your ``INSTALLED_APPS`` list in your project's ``settings.py`` file. 13 | 14 | .. code-block:: python 15 | 16 | INSTALLED_APPS = [ 17 | # ... 18 | "categories", 19 | "categories.editor", 20 | ] 21 | 22 | 3. Run ``./manage.py syncdb`` (or ``./manage.py migrate categories`` if you are using `South `_) 23 | 24 | 25 | To only subclass CategoryBase 26 | ============================= 27 | 28 | If you are going to create your own models using :py:class:`CategoryBase`, (see :ref:`creating_custom_categories`) you'll need a few different steps. 29 | 30 | 1. Install django-categories:: 31 | 32 | pip install django-categories 33 | 34 | 2. Add ``"categories.editor"`` to your ``INSTALLED_APPS`` list in your project's ``settings.py`` file. 35 | 36 | .. code-block:: python 37 | 38 | INSTALLED_APPS = [ 39 | # ... 40 | "categories.editor", 41 | ] 42 | 43 | 3. Create your own models. 44 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | """URL patterns for the example project.""" 2 | 3 | import os 4 | 5 | from django.conf.urls import include 6 | from django.contrib import admin 7 | from django.urls import path, re_path 8 | from django.views.static import serve 9 | 10 | admin.autodiscover() 11 | 12 | 13 | ROOT_PATH = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | urlpatterns = ( 16 | # Example: 17 | # (r'^sample/', include('sample.foo.urls')), 18 | # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 19 | # to INSTALLED_APPS to enable admin documentation: 20 | # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 21 | # Uncomment the next line to enable the admin: 22 | path("admin/", admin.site.urls), 23 | path("categories/", include("categories.urls")), 24 | # r'^cats/', include('categories.urls')), 25 | re_path( 26 | r"^static/categories/(?P.*)$", serve, {"document_root": ROOT_PATH + "/categories/media/categories/"} 27 | ), 28 | # (r'^static/editor/(?P.*)$', 'django.views.static.serve', 29 | # {'document_root': ROOT_PATH + '/editor/media/editor/', 30 | # 'show_indexes':True}), 31 | re_path(r"^static/(?P.*)$", serve, {"document_root": os.path.join(ROOT_PATH, "example", "static")}), 32 | ) 33 | -------------------------------------------------------------------------------- /categories/editor/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # German translation of django-categories 2 | # Copyright (C) 2012 winniehell 3 | # This file is distributed under the same license as django-categories. 4 | # winniehell , 2012. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-categories 1.1.4\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2012-09-02 12:39+0200\n" 10 | "PO-Revision-Date: 2012-09-01 02:38+0200\n" 11 | "Last-Translator: winniehell \n" 12 | "Language-Team: winniehell \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 18 | 19 | #: tree_editor.py:167 20 | msgid "Database error" 21 | msgstr "" 22 | 23 | #: tree_editor.py:206 24 | #, python-format 25 | msgid "%(count)s %(name)s was changed successfully." 26 | msgid_plural "%(count)s %(name)s were changed successfully." 27 | msgstr[0] "" 28 | msgstr[1] "" 29 | 30 | #: tree_editor.py:247 31 | #, python-format 32 | msgid "%(total_count)s selected" 33 | msgid_plural "All %(total_count)s selected" 34 | msgstr[0] "" 35 | msgstr[1] "" 36 | 37 | #: tree_editor.py:252 38 | #, python-format 39 | msgid "0 of %(cnt)s selected" 40 | msgstr "" 41 | -------------------------------------------------------------------------------- /categories/editor/templates/admin/editor/grappelli_tree_list_results.html: -------------------------------------------------------------------------------- 1 | {% if result_hidden_fields %} 2 |
{# DIV for HTML validation #} 3 | {% for item in result_hidden_fields %}{{ item }}{% endfor %} 4 |
5 | {% endif %} 6 | 7 | {% if results %} 8 |
9 | 10 | 11 | 12 | {% for header in result_headers %} 13 | 18 | {% endfor %} 19 | 20 | 21 | 22 | {% for result in results %} 23 | {% for item in result %}{{ item }}{% endfor %} 24 | {% endfor %} 25 | 26 |
14 | {% if header.sortable %}{% endif %} 15 | {{ header.text|capfirst }} 16 | {% if header.sortable %}{% endif %} 17 |
27 |
28 | {% endif %} 29 | -------------------------------------------------------------------------------- /doc_src/user_guide/code_examples/custom_categories3.py: -------------------------------------------------------------------------------- 1 | from categories import models, settings 2 | from categories.base import CategoryBase 3 | 4 | 5 | class Category(CategoryBase): 6 | thumbnail = models.FileField( 7 | upload_to=settings.THUMBNAIL_UPLOAD_PATH, 8 | null=True, 9 | blank=True, 10 | storage=settings.THUMBNAIL_STORAGE, 11 | ) 12 | thumbnail_width = models.IntegerField(blank=True, null=True) 13 | thumbnail_height = models.IntegerField(blank=True, null=True) 14 | order = models.IntegerField(default=0) 15 | alternate_title = models.CharField( 16 | blank=True, default="", max_length=100, help_text="An alternative title to use on pages with this category." 17 | ) 18 | alternate_url = models.CharField( 19 | blank=True, 20 | max_length=200, 21 | help_text="An alternative URL to use instead of the one derived from " "the category hierarchy.", 22 | ) 23 | description = models.TextField(blank=True, null=True) 24 | meta_keywords = models.CharField( 25 | blank=True, default="", max_length=255, help_text="Comma-separated keywords for search engines." 26 | ) 27 | meta_extra = models.TextField( 28 | blank=True, default="", help_text="(Advanced) Any additional HTML to be placed verbatim " "in the <head>" 29 | ) 30 | -------------------------------------------------------------------------------- /categories/migrations/0005_unique_category_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.9 on 2018-10-05 13:59 2 | 3 | from django.db import migrations, models 4 | 5 | from categories.models import Category 6 | 7 | 8 | def make_slugs_unique(apps, schema_editor): 9 | duplicates = Category.tree.values("slug").annotate(slug_count=models.Count("slug")).filter(slug_count__gt=1) 10 | category_objs = [] 11 | for duplicate in duplicates: 12 | slug = duplicate["slug"] 13 | categories = Category.tree.filter(slug=slug) 14 | count = categories.count() 15 | i = 0 16 | for category in categories.all(): 17 | if i != 0: 18 | category.slug = "{}-{}".format(slug, str(i).zfill(len(str(count)))) 19 | category_objs.append(category) 20 | i += 1 21 | Category.objects.bulk_update(category_objs, ["slug"]) 22 | 23 | 24 | class Migration(migrations.Migration): 25 | dependencies = [ 26 | ("categories", "0004_auto_20200517_1832"), 27 | ] 28 | 29 | operations = [ 30 | migrations.RunPython(make_slugs_unique, reverse_code=migrations.RunPython.noop), 31 | migrations.AlterField( 32 | model_name="category", 33 | name="slug", 34 | field=models.SlugField(unique=True, verbose_name="slug"), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Release package on PyPI 2 | 3 | on: 4 | push: 5 | tags: [ "*" ] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-n-publish: 12 | name: Build package and publish to TestPyPI and PyPI 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - uses: actions/setup-python@v2 19 | name: Use Python 3.9 20 | with: 21 | python-version: 3.9 22 | 23 | - name: Install pypa/build 24 | run: >- 25 | python -m 26 | pip install 27 | build 28 | --user 29 | 30 | - name: Build a binary wheel and a source tarball 31 | run: >- 32 | python -m 33 | build 34 | --sdist 35 | --wheel 36 | --outdir dist/ 37 | 38 | - name: Publish package to Test PyPI 39 | uses: pypa/gh-action-pypi-publish@master 40 | with: 41 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 42 | repository_url: https://test.pypi.org/legacy/ 43 | 44 | - name: Publish package 45 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 46 | uses: pypa/gh-action-pypi-publish@v1.5.1 47 | with: 48 | password: ${{ secrets.PYPI_API_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tox tests 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [ 3.7, 3.8, 3.9, '3.10', '3.11', '3.12' ] 12 | fail-fast: false 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | echo "::set-output name=dir::$(pip cache dir)" 27 | 28 | - name: Cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: 33 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }}-${{ hashFiles('**/tox.ini') }} 34 | restore-keys: | 35 | ${{ matrix.python-version }}-v1- 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install tox tox-gh-actions 41 | 42 | - name: Test with tox 43 | run: tox 44 | 45 | - name: Upload coverage 46 | uses: codecov/codecov-action@v1 47 | with: 48 | name: Python ${{ matrix.python-version }} 49 | -------------------------------------------------------------------------------- /example/simpletext/models.py: -------------------------------------------------------------------------------- 1 | """Example model.""" 2 | 3 | from django.db import models 4 | 5 | from categories.base import CategoryBase 6 | 7 | 8 | class SimpleText(models.Model): 9 | """ 10 | (SimpleText description). 11 | """ 12 | 13 | name = models.CharField(max_length=255) 14 | description = models.TextField(blank=True) 15 | created = models.DateTimeField(auto_now_add=True) 16 | updated = models.DateTimeField(auto_now=True) 17 | 18 | class Meta: 19 | verbose_name_plural = "Simple Text" 20 | ordering = ("-created",) 21 | get_latest_by = "updated" 22 | 23 | def __unicode__(self): 24 | return self.name 25 | 26 | def get_absolute_url(self): 27 | """Get the absolute URL for this object.""" 28 | try: 29 | from django.db.models import permalink 30 | 31 | return permalink("simpletext_detail_view_name", [str(self.id)]) 32 | except ImportError: 33 | from django.urls import reverse 34 | 35 | return reverse("simpletext_detail_view_name", args=[str(self.id)]) 36 | 37 | 38 | class SimpleCategory(CategoryBase): 39 | """A Test of catgorizing.""" 40 | 41 | class Meta: 42 | verbose_name_plural = "simple categories" 43 | 44 | 45 | # mport categories 46 | 47 | # ategories.register_fk(SimpleText, 'primary_category', {'related_name':'simpletext_primary_set'}) 48 | # ategories.register_m2m(SimpleText, 'cats', ) 49 | -------------------------------------------------------------------------------- /categories/apps.py: -------------------------------------------------------------------------------- 1 | """Django application setup.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class CategoriesConfig(AppConfig): 7 | """Application configuration for categories.""" 8 | 9 | name = "categories" 10 | verbose_name = "Categories" 11 | 12 | def __init__(self, *args, **kwargs): 13 | super(CategoriesConfig, self).__init__(*args, **kwargs) 14 | from django.db.models.signals import class_prepared 15 | 16 | class_prepared.connect(handle_class_prepared) 17 | 18 | def ready(self): 19 | """Migrate the app after it is ready.""" 20 | from django.db.models.signals import post_migrate 21 | 22 | from .migration import migrate_app 23 | 24 | post_migrate.connect(migrate_app) 25 | 26 | 27 | def handle_class_prepared(sender, **kwargs): 28 | """ 29 | See if this class needs registering of fields. 30 | """ 31 | from .registration import registry 32 | from .settings import FK_REGISTRY, M2M_REGISTRY 33 | 34 | sender_app = sender._meta.app_label 35 | sender_name = sender._meta.model_name 36 | 37 | for key, val in list(FK_REGISTRY.items()): 38 | app_name, model_name = key.split(".") 39 | if app_name == sender_app and sender_name == model_name: 40 | registry.register_model(app_name, sender, "ForeignKey", val) 41 | 42 | for key, val in list(M2M_REGISTRY.items()): 43 | app_name, model_name = key.split(".") 44 | if app_name == sender_app and sender_name == model_name: 45 | registry.register_model(app_name, sender, "ManyToManyField", val) 46 | -------------------------------------------------------------------------------- /categories/migrations/0003_auto_20200306_1050.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-03-06 10:50 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("contenttypes", "0002_remove_content_type_name"), 10 | ("categories", "0002_auto_20170217_1111"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="category", 16 | name="level", 17 | field=models.PositiveIntegerField(editable=False), 18 | ), 19 | migrations.AlterField( 20 | model_name="category", 21 | name="lft", 22 | field=models.PositiveIntegerField(editable=False), 23 | ), 24 | migrations.AlterField( 25 | model_name="category", 26 | name="rght", 27 | field=models.PositiveIntegerField(editable=False), 28 | ), 29 | migrations.AlterField( 30 | model_name="categoryrelation", 31 | name="content_type", 32 | field=models.ForeignKey( 33 | limit_choices_to=models.Q( 34 | models.Q(("app_label", "simpletext"), ("model", "simpletext")), 35 | models.Q(("app_label", "flatpages"), ("model", "flatpage")), 36 | _connector="OR", 37 | ), 38 | on_delete=django.db.models.deletion.CASCADE, 39 | to="contenttypes.ContentType", 40 | verbose_name="content type", 41 | ), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """The setup script.""" 2 | 3 | from pathlib import Path 4 | 5 | from setuptools import setup 6 | 7 | 8 | def parse_reqs(filepath: str) -> list: 9 | """ 10 | Parse a file path containing requirements and return a list of requirements. 11 | 12 | Will properly follow ``-r`` and ``--requirements`` links like ``pip``. This 13 | means nested requirements will be returned as one list. 14 | 15 | Other ``pip``-specific lines are excluded. 16 | 17 | Args: 18 | filepath: The path to the requirements file 19 | 20 | Returns: 21 | All the requirements as a list. 22 | """ 23 | path = Path(filepath) 24 | reqstr = path.read_text() 25 | reqs = [] 26 | for line in reqstr.splitlines(): 27 | line = line.strip() 28 | if line == "": 29 | continue 30 | elif not line or line.startswith("#"): 31 | # comments are lines that start with # only 32 | continue 33 | elif line.startswith("-r") or line.startswith("--requirement"): 34 | _, new_filename = line.split() 35 | new_file_path = path.parent / new_filename 36 | reqs.extend(parse_reqs(new_file_path)) 37 | elif line.startswith("-f") or line.startswith("-i") or line.startswith("--"): 38 | continue 39 | elif line.startswith("-Z") or line.startswith("--always-unzip"): 40 | continue 41 | else: 42 | reqs.append(line) 43 | return reqs 44 | 45 | 46 | requirements = parse_reqs("requirements.txt") 47 | 48 | setup( 49 | install_requires=requirements, 50 | py_modules=[], 51 | ) 52 | -------------------------------------------------------------------------------- /example/simpletext/migrations/0002_auto_20171204_0721.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.10.5 on 2017-12-04 07:21 2 | from __future__ import unicode_literals 3 | 4 | import django.db.models.deletion 5 | import django.db.models.manager 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("categories", "0002_auto_20170217_1111"), 12 | ("simpletext", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelManagers( 17 | name="simplecategory", 18 | managers=[ 19 | ("tree", django.db.models.manager.Manager()), 20 | ], 21 | ), 22 | migrations.AddField( 23 | model_name="simpletext", 24 | name="categories", 25 | field=models.ManyToManyField(blank=True, related_name="m2mcats", to="categories.Category"), 26 | ), 27 | migrations.AddField( 28 | model_name="simpletext", 29 | name="primary_category", 30 | field=models.ForeignKey( 31 | blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="categories.Category" 32 | ), 33 | ), 34 | migrations.AddField( 35 | model_name="simpletext", 36 | name="secondary_category", 37 | field=models.ForeignKey( 38 | blank=True, 39 | null=True, 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="simpletext_sec_cat", 42 | to="categories.Category", 43 | ), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | begin 4 | py37-lint 5 | py{37,38,39}-django{22,3,31} 6 | py{37,38,39,310}-django{32} 7 | py{38,39,310}-django{40} 8 | py{38,39,310,311}-django{41} 9 | py{310,311,312}-django{42,50,51} 10 | coverage-report 11 | 12 | [gh-actions] 13 | python = 14 | 3.7: py37 15 | 3.8: py38 16 | 3.9: py39 17 | 3.10: py310 18 | 3.11: py311 19 | 20 | [testenv] 21 | passenv = GITHUB_* 22 | 23 | deps= 24 | django22: Django>=2.2,<2.3 25 | django3: Django>=3.0,<3.1 26 | django31: Django>=3.1,<3.2 27 | django32: Django>=3.2,<4.0 28 | django40: Django>=4.0,<4.1 29 | django41: Django>=4.1,<4.2 30 | django42: Django>=4.2,<5.0 31 | django50: Django>=5.0,<5.1 32 | django51: Django>=5.1a1,<5.2 33 | coverage[toml] 34 | pillow 35 | ipdb 36 | codecov 37 | django-test-migrations 38 | django21: django-test-migrations<=1.2.0 39 | django22: django-test-migrations<=1.2.0 40 | django3: django-test-migrations<=1.2.0 41 | django31: django-test-migrations<=1.2.0 42 | -r{toxinidir}/requirements.txt 43 | 44 | commands= 45 | coverage erase 46 | coverage run \ 47 | --source=categories \ 48 | --omit='.tox/*,example/*,*/tests/*' \ 49 | {toxinidir}/example/manage.py \ 50 | test \ 51 | --settings='settings-testing' \ 52 | categories{posargs} 53 | coverage report -m 54 | coverage xml 55 | 56 | [testenv:begin] 57 | commands = coverage erase 58 | 59 | [testenv:py36-lint] 60 | deps= 61 | flake8 62 | 63 | commands= 64 | flake8 65 | 66 | [testenv:coverage-report] 67 | commands = 68 | coverage report -m 69 | coverage xml 70 | codecov 71 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.9.2 3 | commit = True 4 | commit_args = --no-verify 5 | tag = True 6 | tag_name = {new_version} 7 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\+\w+-(?P\d+))? 8 | serialize = 9 | {major}.{minor}.{patch}+{$BRANCH_NAME}-{dev} 10 | {major}.{minor}.{patch} 11 | message = Version updated from {current_version} to {new_version} 12 | 13 | [metadata] 14 | name = django-categories 15 | version = attr:categories.__version__ 16 | description = A way to handle one or more hierarchical category trees in django. 17 | long_description = file:README.md 18 | long_description_content_type = text/markdown 19 | author = Corey Oordt 20 | author_email = coreyoordt@gmail.com 21 | url = http://github.com/jazzband/django-categories 22 | classifiers = 23 | Framework :: Django 24 | 25 | [options] 26 | zip_safe = False 27 | include_package_data = True 28 | packages = find: 29 | 30 | [options.packages.find] 31 | exclude = 32 | example* 33 | docs 34 | build 35 | include = 36 | categories 37 | categories.* 38 | 39 | [flake8] 40 | ignore = D203,W503,E501 41 | exclude = 42 | .git 43 | .tox 44 | docs 45 | build 46 | dist 47 | doc_src 48 | max-line-length = 119 49 | 50 | [darglint] 51 | ignore = DAR402 52 | 53 | [bdist_wheel] 54 | universal = 1 55 | 56 | [bumpversion:part:dev] 57 | 58 | [bumpversion:file:setup.cfg] 59 | 60 | [bumpversion:file:categories/__init__.py] 61 | 62 | [bumpversion:file(version heading):CHANGELOG.md] 63 | search = Unreleased 64 | 65 | [bumpversion:file(diff link):CHANGELOG.md] 66 | search = {current_version}...HEAD 67 | replace = {current_version}...{new_version} 68 | 69 | [zest.releaser] 70 | python-file-with-version = categories/__init__.py 71 | -------------------------------------------------------------------------------- /doc_src/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: h4 text-secondary 2 | 3 | {{ fullname }} 4 | 5 | {{ objname | escape | underline}} 6 | .. currentmodule:: {{ fullname }} 7 | 8 | 9 | .. automodule:: {{ fullname }} 10 | 11 | {% block modules -%} 12 | {% if modules %} 13 | 14 | .. rubric:: Submodules 15 | 16 | .. autosummary:: 17 | :toctree: 18 | :recursive: 19 | {% for item in modules %} 20 | {% if "migrations" not in item and "tests" not in item%}{{ item }}{% endif %} 21 | {%- endfor %} 22 | {% endif %} 23 | {% endblock %} 24 | {% block attributes -%} 25 | {%- if attributes -%} 26 | .. rubric:: {{ _('Module Attributes') }} 27 | 28 | .. autosummary:: 29 | :toctree: 30 | {% for item in attributes %} 31 | {{ item }} 32 | {%- endfor -%} 33 | {%- endif -%} 34 | {% endblock attributes -%} 35 | {%- block functions -%} 36 | {%- if functions %} 37 | 38 | .. rubric:: {{ _('Functions') }} 39 | 40 | .. autosummary:: 41 | :toctree: 42 | {% for item in functions %} 43 | {{ item }} 44 | {%- endfor -%} 45 | {%- endif -%} 46 | {% endblock functions -%} 47 | {% block classes -%} 48 | {% if classes %} 49 | 50 | .. rubric:: {{ _('Classes') }} 51 | 52 | .. autosummary:: 53 | :toctree: 54 | {% for item in classes %} 55 | {{ item }} 56 | {%- endfor -%} 57 | {%- endif -%} 58 | {% endblock classes -%} 59 | {% block exceptions -%} 60 | {% if exceptions %} 61 | 62 | .. rubric:: {{ _('Exceptions') }} 63 | 64 | .. autosummary:: 65 | :toctree: 66 | {% for item in exceptions %} 67 | {{ item }} 68 | {%- endfor -%} 69 | {%- endif -%} 70 | {% endblock exceptions -%} 71 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 5.13.2 4 | hooks: 5 | - id: isort 6 | additional_dependencies: [toml] 7 | - repo: https://github.com/psf/black 8 | rev: 24.4.2 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.6.0 13 | hooks: 14 | - id: check-added-large-files 15 | - id: check-case-conflict 16 | - id: check-executables-have-shebangs 17 | - id: check-json 18 | - id: check-merge-conflict 19 | - id: check-shebang-scripts-are-executable 20 | - id: check-symlinks 21 | - id: check-toml 22 | - id: check-yaml 23 | - id: debug-statements 24 | - id: end-of-file-fixer 25 | exclude: "^tests/resources/" 26 | - id: fix-byte-order-marker 27 | - id: fix-encoding-pragma 28 | args: ["--remove"] 29 | - id: requirements-txt-fixer 30 | - repo: https://github.com/PyCQA/flake8 31 | rev: 7.0.0 32 | hooks: 33 | - id: flake8 34 | - repo: https://github.com/pycqa/pydocstyle 35 | rev: 6.3.0 36 | hooks: 37 | - id: pydocstyle 38 | exclude: test.*|custom_.*|\d\d\d\d_.* 39 | additional_dependencies: [toml] 40 | - repo: https://github.com/terrencepreilly/darglint 41 | rev: v1.8.1 42 | hooks: 43 | - id: darglint 44 | args: 45 | - -v 2 46 | - "--message-template={path}:{line} in `{obj}`:\n {msg_id}: {msg}" 47 | - --strictness=short 48 | exclude: test.*|custom_.*|\d\d\d\d_.* 49 | 50 | - repo: https://github.com/econchick/interrogate 51 | rev: 1.7.0 # or master if you're bold 52 | hooks: 53 | - id: interrogate 54 | -------------------------------------------------------------------------------- /categories/editor/templates/admin/editor/tree_list_results.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if result_hidden_fields %} 3 |
{# DIV for HTML validation #} 4 | {% for item in result_hidden_fields %}{{ item }}{% endfor %} 5 |
6 | {% endif %} 7 | {% if results %} 8 |
9 | 10 | 11 | 12 | {% for header in result_headers %}{% endfor %} 25 | 26 | 27 | 28 | {% for result in results %} 29 | {% for item in result %}{{ item }}{% endfor %} 30 | {% endfor %} 31 | 32 |
13 | {% if header.sortable %} 14 | {% if header.sort_priority > 0 %} 15 |
16 | 17 | {% if num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} 18 | 19 |
20 | {% endif %} 21 | {% endif %} 22 |
{% if header.sortable %}{{ header.text|capfirst }}{% else %}{{ header.text|capfirst }}{% endif %}
23 |
24 |
33 |
34 | {% endif %} 35 | -------------------------------------------------------------------------------- /categories/tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info >= (3, 0): 4 | from django_test_migrations.contrib.unittest_case import MigratorTestCase 5 | 6 | class TestMigrations(MigratorTestCase): 7 | migrate_from = ("categories", "0004_auto_20200517_1832") 8 | migrate_to = ("categories", "0005_unique_category_slug") 9 | 10 | def prepare(self): 11 | Category = self.old_state.apps.get_model("categories", "Category") 12 | Category.tree.create(slug="foo", lft=0, rght=0, tree_id=0, level=0) 13 | Category.tree.create(slug="foo", lft=0, rght=0, tree_id=0, level=0) 14 | Category.tree.create(slug="foo", lft=0, rght=0, tree_id=0, level=0) 15 | for i in range(1, 12): 16 | Category.tree.create(slug="bar", lft=0, rght=0, tree_id=0, level=0) 17 | Category.tree.create(slug="baz", lft=0, rght=0, tree_id=0, level=0) 18 | assert Category.tree.count() == 15 19 | 20 | def test_unique_slug_migration(self): 21 | Category = self.new_state.apps.get_model("categories", "Category") 22 | 23 | self.assertListEqual( 24 | list(Category.tree.values_list("slug", flat=True)), 25 | [ 26 | "foo", 27 | "foo-1", 28 | "foo-2", 29 | "bar", 30 | "bar-01", 31 | "bar-02", 32 | "bar-03", 33 | "bar-04", 34 | "bar-05", 35 | "bar-06", 36 | "bar-07", 37 | "bar-08", 38 | "bar-09", 39 | "bar-10", 40 | "baz", 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /doc_src/user_guide/adding_the_fields.rst: -------------------------------------------------------------------------------- 1 | .. _adding_the_fields: 2 | 3 | ================================= 4 | Adding the fields to the database 5 | ================================= 6 | 7 | While Django will create the appropriate columns and tables if you configure Django Categories first, many times that is not possible. You could also reset the table, but you would loose all data in it. There is another way. 8 | 9 | Enter South 10 | *********** 11 | 12 | `South `_ is a Django application for managing database schema changes. South's default behavior is for managing permanent changes to a model's structure. In the case of dynamically adding a field or fields to a model, this doesn't work because you are not making the change permanent. And probably don't want to. 13 | 14 | Django Categories has a management command to create any missing fields. It requires South because it uses the South's API for making changes to databases. The management command is simple: ``python manage.py add_category_fields [app]``\ . If you do not include an app name, it will attempt to do all applications configured. 15 | 16 | Running this command several times will not hurt any data or cause any errors. 17 | 18 | Reconfiguring Fields 19 | ******************** 20 | 21 | 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. 22 | 23 | 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`` 24 | -------------------------------------------------------------------------------- /categories/genericcollection.py: -------------------------------------------------------------------------------- 1 | """Special helpers for generic collections.""" 2 | 3 | import json 4 | 5 | from django.contrib import admin 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.urls import NoReverseMatch, reverse 8 | 9 | 10 | class GenericCollectionInlineModelAdmin(admin.options.InlineModelAdmin): 11 | """Inline admin for generic model collections.""" 12 | 13 | ct_field = "content_type" 14 | ct_fk_field = "object_id" 15 | 16 | def get_content_types(self): 17 | """Get the content types supported by this collection.""" 18 | ctypes = ContentType.objects.all().order_by("id").values_list("id", "app_label", "model") 19 | elements = {} 20 | for x, y, z in ctypes: 21 | try: 22 | elements[x] = reverse("admin:%s_%s_changelist" % (y, z)) 23 | except NoReverseMatch: 24 | continue 25 | return json.dumps(elements) 26 | 27 | def get_formset(self, request, obj=None, **kwargs): 28 | """Get the formset for the generic collection.""" 29 | result = super(GenericCollectionInlineModelAdmin, self).get_formset(request, obj, **kwargs) 30 | result.content_types = self.get_content_types() 31 | result.ct_fk_field = self.ct_fk_field 32 | return result 33 | 34 | class Media: 35 | js = ("contentrelations/js/genericlookup.js",) 36 | 37 | 38 | class GenericCollectionTabularInline(GenericCollectionInlineModelAdmin): 39 | """Tabular model admin for a generic collection.""" 40 | 41 | template = "admin/edit_inline/gen_coll_tabular.html" 42 | 43 | 44 | class GenericCollectionStackedInline(GenericCollectionInlineModelAdmin): 45 | """Stacked model admin for a generic collection.""" 46 | 47 | template = "admin/edit_inline/gen_coll_stacked.html" 48 | -------------------------------------------------------------------------------- /categories/editor/templates/admin/editor/tree_editor.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% block extrahead %} 3 | {{block.super}} 4 | 33 | {% endblock %} 34 | {% block result_list %} 35 | {% load admin_list i18n admin_tree_list_tags %} 36 | {% if action_form and actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %} 37 | {% result_tree_list cl %} 38 | {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /doc_src/user_guide/admin_settings.rst: -------------------------------------------------------------------------------- 1 | .. _admin_settings: 2 | 3 | ============================== 4 | Adding the fields to the Admin 5 | ============================== 6 | 7 | By default, Django Categories adds the fields you configure to the model's Admin class. If your ModelAdmin class does not use the ``fieldsets`` attribute, the configured category fields are simply appended to the bottom the fields. If your ModelAdmin uses the ``fieldsets`` attribute, a new fieldset named ``Categories``, containing all the configured fields is appended to the fieldsets. You can override or alter this behavior with the :ref:`ADMIN_FIELDSETS` setting. 8 | 9 | ADMIN_FIELDSETS allows you to: 10 | 11 | * Prevent Django Categories from adding the fields or fieldsets to a model's ModelAdmin class. 12 | * Change the name of the fieldset (from the default: "Categories") 13 | * Change the placement of the fieldset (from the default: bottom) 14 | 15 | Preventing fields in the admin class 16 | ==================================== 17 | 18 | If you don't want Django Categories to add any fields to the admin class, simply use the following format:: 19 | 20 | CATEGORIES_SETTINGS = { 21 | 'ADMIN_FIELDSETS': [ 22 | 'app.model': None, 23 | ] 24 | } 25 | 26 | Changing the name of the field set 27 | ================================== 28 | 29 | To rename the field set, use the following format:: 30 | 31 | CATEGORIES_SETTINGS = { 32 | 'ADMIN_FIELDSETS': [ 33 | 'app.model': 'Taxonomy', 34 | ] 35 | } 36 | 37 | Putting the field set exactly where you want it 38 | =============================================== 39 | 40 | For complete control over the field set, use the following format:: 41 | 42 | CATEGORIES_SETTINGS = { 43 | 'ADMIN_FIELDSETS': [ 44 | 'app.model': { 45 | 'name': 'Categories', 46 | 'index': 0 47 | }, 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 40.9.0", 4 | "wheel", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.coverage.run] 9 | branch = true 10 | omit = ["**/test_*.py"] 11 | 12 | [tool.coverage.report] 13 | omit = [ 14 | "*site-packages*", 15 | "*tests*", 16 | "*.tox*", 17 | ] 18 | show_missing = true 19 | exclude_lines = [ 20 | "raise NotImplementedError", 21 | "pragma: no-coverage", 22 | ] 23 | 24 | [tool.interrogate] 25 | ignore-init-method = true 26 | ignore-init-module = false 27 | ignore-magic = true 28 | ignore-semiprivate = false 29 | ignore-private = false 30 | ignore-property-decorators = false 31 | ignore-module = false 32 | ignore-nested-functions = true 33 | ignore-nested-classes = true 34 | ignore-setters = false 35 | fail-under = 60 36 | exclude = ["setup.py", "docs", "build", "test"] 37 | ignore-regex = ["^get$", "^mock_.*", ".*BaseClass.*"] 38 | verbose = 0 39 | quiet = false 40 | whitelist-regex = [] 41 | color = true 42 | 43 | [tool.isort] 44 | py_version = "38" 45 | force_grid_wrap = 0 46 | use_parentheses = true 47 | line_length = 88 48 | known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] 49 | sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 50 | include_trailing_comma = true 51 | profile = "black" 52 | multi_line_output = 3 53 | indent = 4 54 | color_output = true 55 | 56 | [tool.pydocstyle] 57 | convention = "google" 58 | add-ignore = ["D104", "D105", "D106", "D107", "D200", "D212"] 59 | match = "(?!test_).*\\.py" 60 | 61 | [tool.black] 62 | line-length = 119 63 | target-version = ['py38', 'py39'] 64 | include = '\.pyi?$' 65 | exclude = ''' 66 | /( 67 | \.eggs 68 | | \.git 69 | | \.hg 70 | | \.mypy_cache 71 | | \.tox 72 | | \.venv 73 | | _build 74 | | buck-out 75 | | build 76 | | dist 77 | # The following are specific to Black, you probably don't want those. 78 | | blib2to3 79 | | tests/data 80 | | profiling 81 | )/ 82 | ''' 83 | -------------------------------------------------------------------------------- /example/static/editor/jquery.treeTable.css: -------------------------------------------------------------------------------- 1 | /* jQuery TreeTable Core 2.0 stylesheet 2 | * 3 | * This file contains styles that are used to display the tree table. Each tree 4 | * table is assigned the +treeTable+ class. 5 | * ========================================================================= */ 6 | 7 | /* jquery.treeTable.collapsible 8 | * ------------------------------------------------------------------------- */ 9 | .treeTable tr td .expander { 10 | background-position: left center; 11 | background-repeat: no-repeat; 12 | cursor: pointer; 13 | padding: 0; 14 | zoom: 1; /* IE7 Hack */ 15 | } 16 | 17 | .treeTable tr.collapsed td .expander { 18 | background-image: url(toggle-expand-dark.png); 19 | } 20 | 21 | .treeTable tr.expanded td .expander { 22 | background-image: url(toggle-collapse-dark.png); 23 | } 24 | 25 | #changelist table.treeTable td.disclosure { 26 | font-size: 12px; 27 | text-align: left; 28 | font-weight: bold; 29 | padding-left: 19px; 30 | } 31 | #changelist table.treeTable td.disclosure input[type="checkbox"] { 32 | margin-right: 10px; 33 | } 34 | 35 | /* jquery.treeTable.sortable 36 | * ------------------------------------------------------------------------- */ 37 | .treeTable tr.selected, .treeTable tr.accept { 38 | background-color: #3875d7; 39 | color: #fff; 40 | } 41 | 42 | .treeTable .ui-draggable-dragging { 43 | border-width: 0; 44 | } 45 | 46 | .treeTable tr.selected, .treeTable tr.ghost_row td { 47 | padding : 5px; 48 | background-color : #ccc; 49 | border : dotted 1px #eee; 50 | font-weight : bold; 51 | text-align : center; 52 | } 53 | 54 | .treeTable tr.collapsed.selected td .expander, .treeTable tr.collapsed.accept td .expander { 55 | background-image: url(toggle-expand-light.png); 56 | } 57 | 58 | .treeTable tr.expanded.selected td .expander, .treeTable tr.expanded.accept td .expander { 59 | background-image: url(toggle-collapse-light.png); 60 | } 61 | 62 | .treeTable .ui-draggable-dragging { 63 | color: #000; 64 | z-index: 1; 65 | } 66 | -------------------------------------------------------------------------------- /categories/editor/static/editor/jquery.treeTable.css: -------------------------------------------------------------------------------- 1 | /* jQuery TreeTable Core 2.0 stylesheet 2 | * 3 | * This file contains styles that are used to display the tree table. Each tree 4 | * table is assigned the +treeTable+ class. 5 | * ========================================================================= */ 6 | 7 | /* jquery.treeTable.collapsible 8 | * ------------------------------------------------------------------------- */ 9 | .treeTable tr td .expander { 10 | background-position: left center; 11 | background-repeat: no-repeat; 12 | cursor: pointer; 13 | padding: 0; 14 | zoom: 1; /* IE7 Hack */ 15 | } 16 | 17 | 18 | .treeTable td.action-checkbox, .treeTable th.action-checkbox-column { 19 | padding-left: 20px; 20 | } 21 | .treeTable tr.collapsed td .expander { 22 | background-image: url(toggle-expand-dark.png); 23 | } 24 | 25 | .treeTable tr.expanded td .expander { 26 | background-image: url(toggle-collapse-dark.png); 27 | } 28 | 29 | #changelist table.treeTable td.disclosure { 30 | font-size: 12px; 31 | text-align: left; 32 | font-weight: bold; 33 | padding-left: 19px; 34 | } 35 | #changelist table.treeTable td.disclosure input[type="checkbox"] { 36 | margin-right: 10px; 37 | } 38 | 39 | /* jquery.treeTable.sortable 40 | * ------------------------------------------------------------------------- */ 41 | .treeTable tr.selected, .treeTable tr.accept { 42 | background-color: #3875d7; 43 | color: #fff; 44 | } 45 | 46 | .treeTable .ui-draggable-dragging { 47 | border-width: 0; 48 | } 49 | 50 | .treeTable tr.selected, .treeTable tr.ghost_row td { 51 | padding : 5px; 52 | background-color : #ccc; 53 | border : dotted 1px #eee; 54 | font-weight : bold; 55 | text-align : center; 56 | } 57 | 58 | .treeTable tr.collapsed.selected td .expander, .treeTable tr.collapsed.accept td .expander { 59 | background-image: url(toggle-expand-light.png); 60 | } 61 | 62 | .treeTable tr.expanded.selected td .expander, .treeTable tr.expanded.accept td .expander { 63 | background-image: url(toggle-collapse-light.png); 64 | } 65 | 66 | .treeTable .ui-draggable-dragging { 67 | color: #000; 68 | z-index: 1; 69 | } 70 | -------------------------------------------------------------------------------- /categories/editor/templates/admin/editor/grappelli_tree_editor.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_list.html" %} 2 | {% load admin_list i18n admin_tree_list_tags %} 3 | {% block extrahead %} 4 | {{ block.super }} 5 | 34 | 44 | {% endblock %} 45 | {% block result_list %} 46 | {% result_tree_list cl %} 47 | {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /categories/tests/test_registration.py: -------------------------------------------------------------------------------- 1 | # Test adding 1 fk string 2 | # Test adding 1 fk dict 3 | # test adding many-to-many 4 | # test adding 1 fk, 1 m2m 5 | import django 6 | from django.test import TestCase 7 | 8 | from categories.registration import _process_registry, registry 9 | 10 | 11 | class CategoryRegistrationTest(TestCase): 12 | """ 13 | Test various aspects of adding fields to a model. 14 | """ 15 | 16 | def test_foreignkey_string(self): 17 | FK_REGISTRY = {"flatpages.flatpage": "category"} 18 | _process_registry(FK_REGISTRY, registry.register_fk) 19 | from django.contrib.flatpages.models import FlatPage 20 | 21 | self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()]) 22 | 23 | def test_foreignkey_dict(self): 24 | FK_REGISTRY = {"flatpages.flatpage": {"name": "category"}} 25 | _process_registry(FK_REGISTRY, registry.register_fk) 26 | from django.contrib.flatpages.models import FlatPage 27 | 28 | self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()]) 29 | 30 | def test_foreignkey_list(self): 31 | FK_REGISTRY = {"flatpages.flatpage": ({"name": "category", "related_name": "cats"},)} 32 | _process_registry(FK_REGISTRY, registry.register_fk) 33 | from django.contrib.flatpages.models import FlatPage 34 | 35 | self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()]) 36 | 37 | if django.VERSION[1] >= 7: 38 | 39 | def test_new_foreignkey_string(self): 40 | registry.register_model("flatpages", "flatpage", "ForeignKey", "category") 41 | from django.contrib.flatpages.models import FlatPage 42 | 43 | self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()]) 44 | 45 | 46 | class Categorym2mTest(TestCase): 47 | def test_m2m_string(self): 48 | M2M_REGISTRY = {"flatpages.flatpage": "categories"} 49 | _process_registry(M2M_REGISTRY, registry.register_m2m) 50 | from django.contrib.flatpages.models import FlatPage 51 | 52 | self.assertTrue("category" in [f.name for f in FlatPage()._meta.get_fields()]) 53 | -------------------------------------------------------------------------------- /categories/settings.py: -------------------------------------------------------------------------------- 1 | """Manages settings for the categories application.""" 2 | 3 | import collections 4 | 5 | from django.conf import settings 6 | from django.db.models import Q 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | DEFAULT_SETTINGS = { 10 | "ALLOW_SLUG_CHANGE": False, 11 | "M2M_REGISTRY": {}, 12 | "FK_REGISTRY": {}, 13 | "THUMBNAIL_UPLOAD_PATH": "uploads/categories/thumbnails", 14 | "THUMBNAIL_STORAGE_ALIAS": "default", 15 | "JAVASCRIPT_URL": getattr(settings, "STATIC_URL", settings.MEDIA_URL) + "js/", 16 | "SLUG_TRANSLITERATOR": "", 17 | "REGISTER_ADMIN": True, 18 | "RELATION_MODELS": [], 19 | } 20 | 21 | if hasattr(settings, "DEFAULT_FILE_STORAGE"): 22 | DEFAULT_SETTINGS["THUMBNAIL_STORAGE"] = settings.DEFAULT_FILE_STORAGE 23 | 24 | DEFAULT_SETTINGS.update(getattr(settings, "CATEGORIES_SETTINGS", {})) 25 | 26 | if DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"]: 27 | if isinstance(DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"], collections.Callable): 28 | pass 29 | elif isinstance(DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"], str): 30 | from django.utils.importlib import import_module 31 | 32 | bits = DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"].split(".") 33 | module = import_module(".".join(bits[:-1])) 34 | DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"] = getattr(module, bits[-1]) 35 | else: 36 | from django.core.exceptions import ImproperlyConfigured 37 | 38 | raise ImproperlyConfigured( 39 | _("%(transliterator) must be a callable or a string.") % {"transliterator": "SLUG_TRANSLITERATOR"} 40 | ) 41 | else: 42 | DEFAULT_SETTINGS["SLUG_TRANSLITERATOR"] = lambda x: x 43 | 44 | 45 | # Add all the keys/values to the module's namespace 46 | globals().update(DEFAULT_SETTINGS) 47 | 48 | RELATIONS = [Q(app_label=al, model=m) for al, m in [x.split(".") for x in DEFAULT_SETTINGS["RELATION_MODELS"]]] 49 | 50 | # The field registry keeps track of the individual fields created. 51 | # {'app.model.field': Field(**extra_params)} 52 | # Useful for doing a schema migration 53 | FIELD_REGISTRY = {} 54 | 55 | # The model registry keeps track of which models have one or more fields 56 | # registered. 57 | # {'app': [model1, model2]} 58 | # Useful for admin alteration 59 | MODEL_REGISTRY = {} 60 | -------------------------------------------------------------------------------- /doc_src/conf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sphinx configuration. 3 | """ 4 | 5 | import os 6 | import sys 7 | from datetime import date 8 | from pathlib import Path 9 | 10 | project_root = Path("..").resolve() 11 | sys.path.insert(0, str(project_root / "example")) 12 | sys.path.insert(0, str(project_root)) 13 | os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings" 14 | 15 | # Setup Django 16 | import django # NOQA 17 | 18 | django.setup() 19 | 20 | import categories # noqa 21 | import categories.urls # noqa 22 | 23 | project = "Django Categories" 24 | copyright = f"2010-{date.today():%Y}, Corey Oordt" 25 | 26 | version = categories.__version__ 27 | release = categories.__version__ 28 | 29 | # -- General configuration ----------------------------------------------------- 30 | 31 | extensions = [ 32 | "myst_parser", 33 | "sphinx.ext.autodoc", 34 | "sphinx.ext.viewcode", 35 | "sphinx.ext.autosummary", 36 | "sphinx.ext.intersphinx", 37 | "sphinx.ext.autosectionlabel", 38 | "sphinx.ext.napoleon", 39 | "sphinx_autodoc_typehints", 40 | "sphinx.ext.coverage", 41 | "sphinx.ext.githubpages", 42 | "sphinxcontrib_django2", 43 | ] 44 | autosectionlabel_prefix_document = True 45 | autosectionlabel_maxdepth = 2 46 | autosummary_generate = True 47 | napoleon_attr_annotations = True 48 | napoleon_include_special_with_doc = False 49 | napoleon_include_private_with_doc = True 50 | napoleon_include_init_with_doc = True 51 | myst_enable_extensions = [ 52 | "amsmath", 53 | "colon_fence", 54 | "deflist", 55 | "dollarmath", 56 | "linkify", 57 | "replacements", 58 | "smartquotes", 59 | "substitution", 60 | "tasklist", 61 | ] 62 | intersphinx_mapping = { 63 | "python": ("https://docs.python.org/3", None), 64 | "django": ( 65 | "https://docs.djangoproject.com/en/stable", 66 | "https://docs.djangoproject.com/en/stable/_objects", 67 | ), 68 | } 69 | 70 | templates_path = ["_templates"] 71 | source_suffix = [".rst", ".md"] 72 | master_doc = "index" 73 | 74 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 75 | pygments_style = "sphinx" 76 | todo_include_todos = False 77 | 78 | 79 | # -- Options for HTML output --------------------------------------------------- 80 | 81 | html_theme = "pydata_sphinx_theme" 82 | html_static_path = ["_static"] 83 | html_css_files = [ 84 | "css/custom.css", 85 | ] 86 | -------------------------------------------------------------------------------- /doc_src/user_guide/usage.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Using categories in templates 3 | ============================= 4 | 5 | 6 | Getting all items within a category 7 | =================================== 8 | 9 | The :py:class:`Category` model automatically gets `reverse relationships `_ with all other models related to it. 10 | 11 | This allows you access to the related objects from the template without altering any views. For example, if you only had ``Entry`` models related to :py:class:`Category`, your ``categories/category_detail.html`` template could look like 12 | 13 | .. literalinclude:: usage_example_template.html 14 | :language: django 15 | :linenos: 16 | 17 | 18 | If you have ``related_name`` parameters to the configuration (see :ref:`registering_models`), then you would use ``category.related_name.all`` instead of ``category.relatedmodel_set.all``\ . 19 | 20 | 21 | Template Tags 22 | ============= 23 | 24 | To use the template tags:: 25 | 26 | {% import category_tags %} 27 | 28 | 29 | ``tree_info`` 30 | ------------- 31 | 32 | Given a list of categories, iterates over the list, generating 33 | two-tuples of the current tree item and a ``dict`` containing 34 | information about the tree structure around the item, with the following 35 | keys: 36 | 37 | ``'new_level'`` 38 | ``True`` if the current item is the start of a new level in 39 | the tree, ``False`` otherwise. 40 | 41 | ``'closed_levels'`` 42 | A list of levels which end after the current item. This will 43 | be an empty list if the next item's level is the same as or 44 | greater than the level of the current item. 45 | 46 | An optional argument can be provided to specify extra details about the 47 | structure which should appear in the ``dict``. This should be a 48 | comma-separated list of feature names. The valid feature names are: 49 | 50 | ancestors 51 | Adds a list of unicode representations of the ancestors of the 52 | current node, in descending order (root node first, immediate 53 | parent last), under the key ``'ancestors'``. 54 | 55 | For example: given the sample tree below, the contents of the list 56 | which would be available under the ``'ancestors'`` key are given 57 | on the right:: 58 | 59 | Books -> [] 60 | Sci-fi -> [u'Books'] 61 | Dystopian Futures -> [u'Books', u'Sci-fi'] 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Categories 2 | 3 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 4 | [![codecov](https://codecov.io/gh/jazzband/django-categories/branch/master/graph/badge.svg?token=rW8mpdZqWQ)](https://codecov.io/gh/jazzband/django-categories) 5 | [![PyPI](https://img.shields.io/pypi/v/django-categories.svg)](https://pypi.org/project/django-categories/#history) 6 | 7 | 8 | Django Categories grew out of our need to provide a basic hierarchical taxonomy management system that multiple applications could use independently or in concert. 9 | 10 | 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. 11 | 12 | ## Features of the project 13 | 14 | **Multiple trees, or a single tree.** 15 | 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. 16 | 17 | **Easy handling of hierarchical data.** 18 | We use [Django MPTT](http://pypi.python.org/pypi/django-mptt) to manage the data efficiently and provide the extra access functions. 19 | 20 | **Easy importation of data.** 21 | Import a tree or trees of space- or tab-indented data with a Django management command. 22 | 23 | **Metadata for better SEO on web pages** 24 | Include all the metadata you want for easy inclusion on web pages. 25 | 26 | **Link uncategorized objects to a category** 27 | Attach any number of objects to a category, even if the objects themselves aren't categorized. 28 | 29 | **Hierarchical Admin** 30 | Shows the data in typical tree form with disclosure triangles 31 | 32 | **Template Helpers** 33 | Easy ways for displaying the tree data in templates: 34 | 35 | - **Show one level of a tree.** All root categories or just children of a specified category 36 | 37 | - **Show multiple levels.** Ancestors of category, category and all children of category or a category and its children 38 | 39 | ## Categories API 40 | 41 | Support for categories API can be added by third party application [django-categories-api](https://github.com/PetrDlouhy/django-categories-api). 42 | 43 | **Optional Thumbnail field.** 44 | Have a thumbnail for each category! 45 | 46 | **"Categorize" models in settings.** 47 | Now you don't have to modify the model to add a `Category` relationship. Use the new settings to "wire" categories to different models. 48 | -------------------------------------------------------------------------------- /example/simpletext/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.13 on 2017-10-12 20:27 2 | from __future__ import unicode_literals 3 | 4 | import django.db.models.deletion 5 | import mptt.fields 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="SimpleCategory", 17 | fields=[ 18 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ("name", models.CharField(max_length=100, verbose_name="name")), 20 | ("slug", models.SlugField(verbose_name="slug")), 21 | ("active", models.BooleanField(default=True, verbose_name="active")), 22 | ("lft", models.PositiveIntegerField(db_index=True, editable=False)), 23 | ("rght", models.PositiveIntegerField(db_index=True, editable=False)), 24 | ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)), 25 | ("level", models.PositiveIntegerField(db_index=True, editable=False)), 26 | ( 27 | "parent", 28 | mptt.fields.TreeForeignKey( 29 | blank=True, 30 | null=True, 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="children", 33 | to="simpletext.SimpleCategory", 34 | verbose_name="parent", 35 | ), 36 | ), 37 | ], 38 | options={ 39 | "verbose_name_plural": "simple categories", 40 | }, 41 | ), 42 | migrations.CreateModel( 43 | name="SimpleText", 44 | fields=[ 45 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 46 | ("name", models.CharField(max_length=255)), 47 | ("description", models.TextField(blank=True)), 48 | ("created", models.DateTimeField(auto_now_add=True)), 49 | ("updated", models.DateTimeField(auto_now=True)), 50 | ], 51 | options={ 52 | "ordering": ("-created",), 53 | "get_latest_by": "updated", 54 | "verbose_name_plural": "Simple Text", 55 | }, 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /categories/tests/test_category_import.py: -------------------------------------------------------------------------------- 1 | # test spaces in hierarchy 2 | # test tabs in hierarchy 3 | # test mixed 4 | import os 5 | 6 | from django.conf import settings 7 | from django.core.management.base import CommandError 8 | from django.test import TestCase, override_settings 9 | 10 | from categories.management.commands.import_categories import Command 11 | from categories.models import Category 12 | 13 | 14 | @override_settings(INSTALLED_APPS=(app for app in settings.INSTALLED_APPS if app != "django.contrib.flatpages")) 15 | class CategoryImportTest(TestCase): 16 | def setUp(self): 17 | pass 18 | 19 | def _import_file(self, filename): 20 | root_cats = ["Category 1", "Category 2", "Category 3"] 21 | testfile = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures", filename)) 22 | cmd = Command() 23 | cmd.handle(testfile) 24 | roots = Category.tree.root_nodes() 25 | 26 | self.assertEqual(len(roots), 3) 27 | for item in roots: 28 | assert item.name in root_cats 29 | 30 | cat2 = Category.objects.get(name="Category 2") 31 | cat21 = cat2.children.all()[0] 32 | self.assertEqual(cat21.name, "Category 2-1") 33 | cat211 = cat21.children.all()[0] 34 | self.assertEqual(cat211.name, "Category 2-1-1") 35 | 36 | def testImportSpaceDelimited(self): 37 | Category.objects.all().delete() 38 | self._import_file("test_category_spaces.txt") 39 | 40 | items = Category.objects.all() 41 | 42 | self.assertEqual(items[0].name, "Category 1") 43 | self.assertEqual(items[1].name, "Category 1-1") 44 | self.assertEqual(items[2].name, "Category 1-2") 45 | 46 | def testImportTabDelimited(self): 47 | Category.objects.all().delete() 48 | self._import_file("test_category_tabs.txt") 49 | 50 | items = Category.objects.all() 51 | 52 | self.assertEqual(items[0].name, "Category 1") 53 | self.assertEqual(items[1].name, "Category 1-1") 54 | self.assertEqual(items[2].name, "Category 1-2") 55 | 56 | def testMixingTabsSpaces(self): 57 | """ 58 | Should raise an exception. 59 | """ 60 | string1 = ["cat1", " cat1-1", "\tcat1-2-FAIL!", ""] 61 | string2 = ["cat1a", "\tcat1-1a", " cat1-2-FAIL!", ""] 62 | cmd = Command() 63 | 64 | # raise Exception 65 | self.assertRaises(CommandError, cmd.parse_lines, string1) 66 | self.assertRaises(CommandError, cmd.parse_lines, string2) 67 | -------------------------------------------------------------------------------- /doc_src/user_guide/custom_categories.rst: -------------------------------------------------------------------------------- 1 | .. _creating_custom_categories: 2 | 3 | ========================== 4 | Creating Custom Categories 5 | ========================== 6 | 7 | Django Categories isn't just for using a single category model. It allows you to create your own custom category-like models with as little or much customization as you need. 8 | 9 | Name only 10 | ========= 11 | 12 | For many cases, you want a simple user-managed lookup table. You can do this with just a little bit of code. The resulting model will include name, slug and active fields and a hierarchical admin. 13 | 14 | #. Create a model that subclasses :py:class:`CategoryBase` 15 | 16 | .. literalinclude:: code_examples/custom_categories1.py 17 | :linenos: 18 | 19 | #. Create a subclass of CategoryBaseAdmin. 20 | 21 | .. literalinclude:: code_examples/custom_categories2.py 22 | :linenos: 23 | 24 | #. Register your model and custom model admin class. 25 | 26 | Name and other data 27 | =================== 28 | 29 | Sometimes you need more functionality, such as extra metadata and custom functions. The :py:class:`Category` model in this package does this. 30 | 31 | #. Create a model that subclasses :py:class:`CategoryBase` as above. 32 | 33 | #. Add new fields to the model. The :py:class:`Category` model adds these extra fields. 34 | 35 | .. literalinclude:: code_examples/custom_categories3.py 36 | :linenos: 37 | 38 | #. Add new methods to the model. For example, the :py:class:`Category` model adds several new methods, including overriding the :py:meth:`save` method. 39 | 40 | .. literalinclude:: code_examples/custom_categories4.py 41 | :linenos: 42 | 43 | #. Alter :py:class:`Meta` or :py:class:`MPTTMeta` class. Either of these inner classes can be overridden, however your :py:class:`Meta` class should inherit :py:class:`CategoryBase.Meta`. Options for :py:class:`Meta` are in the `Django-MPTT docs `_. 44 | 45 | .. literalinclude:: code_examples/custom_categories5.py 46 | :linenos: 47 | 48 | #. For the admin, you must create a form that subclasses :py:class:`CategoryBaseAdminForm` and at least sets the ``Meta.model`` attribute. You can also alter the form fields and cleaning methods, as :py:class:`Category` does. 49 | 50 | .. literalinclude:: code_examples/custom_categories6.py 51 | :linenos: 52 | 53 | #. Next you must subclass :py:class:`CategoryBaseAdmin` and assign the ``form`` attribute the form class created above. You can alter any other attributes as necessary. 54 | 55 | .. literalinclude:: code_examples/custom_categories7.py 56 | :linenos: 57 | -------------------------------------------------------------------------------- /doc_src/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | You can use Django Categories in two ways: 6 | 7 | 1. As storage for one tree of categories, using the :py:class:`Category` model:: 8 | 9 | Top Category 1 10 | Subcategory 1-1 11 | Subcategory 1-2 12 | subcategory 1-2-1 13 | Top Category 2 14 | Subcategory 2-1 15 | 16 | 2. As the basis for several custom categories (see :ref:`creating_custom_categories`), e.g. a **MusicGenre** model 17 | 18 | :: 19 | 20 | MusicGenre 1 21 | MusicSubGenre 1-1 22 | MusicSubGenre 1-2 23 | MusicSubGenre 1-2-1 24 | MusicGenre 2 25 | MusicSubGenre 2-1 26 | 27 | and a **Subject** model 28 | 29 | :: 30 | 31 | Subject 1 32 | Discipline 1-1 33 | Discipline 1-2 34 | SubDiscipline 1-2-1 35 | Subject 2 36 | Discipline 2-1 37 | 38 | 39 | 40 | Connecting your model with Django-Categories 41 | ============================================ 42 | 43 | There are two ways to add Category fields to an application. If you are in control of the code (it's your application) or you are willing to take control of the code (fork someone else's app) you can make a :ref:`hard_coded_connection`\ . 44 | 45 | For 3rd-party apps or even your own apps that you don't wish to add Django-Categories as a dependency, you can configure a :ref:`lazy_connection`\ . 46 | 47 | .. _hard_coded_connection: 48 | 49 | Hard Coded Connection 50 | --------------------- 51 | 52 | Hard coded connections are done in the exact same way you handle any other foreign key or many-to-many relations to a model. 53 | 54 | .. code-block:: python 55 | 56 | from django.db import models 57 | 58 | class MyModel(models.Model): 59 | name = models.CharField(max_length=100) 60 | category = models.ForeignKey('categories.Category') 61 | 62 | Don't forget to add the field or fields to your ``ModelAdmin`` class as well. 63 | 64 | 65 | .. _lazy_connection: 66 | 67 | Lazy Connection 68 | --------------- 69 | 70 | Lazy connections are done through configuring Django Categories in the project's ``settings.py`` file. When the project starts up, the configured fields are dynamically added to the configured models and admin. 71 | 72 | If you do this before you have created the database (before you ran ``manage.py syncdb``), the fields will also be in the tables. If you do this after you have already created all the tables, you can run ``manage.py add_category_fields`` to create the fields (this requires Django South to be installed). 73 | 74 | You add a many-to-one or many-to-many relationship with Django Categories using the :ref:`FK_REGISTRY` and :ref:`M2M_REGISTRY` settings respectively. For more information see :ref:`registering_models`\ . 75 | -------------------------------------------------------------------------------- /categories/migration.py: -------------------------------------------------------------------------------- 1 | """Adds and removes category relations on the database.""" 2 | 3 | from django.apps import apps 4 | from django.db import DatabaseError, connection, transaction 5 | from django.db.utils import OperationalError, ProgrammingError 6 | 7 | 8 | def table_exists(table_name): 9 | """ 10 | Check if a table exists in the database. 11 | """ 12 | pass 13 | 14 | 15 | def field_exists(app_name, model_name, field_name): 16 | """ 17 | Does the FK or M2M table exist in the database already? 18 | """ 19 | model = apps.get_model(app_name, model_name) 20 | table_name = model._meta.db_table 21 | cursor = connection.cursor() 22 | field_info = connection.introspection.get_table_description(cursor, table_name) 23 | field_names = [f.name for f in field_info] 24 | 25 | # Return True if the many to many table exists 26 | field = model._meta.get_field(field_name) 27 | if hasattr(field, "m2m_db_table"): 28 | m2m_table_name = field.m2m_db_table() 29 | try: 30 | m2m_field_info = connection.introspection.get_table_description(cursor, m2m_table_name) 31 | except DatabaseError: # Django >= 4.1 throws DatabaseError 32 | m2m_field_info = [] 33 | if m2m_field_info: 34 | return True 35 | 36 | return field_name in field_names 37 | 38 | 39 | def drop_field(app_name, model_name, field_name): 40 | """ 41 | Drop the given field from the app's model. 42 | """ 43 | app_config = apps.get_app_config(app_name) 44 | model = app_config.get_model(model_name) 45 | field = model._meta.get_field(field_name) 46 | with connection.schema_editor() as schema_editor: 47 | schema_editor.remove_field(model, field) 48 | 49 | 50 | def migrate_app(sender, *args, **kwargs): 51 | """ 52 | Migrate all models of this app registered. 53 | """ 54 | from .registration import registry 55 | 56 | if "app_config" not in kwargs: 57 | return 58 | app_config = kwargs["app_config"] 59 | 60 | app_name = app_config.label 61 | 62 | fields = [fld for fld in list(registry._field_registry.keys()) if fld.startswith(app_name)] 63 | 64 | sid = transaction.savepoint() 65 | for fld in fields: 66 | model_name, field_name = fld.split(".")[1:] 67 | if field_exists(app_name, model_name, field_name): 68 | continue 69 | model = app_config.get_model(model_name) 70 | try: 71 | with connection.schema_editor() as schema_editor: 72 | schema_editor.add_field(model, registry._field_registry[fld]) 73 | if sid: 74 | transaction.savepoint_commit(sid) 75 | # Django 4.1 with sqlite3 has for some reason started throwing OperationalError 76 | # instead of ProgrammingError, so we need to catch both. 77 | except (ProgrammingError, OperationalError): 78 | if sid: 79 | transaction.savepoint_rollback(sid) 80 | continue 81 | -------------------------------------------------------------------------------- /categories/management/commands/import_categories.py: -------------------------------------------------------------------------------- 1 | """Import category trees from a file.""" 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | from django.db import transaction 5 | 6 | from categories.models import Category 7 | from categories.settings import SLUG_TRANSLITERATOR 8 | 9 | from ...utils import slugify 10 | 11 | 12 | class Command(BaseCommand): 13 | """Import category trees from a file.""" 14 | 15 | help = ( 16 | "Imports category tree(s) from a file. Sub categories must be indented by the same multiple of spaces or tabs." 17 | ) 18 | args = "file_path [file_path ...]" 19 | 20 | def get_indent(self, string): 21 | """ 22 | Look through the string and count the spaces. 23 | """ 24 | indent_amt = 0 25 | 26 | if string[0] == "\t": 27 | return "\t" 28 | for char in string: 29 | if char == " ": 30 | indent_amt += 1 31 | else: 32 | return " " * indent_amt 33 | 34 | @transaction.atomic 35 | def make_category(self, string, parent=None, order=1): 36 | """ 37 | Make and save a category object from a string. 38 | """ 39 | cat = Category( 40 | name=string.strip(), 41 | slug=slugify(SLUG_TRANSLITERATOR(string.strip()))[:49], 42 | # arent=parent, 43 | order=order, 44 | ) 45 | cat._tree_manager.insert_node(cat, parent, "last-child", True) 46 | cat.save() 47 | if parent: 48 | parent.rght = cat.rght + 1 49 | parent.save() 50 | return cat 51 | 52 | def parse_lines(self, lines): 53 | """ 54 | Do the work of parsing each line. 55 | """ 56 | indent = "" 57 | level = 0 58 | 59 | if lines[0][0] in [" ", "\t"]: 60 | raise CommandError("The first line in the file cannot start with a space or tab.") 61 | 62 | # This keeps track of the current parents at a given level 63 | current_parents = {0: None} 64 | 65 | for line in lines: 66 | if len(line) == 0: 67 | continue 68 | if line[0] in [" ", "\t"]: 69 | if indent == "": 70 | indent = self.get_indent(line) 71 | elif line[0] not in indent: 72 | raise CommandError("You can't mix spaces and tabs for indents") 73 | level = line.count(indent) 74 | current_parents[level] = self.make_category(line, parent=current_parents[level - 1]) 75 | else: 76 | # We are back to a zero level, so reset the whole thing 77 | current_parents = {0: self.make_category(line)} 78 | current_parents[0]._tree_manager.rebuild() 79 | 80 | def handle(self, *file_paths, **options): 81 | """ 82 | Handle the basic import. 83 | """ 84 | import os 85 | 86 | for file_path in file_paths: 87 | if not os.path.isfile(file_path): 88 | print("File %s not found." % file_path) 89 | continue 90 | with open(file_path, "r") as f: 91 | data = f.readlines() 92 | self.parse_lines(data) 93 | -------------------------------------------------------------------------------- /categories/templates/admin/edit_inline/gen_coll_tabular.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 |
3 | 68 |
69 | -------------------------------------------------------------------------------- /categories/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import Client, TestCase 3 | from django.test.utils import override_settings 4 | from django.urls import reverse 5 | from django.utils.encoding import smart_str 6 | 7 | from categories.models import Category 8 | 9 | 10 | class TestCategoryAdmin(TestCase): 11 | def setUp(self): 12 | self.client = Client() 13 | self.superuser = User.objects.create_superuser("testuser", "testuser@example.com", "password") 14 | 15 | def test_adding_parent_and_child(self): 16 | self.client.login(username="testuser", password="password") 17 | url = reverse("admin:categories_category_add") 18 | data = { 19 | "parent": "", 20 | "name": smart_str("Parent Catégory"), 21 | "thumbnail": "", 22 | "filename": "", 23 | "active": "on", 24 | "alternate_title": "", 25 | "alternate_url": "", 26 | "description": "", 27 | "meta_keywords": "", 28 | "meta_extra": "", 29 | "order": 0, 30 | "slug": "parent", 31 | "_save": "_save", 32 | "categoryrelation_set-TOTAL_FORMS": "0", 33 | "categoryrelation_set-INITIAL_FORMS": "0", 34 | "categoryrelation_set-MIN_NUM_FORMS": "1000", 35 | "categoryrelation_set-MAX_NUM_FORMS": "1000", 36 | } 37 | resp = self.client.post(url, data=data) 38 | self.assertEqual(resp.status_code, 302) 39 | self.assertEqual(1, Category.objects.count()) 40 | 41 | # update parent 42 | data.update({"name": smart_str("Parent Catégory (Changed)")}) 43 | resp = self.client.post(reverse("admin:categories_category_change", args=(1,)), data=data) 44 | self.assertEqual(resp.status_code, 302) 45 | self.assertEqual(1, Category.objects.count()) 46 | 47 | # add a child 48 | data.update( 49 | { 50 | "parent": "1", 51 | "name": smart_str("Child Catégory"), 52 | "slug": smart_str("child-category"), 53 | } 54 | ) 55 | resp = self.client.post(url, data=data) 56 | self.assertEqual(resp.status_code, 302) 57 | self.assertEqual(2, Category.objects.count()) 58 | 59 | # update child 60 | data.update({"name": "Child (Changed)"}) 61 | resp = self.client.post(reverse("admin:categories_category_change", args=(2,)), data=data) 62 | self.assertEqual(resp.status_code, 302) 63 | self.assertEqual(2, Category.objects.count()) 64 | 65 | # test the admin list view 66 | url = reverse("admin:categories_category_changelist") 67 | resp = self.client.get(url) 68 | self.assertEqual(resp.status_code, 200) 69 | 70 | @override_settings(RELATION_MODELS=True) 71 | def test_addview_get(self): 72 | self.client.force_login(self.superuser) 73 | url = reverse("admin:categories_category_add") 74 | resp = self.client.get(url) 75 | self.assertEqual(resp.status_code, 200) 76 | self.assertContains(resp, '') 77 | self.assertContains( 78 | resp, 79 | '', 81 | html=True, 82 | ) 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | ### Python ### 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | cover/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | dev.db 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | .pybuilder/ 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | */.ipynb_checkpoints/* 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | # For a library or package, you might want to ignore these files since the code is 96 | # intended to run in multiple environments; otherwise, check them in: 97 | # .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | 125 | # Spyder project settings 126 | .spyderproject 127 | .spyproject 128 | 129 | # Rope project settings 130 | .ropeproject 131 | 132 | # mkdocs documentation 133 | /site 134 | 135 | # mypy 136 | .mypy_cache/ 137 | .dmypy.json 138 | dmypy.json 139 | 140 | # Pyre type checker 141 | .pyre/ 142 | 143 | # pytype static type analyzer 144 | .pytype/ 145 | 146 | # Cython debug symbols 147 | cython_debug/ 148 | 149 | # Mr Developer 150 | .mr.developer.cfg 151 | .project 152 | .pydevproject 153 | 154 | # Pycharm/Intellij 155 | .idea 156 | 157 | # VSCode 158 | .vscode 159 | 160 | # Docker 161 | docker-compose* 162 | **/docker-compose* 163 | 164 | # Complexity 165 | output/*.html 166 | output/*/index.html 167 | 168 | # Testing artifacts 169 | junit-*.xml 170 | flake8-errors.txt 171 | example/media/ 172 | 173 | # Documentation building 174 | _build 175 | doc_src/api/categories*.rst 176 | docs 177 | 178 | RELEASE.txt 179 | site-packages 180 | reports 181 | test-reports/* 182 | -------------------------------------------------------------------------------- /doc_src/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -a 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | DESTDIR = _build 10 | 11 | # Internal variables. 12 | PAPEROPT_a4 = -D latex_paper_size=a4 13 | PAPEROPT_letter = -D latex_paper_size=letter 14 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 15 | 16 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 17 | 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 27 | @echo " changes to make an overview of all changed/added/deprecated items" 28 | @echo " linkcheck to check all external links for integrity" 29 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 30 | 31 | clean: 32 | -rm -rf $(BUILDDIR)/* 33 | 34 | html: 35 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(DESTDIR)/html 36 | @echo 37 | @echo "Build finished. The HTML pages are in $(DESTDIR)/html." 38 | 39 | dirhtml: 40 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 43 | 44 | pickle: 45 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 46 | @echo 47 | @echo "Build finished; now you can process the pickle files." 48 | 49 | json: 50 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 51 | @echo 52 | @echo "Build finished; now you can process the JSON files." 53 | 54 | htmlhelp: 55 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 56 | @echo 57 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 58 | ".hhp project file in $(BUILDDIR)/htmlhelp." 59 | 60 | qthelp: 61 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 62 | @echo 63 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 64 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 65 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoCategories.qhcp" 66 | @echo "To view the help file:" 67 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoCategories.qhc" 68 | 69 | latex: 70 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 71 | @echo 72 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 73 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 74 | "run these through (pdf)latex." 75 | 76 | changes: 77 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 78 | @echo 79 | @echo "The overview file is in $(BUILDDIR)/changes." 80 | 81 | linkcheck: 82 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 83 | @echo 84 | @echo "Link check complete; look for any errors in the above output " \ 85 | "or in $(BUILDDIR)/linkcheck/output.txt." 86 | 87 | doctest: 88 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 89 | @echo "Testing of doctests in the sources finished, look at the " \ 90 | "results in $(BUILDDIR)/doctest/output.txt." 91 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | RELEASE_KIND := patch 5 | SOURCE_DIR := categories 6 | 7 | BRANCH_NAME := $(shell echo $$(git rev-parse --abbrev-ref HEAD)) 8 | SHORT_BRANCH_NAME := $(shell echo $(BRANCH_NAME) | cut -c 1-20) 9 | PRIMARY_BRANCH_NAME := master 10 | BUMPVERSION_OPTS := 11 | 12 | EDIT_CHANGELOG_IF_EDITOR_SET := @bash -c "$(shell if [[ -n $$EDITOR ]] ; then echo "$$EDITOR CHANGELOG.md" ; else echo "" ; fi)" 13 | 14 | help: 15 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' 16 | 17 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 18 | 19 | clean-build: ## remove build artifacts 20 | rm -fr build/ 21 | rm -fr dist/ 22 | rm -fr .eggs/ 23 | find . -name '*.egg-info' -exec rm -fr {} + 24 | find . -name '*.egg' -exec rm -f {} + 25 | 26 | clean-pyc: ## remove Python file artifacts 27 | find . -name '*.pyc' -exec rm -f {} + 28 | find . -name '*.pyo' -exec rm -f {} + 29 | find . -name '*~' -exec rm -f {} + 30 | find . -name '__pycache__' -exec rm -fr {} + 31 | 32 | clean-test: ## remove test and coverage artifacts 33 | rm -fr .tox/ 34 | rm -f .coverage 35 | rm -fr htmlcov/ 36 | rm -fr .pytest_cache 37 | 38 | deps: ## Install development dependencies 39 | pip install -r requirements.txt 40 | pip install tox sphinx sphinx-autobuild twine 41 | 42 | test: ## Run tests 43 | tox 44 | 45 | publish: ## Publish a release to PyPi (requires permissions) 46 | rm -fr build dist 47 | python setup.py sdist bdist_wheel 48 | twine upload dist/* 49 | 50 | docs: ## generate Sphinx HTML documentation, including API docs 51 | mkdir -p docs 52 | rm -f doc_src/api/$(SOURCE_DIR)*.rst 53 | ls -A1 docs | xargs -I {} rm -rf docs/{} 54 | $(MAKE) -C doc_src clean html 55 | cp -a doc_src/_build/html/. docs 56 | 57 | pubdocs: docs ## Publish the documentation to GitHub 58 | ghp-import -op docs 59 | 60 | release-dev: RELEASE_KIND := dev 61 | release-dev: do-release ## Release a new development version: 1.1.1 -> 1.1.1+branchname-1 62 | 63 | release-patch: RELEASE_KIND := patch 64 | release-patch: do-release ## Release a new patch version: 1.1.1 -> 1.1.2 65 | 66 | release-minor: RELEASE_KIND := minor 67 | release-minor: do-release ## Release a new minor version: 1.1.1 -> 1.2.0 68 | 69 | release-major: RELEASE_KIND := major 70 | release-major: do-release ## Release a new major version: 1.1.1 -> 2.0.0 71 | 72 | release-version: get-version do-release ## Release a specific version: release-version 1.2.3 73 | 74 | # 75 | # Helper targets. Not meant to use directly 76 | # 77 | 78 | do-release: 79 | @if [[ "$(BRANCH_NAME)" == "$(PRIMARY_BRANCH_NAME)" ]]; then \ 80 | if [[ "$(RELEASE_KIND)" == "dev" ]]; then \ 81 | echo "Error! Can't bump $(RELEASE_KIND) while on the $(PRIMARY_BRANCH_NAME) branch."; \ 82 | exit; \ 83 | fi; \ 84 | elif [[ "$(RELEASE_KIND)" != "dev" ]]; then \ 85 | echo "Error! Must be on the $(PRIMARY_BRANCH_NAME) branch to bump $(RELEASE_KIND)."; \ 86 | exit; \ 87 | fi; \ 88 | git fetch -p --all; \ 89 | generate-changelog; \ 90 | export BRANCH_NAME=$(SHORT_BRANCH_NAME);bumpversion $(BUMPVERSION_OPTS) $(RELEASE_KIND) --allow-dirty; \ 91 | git push origin $(BRANCH_NAME); \ 92 | git push --tags; 93 | 94 | get-version: # Sets the value after release-version to the VERSION 95 | $(eval VERSION := $(filter-out release-version,$(MAKECMDGOALS))) 96 | $(eval BUMPVERSION_OPTS := --new-version=$(VERSION)) 97 | 98 | %: # NO-OP for unrecognized rules 99 | @: 100 | -------------------------------------------------------------------------------- /categories/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from django.http import Http404 3 | from django.test import Client, RequestFactory, TestCase 4 | 5 | from categories import views 6 | from categories.models import Category, CategoryRelation 7 | 8 | 9 | class MyCategoryRelationView(views.CategoryRelatedDetail): 10 | model = CategoryRelation 11 | 12 | 13 | class TestCategoryViews(TestCase): 14 | fixtures = [ 15 | "musicgenres.json", 16 | ] 17 | 18 | def setUp(self): 19 | self.client = Client() 20 | self.factory = RequestFactory() 21 | 22 | def test_category_detail(self): 23 | cat0 = Category.objects.get(slug="country", level=0) 24 | cat1 = cat0.children.get(slug="country-pop") 25 | cat2 = Category.objects.get(slug="urban-cowboy") 26 | url = cat0.get_absolute_url() 27 | response = self.client.get(url) 28 | self.assertEqual(response.status_code, 200) 29 | url = cat1.get_absolute_url() 30 | response = self.client.get(url) 31 | self.assertEqual(response.status_code, 200) 32 | url = cat2.get_absolute_url() 33 | response = self.client.get(url) 34 | self.assertEqual(response.status_code, 200) 35 | response = self.client.get("%sfoo/" % url) 36 | self.assertEqual(response.status_code, 404) 37 | 38 | def test_get_category_for_path(self): 39 | cat0 = Category.objects.get(slug="country", level=0) 40 | cat1 = cat0.children.get(slug="country-pop") 41 | cat2 = Category.objects.get(slug="urban-cowboy") 42 | 43 | result = views.get_category_for_path("/country/country-pop/urban-cowboy/") 44 | self.assertEqual(result, cat2) 45 | result = views.get_category_for_path("/country/country-pop/") 46 | self.assertEqual(result, cat1) 47 | result = views.get_category_for_path("/country/") 48 | self.assertEqual(result, cat0) 49 | 50 | def test_categorydetailview(self): 51 | request = self.factory.get("") 52 | request.user = AnonymousUser() 53 | self.assertRaises(AttributeError, views.CategoryDetailView.as_view(), request) 54 | 55 | request = self.factory.get("") 56 | request.user = AnonymousUser() 57 | response = views.CategoryDetailView.as_view()(request, path="/country/country-pop/urban-cowboy/") 58 | self.assertEqual(response.status_code, 200) 59 | 60 | request = self.factory.get("") 61 | request.user = AnonymousUser() 62 | self.assertRaises(Http404, views.CategoryDetailView.as_view(), request, path="/country/country-pop/foo/") 63 | 64 | def test_categoryrelateddetailview(self): 65 | from simpletext.models import SimpleText 66 | 67 | stext = SimpleText.objects.create(name="Test", description="test description") 68 | cat = Category.objects.get(slug="urban-cowboy") 69 | cat_rel = CategoryRelation.objects.create(category=cat, content_object=stext) # NOQA 70 | request = self.factory.get("") 71 | request.user = AnonymousUser() 72 | self.assertRaises(AttributeError, MyCategoryRelationView.as_view(), request) 73 | 74 | request = self.factory.get("") 75 | request.user = AnonymousUser() 76 | response = MyCategoryRelationView.as_view()(request, category_path="/country/country-pop/urban-cowboy/") 77 | self.assertEqual(response.status_code, 200) 78 | 79 | request = self.factory.get("") 80 | request.user = AnonymousUser() 81 | self.assertRaises( 82 | Http404, MyCategoryRelationView.as_view(), request, category_path="/country/country-pop/foo/" 83 | ) 84 | -------------------------------------------------------------------------------- /doc_src/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | set SPHINXBUILD=sphinx-build 6 | set BUILDDIR=_build 7 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 8 | if NOT "%PAPER%" == "" ( 9 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 10 | ) 11 | 12 | if "%1" == "" goto help 13 | 14 | if "%1" == "help" ( 15 | :help 16 | echo.Please use `make ^` where ^ is one of 17 | echo. html to make standalone HTML files 18 | echo. dirhtml to make HTML files named index.html in directories 19 | echo. pickle to make pickle files 20 | echo. json to make JSON files 21 | echo. htmlhelp to make HTML files and a HTML help project 22 | echo. qthelp to make HTML files and a qthelp project 23 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 24 | echo. changes to make an overview over all changed/added/deprecated items 25 | echo. linkcheck to check all external links for integrity 26 | echo. doctest to run all doctests embedded in the documentation if enabled 27 | goto end 28 | ) 29 | 30 | if "%1" == "clean" ( 31 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 32 | del /q /s %BUILDDIR%\* 33 | goto end 34 | ) 35 | 36 | if "%1" == "html" ( 37 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 38 | echo. 39 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 40 | goto end 41 | ) 42 | 43 | if "%1" == "dirhtml" ( 44 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 47 | goto end 48 | ) 49 | 50 | if "%1" == "pickle" ( 51 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 52 | echo. 53 | echo.Build finished; now you can process the pickle files. 54 | goto end 55 | ) 56 | 57 | if "%1" == "json" ( 58 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 59 | echo. 60 | echo.Build finished; now you can process the JSON files. 61 | goto end 62 | ) 63 | 64 | if "%1" == "htmlhelp" ( 65 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 66 | echo. 67 | echo.Build finished; now you can run HTML Help Workshop with the ^ 68 | .hhp project file in %BUILDDIR%/htmlhelp. 69 | goto end 70 | ) 71 | 72 | if "%1" == "qthelp" ( 73 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 74 | echo. 75 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 76 | .qhcp project file in %BUILDDIR%/qthelp, like this: 77 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoCategories.qhcp 78 | echo.To view the help file: 79 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoCategories.ghc 80 | goto end 81 | ) 82 | 83 | if "%1" == "latex" ( 84 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 85 | echo. 86 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 87 | goto end 88 | ) 89 | 90 | if "%1" == "changes" ( 91 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 92 | echo. 93 | echo.The overview file is in %BUILDDIR%/changes. 94 | goto end 95 | ) 96 | 97 | if "%1" == "linkcheck" ( 98 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 99 | echo. 100 | echo.Link check complete; look for any errors in the above output ^ 101 | or in %BUILDDIR%/linkcheck/output.txt. 102 | goto end 103 | ) 104 | 105 | if "%1" == "doctest" ( 106 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 107 | echo. 108 | echo.Testing of doctests in the sources finished, look at the ^ 109 | results in %BUILDDIR%/doctest/output.txt. 110 | goto end 111 | ) 112 | 113 | :end 114 | -------------------------------------------------------------------------------- /categories/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import template 4 | from django.test import TestCase 5 | 6 | from categories.models import Category 7 | 8 | 9 | class CategoryTagsTest(TestCase): 10 | fixtures = ["musicgenres.json"] 11 | 12 | def render_template(self, template_string, context={}): 13 | """ 14 | Return the rendered string or raise an exception. 15 | """ 16 | tpl = template.Template(template_string) 17 | ctxt = template.Context(context) 18 | return tpl.render(ctxt) 19 | 20 | def testTooFewArguments(self): 21 | """ 22 | Ensure that get_category raises an exception if there aren't enough arguments. 23 | """ 24 | self.assertRaises( 25 | template.TemplateSyntaxError, self.render_template, "{% load category_tags %}{% get_category %}" 26 | ) 27 | 28 | def testBasicUsage(self): 29 | """ 30 | Test that we can properly retrieve a category. 31 | """ 32 | # display_path_as_ul 33 | rock_resp = '' 34 | resp = self.render_template('{% load category_tags %}{% display_path_as_ul "/Rock" %}') 35 | resp = re.sub(r"\n$", "", resp) 36 | self.assertEqual(resp, rock_resp) 37 | 38 | # display_drilldown_as_ul 39 | expected_resp = '' 40 | resp = self.render_template( 41 | "{% load category_tags %}" '{% display_drilldown_as_ul "/World/Worldbeat" "categories.category" %}' 42 | ) 43 | resp = re.sub(r"\n$", "", resp) 44 | self.assertEqual(resp, expected_resp) 45 | 46 | # breadcrumbs 47 | expected_resp = 'World > Worldbeat\n' 48 | resp = self.render_template( 49 | "{% load category_tags %}" '{% breadcrumbs "/World/Worldbeat" " > " "categories.category" %}' 50 | ) 51 | self.assertEqual(resp, expected_resp) 52 | 53 | # get_top_level_categories 54 | expected_resp = "Avant-garde|Blues|Country|Easy listening|Electronic|Hip hop/Rap music|Jazz|Latin|Modern folk|Pop|Reggae|Rhythm and blues|Rock|World|" 55 | resp = self.render_template( 56 | "{% load category_tags %}" 57 | '{% get_top_level_categories using "categories.category" as varname %}' 58 | "{% for item in varname %}{{ item }}|{% endfor %}" 59 | ) 60 | self.assertEqual(resp, expected_resp) 61 | 62 | # get_category_drilldown 63 | expected_resp = "World|World > Worldbeat|" 64 | resp = self.render_template( 65 | "{% load category_tags %}" 66 | '{% get_category_drilldown "/World" using "categories.category" as var %}' 67 | "{% for item in var %}{{ item }}|{% endfor %}" 68 | ) 69 | self.assertEqual(resp, expected_resp) 70 | 71 | # recursetree 72 | expected_resp = "
  • Country
    • Country pop
      • Urban Cowboy
  • World
    • Worldbeat
    " 73 | ctxt = {"nodes": Category.objects.filter(name__in=("Worldbeat", "Urban Cowboy"))} 74 | resp = self.render_template( 75 | "{% load category_tags %}" 76 | "
      {% recursetree nodes|tree_queryset %}
    • {{ node.name }}" 77 | "{% if not node.is_leaf_node %}
        {{ children }}" 78 | "
      {% endif %}
    • {% endrecursetree %}
    ", 79 | ctxt, 80 | ) 81 | self.assertEqual(resp, expected_resp) 82 | -------------------------------------------------------------------------------- /doc_src/reference/models.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Models 3 | ====== 4 | 5 | CategoryBase 6 | ============ 7 | 8 | .. py:class:: CategoryBase 9 | 10 | .. py:attribute:: parent 11 | 12 | :py:class:`TreeForeignKey` ``(self)`` 13 | 14 | The category's parent category. Leave this blank for an root category. 15 | 16 | .. py:attribute:: name 17 | 18 | **Required** ``CharField(100)`` 19 | 20 | The name of the category. 21 | 22 | .. py:attribute:: slug 23 | 24 | **Required** ``SlugField`` 25 | 26 | URL-friendly title. It is automatically generated from the title. 27 | 28 | .. py:attribute:: active 29 | 30 | **Required** ``BooleanField`` *default:* ``True`` 31 | 32 | Is this item active. If it is inactive, all children are set to inactive as well. 33 | 34 | .. py:attribute:: objects 35 | 36 | ``CategoryManager`` 37 | 38 | An object manager that adds an ``active`` method for only selecting items whose ``active`` attribute is ``True``. 39 | 40 | .. py:attribute:: tree 41 | 42 | ``TreeManager`` 43 | 44 | A Django-MPTT `TreeManager `_ instance. 45 | 46 | Category 47 | ======== 48 | 49 | .. py:class:: Category 50 | 51 | Category is a subclass of :py:class:`CategoryBase` and includes all its attributes. 52 | 53 | .. py:attribute:: thumbnail 54 | 55 | ``FileField`` 56 | 57 | An optional thumbnail, that is uploaded to :ref:`thumbnail_upload_path` via :ref:`THUMBNAIL_STORAGE`. 58 | 59 | .. note:: Why isn't this an ``ImageField``? 60 | 61 | For ``ImageField``\ s, Django checks the file system for the existance of the files to handle the height and width. In many cases this can lead to problems and impact performance. 62 | 63 | For these reasons, a ``FileField`` that manually manages the width and height was chosen. 64 | 65 | .. py:attribute:: thumbnail_width 66 | 67 | ``IntegerField`` 68 | 69 | The thumbnail width. Automatically set on save if a thumbnail is uploaded. 70 | 71 | .. py:attribute:: thumbnail_height 72 | 73 | ``IntegerField`` 74 | 75 | The thumbnail height. Automatically set on save if a thumbnail is uploaded. 76 | 77 | .. py:attribute:: order 78 | 79 | **Required** ``IntegerField`` *default:* 0 80 | 81 | A manually-managed order of this category in the listing. Items with the same order are sorted alphabetically. 82 | 83 | .. py:attribute:: alternate_title 84 | 85 | ``CharField(100)`` 86 | 87 | An alternative title to use on pages with this category. 88 | 89 | .. py:attribute:: alternate_url 90 | 91 | ``CharField(200)`` 92 | 93 | An alternative URL to use instead of the one derived from the category hierarchy. 94 | 95 | .. note:: Why isn't this a ``URLField``? 96 | 97 | For ``URLField``\ s, Django checks that the URL includes ``http://`` and the site name. This makes it impossible to use relative URLs in that field. 98 | 99 | .. py:attribute:: description 100 | 101 | ``TextField`` 102 | 103 | An optional longer description of the category. Very useful on category landing pages. 104 | 105 | .. py:attribute:: meta_keywords 106 | 107 | ``CharField(255)`` 108 | 109 | Comma-separated keywords for search engines. 110 | 111 | .. py:attribute:: meta_extra 112 | 113 | ``TextField`` 114 | 115 | (Advanced) Any additional HTML to be placed verbatim in the ```` of the page. 116 | -------------------------------------------------------------------------------- /.changelog-config.yaml: -------------------------------------------------------------------------------- 1 | # For more configuration information, please see https://coordt.github.io/generate-changelog/ 2 | 3 | # User variables for reference in other parts of the configuration. 4 | variables: 5 | repo_url: https://github.com/jazzband/django-categories 6 | changelog_filename: CHANGELOG.md 7 | 8 | # Pipeline to find the most recent tag for incremental changelog generation. 9 | # Leave empty to always start at first commit. 10 | starting_tag_pipeline: 11 | - action: ReadFile 12 | kwargs: 13 | filename: '{{ changelog_filename }}' 14 | - action: FirstRegExMatch 15 | kwargs: 16 | pattern: (?im)^## (?P\d+\.\d+(?:\.\d+)?)\s+\(\d+-\d{2}-\d{2}\)$ 17 | named_subgroup: rev 18 | 19 | # Used as the version title of the changes since the last valid tag. 20 | unreleased_label: Unreleased 21 | 22 | # Process the commit's first line for use in the changelog. 23 | summary_pipeline: 24 | - action: strip_spaces 25 | - action: Strip 26 | comment: Get rid of any periods so we don't get double periods 27 | kwargs: 28 | chars: . 29 | - action: SetDefault 30 | args: 31 | - no commit message 32 | - action: capitalize 33 | - action: append_dot 34 | 35 | # Process the commit's body for use in the changelog. 36 | body_pipeline: 37 | - action: ParseTrailers 38 | comment: Parse the trailers into metadata. 39 | kwargs: 40 | commit_metadata: save_commit_metadata 41 | 42 | # Process and store the full or partial changelog. 43 | output_pipeline: 44 | - action: IncrementalFileInsert 45 | kwargs: 46 | filename: '{{ changelog_filename }}' 47 | last_heading_pattern: (?im)^## \d+\.\d+(?:\.\d+)?\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)$ 48 | 49 | # Full or relative paths to look for output generation templates. 50 | template_dirs: 51 | - .github/changelog_templates/ 52 | 53 | # Group the commits within a version by these commit attributes. 54 | group_by: 55 | - metadata.category 56 | 57 | # Only tags matching this regular expression are used for the changelog. 58 | tag_pattern: ^[0-9]+\.[0-9]+(?:\.[0-9]+)?$ 59 | 60 | # Tells ``git-log`` whether to include merge commits in the log. 61 | include_merges: false 62 | 63 | # Ignore commits whose summary line matches any of these regular expression patterns. 64 | ignore_patterns: 65 | - '[@!]minor' 66 | - '[@!]cosmetic' 67 | - '[@!]refactor' 68 | - '[@!]wip' 69 | - ^$ 70 | - ^Merge branch 71 | - ^Merge pull 72 | 73 | # Set the commit's category metadata to the first classifier that returns ``True``. 74 | commit_classifiers: 75 | - action: SummaryRegexMatch 76 | category: New 77 | kwargs: 78 | pattern: (?i)^(?:new|add)[^\n]*$ 79 | - action: SummaryRegexMatch 80 | category: Updates 81 | kwargs: 82 | pattern: (?i)^(?:update|change|rename|remove|delete|improve|refactor|chg|modif)[^\n]*$ 83 | - action: SummaryRegexMatch 84 | category: Fixes 85 | kwargs: 86 | pattern: (?i)^(?:fix)[^\n]*$ 87 | - action: 88 | category: Other 89 | 90 | # Tokens in git commit trailers that indicate authorship. 91 | valid_author_tokens: 92 | - author 93 | - based-on-a-patch-by 94 | - based-on-patch-by 95 | - co-authored-by 96 | - co-committed-by 97 | - contributions-by 98 | - from 99 | - helped-by 100 | - improved-by 101 | - original-patch-by 102 | 103 | # Rules applied to commits to determine the type of release to suggest. 104 | release_hint_rules: 105 | - match_result: patch 106 | no_match_result: no-release 107 | grouping: Other 108 | - match_result: patch 109 | no_match_result: no-release 110 | grouping: Fixes 111 | - match_result: minor 112 | no_match_result: no-release 113 | grouping: Updates 114 | - match_result: minor 115 | no_match_result: 116 | grouping: New 117 | - match_result: major 118 | no_match_result: 119 | grouping: Breaking Changes 120 | -------------------------------------------------------------------------------- /categories/admin.py: -------------------------------------------------------------------------------- 1 | """Admin interface classes.""" 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from .base import CategoryBaseAdmin, CategoryBaseAdminForm 8 | from .genericcollection import GenericCollectionTabularInline 9 | from .models import Category 10 | from .settings import JAVASCRIPT_URL, MODEL_REGISTRY, REGISTER_ADMIN, RELATION_MODELS 11 | 12 | 13 | class NullTreeNodeChoiceField(forms.ModelChoiceField): 14 | """A ModelChoiceField for tree nodes.""" 15 | 16 | def __init__(self, level_indicator="---", *args, **kwargs): 17 | self.level_indicator = level_indicator 18 | super(NullTreeNodeChoiceField, self).__init__(*args, **kwargs) 19 | 20 | def label_from_instance(self, obj) -> str: 21 | """ 22 | Creates labels which represent the tree level of each node when generating option labels. 23 | """ 24 | return "%s %s" % (self.level_indicator * getattr(obj, obj._mptt_meta.level_attr), obj) 25 | 26 | 27 | if RELATION_MODELS: 28 | from .models import CategoryRelation 29 | 30 | class InlineCategoryRelation(GenericCollectionTabularInline): 31 | """The inline admin panel for category relations.""" 32 | 33 | model = CategoryRelation 34 | 35 | 36 | class CategoryAdminForm(CategoryBaseAdminForm): 37 | """The form for a category in the admin.""" 38 | 39 | class Meta: 40 | model = Category 41 | fields = "__all__" 42 | 43 | def clean_alternate_title(self) -> str: 44 | """Return either the name or alternate title for the category.""" 45 | if self.instance is None or not self.cleaned_data["alternate_title"]: 46 | return self.cleaned_data["name"] 47 | else: 48 | return self.cleaned_data["alternate_title"] 49 | 50 | 51 | class CategoryAdmin(CategoryBaseAdmin): 52 | """Admin for categories.""" 53 | 54 | form = CategoryAdminForm 55 | list_display = ("name", "alternate_title", "active") 56 | fieldsets = ( 57 | (None, {"fields": ("parent", "name", "thumbnail", "active")}), 58 | ( 59 | _("Meta Data"), 60 | { 61 | "fields": ("alternate_title", "alternate_url", "description", "meta_keywords", "meta_extra"), 62 | "classes": ("collapse",), 63 | }, 64 | ), 65 | ( 66 | _("Advanced"), 67 | { 68 | "fields": ("order", "slug"), 69 | "classes": ("collapse",), 70 | }, 71 | ), 72 | ) 73 | autocomplete_fields = ("parent",) 74 | 75 | if RELATION_MODELS: 76 | inlines = [ 77 | InlineCategoryRelation, 78 | ] 79 | 80 | class Media: 81 | js = (JAVASCRIPT_URL + "genericcollections.js",) 82 | 83 | 84 | if REGISTER_ADMIN: 85 | admin.site.register(Category, CategoryAdmin) 86 | 87 | for model, modeladmin in list(admin.site._registry.items()): 88 | if model in list(MODEL_REGISTRY.values()) and modeladmin.fieldsets: 89 | fieldsets = getattr(modeladmin, "fieldsets", ()) 90 | fields = [cat.split(".")[2] for cat in MODEL_REGISTRY if MODEL_REGISTRY[cat] == model] 91 | # check each field to see if already defined 92 | for cat in fields: 93 | for k, v in fieldsets: 94 | if cat in v["fields"]: 95 | fields.remove(cat) 96 | # if there are any fields left, add them under the categories fieldset 97 | if len(fields) > 0: 98 | admin.site.unregister(model) 99 | admin.site.register( 100 | model, 101 | type( 102 | "newadmin", 103 | (modeladmin.__class__,), 104 | {"fieldsets": fieldsets + (("Categories", {"fields": fields}),)}, 105 | ), 106 | ) 107 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | """Django settings for sample project.""" 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.db import models 8 | 9 | APP = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 10 | PROJ_ROOT = os.path.abspath(os.path.dirname(__file__)) 11 | sys.path.insert(0, APP) 12 | DEBUG = True 13 | 14 | ADMINS = ( 15 | # ('Your Name', 'your_email@domain.com'), 16 | ) 17 | 18 | MANAGERS = ADMINS 19 | 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.sqlite3", 23 | "NAME": "dev.db", 24 | "USER": "", 25 | "PASSWORD": "", 26 | "HOST": "", 27 | "PORT": "", 28 | } 29 | } 30 | 31 | INSTALLED_APPS = ( 32 | "django.contrib.admin", 33 | "django.contrib.auth", 34 | "django.contrib.contenttypes", 35 | "django.contrib.sessions", 36 | "django.contrib.sites", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | "django.contrib.flatpages", 40 | "categories", 41 | "categories.editor", 42 | "mptt", 43 | "simpletext", 44 | ) 45 | 46 | TIME_ZONE = "America/Chicago" 47 | 48 | LANGUAGE_CODE = "en-us" 49 | 50 | SITE_ID = 1 51 | 52 | USE_I18N = True 53 | 54 | MEDIA_ROOT = os.path.abspath(os.path.join(PROJ_ROOT, "media", "uploads")) 55 | 56 | MEDIA_URL = "/uploads/" 57 | 58 | STATIC_ROOT = os.path.abspath(os.path.join(PROJ_ROOT, "media", "static")) 59 | 60 | STATIC_URL = "/static/" 61 | 62 | STATICFILES_DIRS = () 63 | 64 | STATICFILES_FINDERS = ( 65 | "django.contrib.staticfiles.finders.FileSystemFinder", 66 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 67 | ) 68 | 69 | SECRET_KEY = "bwq#m)-zsey-fs)0#4*o=2z(v5g!ei=zytl9t-1hesh4b&-u^d" 70 | 71 | MIDDLEWARE = ( 72 | "django.middleware.security.SecurityMiddleware", 73 | "django.contrib.sessions.middleware.SessionMiddleware", 74 | "django.middleware.common.CommonMiddleware", 75 | "django.middleware.csrf.CsrfViewMiddleware", 76 | "django.contrib.auth.middleware.AuthenticationMiddleware", 77 | "django.contrib.messages.middleware.MessageMiddleware", 78 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 79 | ) 80 | 81 | ROOT_URLCONF = "urls" 82 | 83 | TEMPLATES = [ 84 | { 85 | "BACKEND": "django.template.backends.django.DjangoTemplates", 86 | "APP_DIRS": True, 87 | "DIRS": [os.path.abspath(os.path.join(os.path.dirname(__file__), "templates"))], 88 | "OPTIONS": { 89 | "debug": DEBUG, 90 | "context_processors": [ 91 | "django.contrib.auth.context_processors.auth", 92 | "django.template.context_processors.debug", 93 | "django.template.context_processors.i18n", 94 | "django.template.context_processors.media", 95 | "django.template.context_processors.static", 96 | "django.template.context_processors.tz", 97 | "django.contrib.messages.context_processors.messages", 98 | ], 99 | }, 100 | } 101 | ] 102 | 103 | 104 | CATEGORIES_SETTINGS = { 105 | "ALLOW_SLUG_CHANGE": True, 106 | "RELATION_MODELS": ["simpletext.simpletext", "flatpages.flatpage"], 107 | "FK_REGISTRY": { 108 | "flatpages.flatpage": ("category", {"on_delete": models.CASCADE}), 109 | "simpletext.simpletext": ( 110 | "primary_category", 111 | {"name": "secondary_category", "related_name": "simpletext_sec_cat"}, 112 | ), 113 | }, 114 | "M2M_REGISTRY": { 115 | "simpletext.simpletext": {"name": "categories", "related_name": "m2mcats"}, 116 | "flatpages.flatpage": ( 117 | {"name": "other_categories", "related_name": "other_cats"}, 118 | {"name": "more_categories", "related_name": "more_cats"}, 119 | ), 120 | }, 121 | } 122 | 123 | if django.VERSION[1] > 5: 124 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 125 | -------------------------------------------------------------------------------- /doc_src/reference/settings.rst: -------------------------------------------------------------------------------- 1 | .. _reference_settings: 2 | 3 | ======== 4 | Settings 5 | ======== 6 | 7 | The ``CATEGORIES_SETTINGS`` dictionary is where you can override the default settings. You don't have to include all the settings; only the ones which you want to override. 8 | 9 | .. contents:: 10 | :local: 11 | 12 | 13 | The default settings are: 14 | 15 | .. code-block:: python 16 | 17 | CATEGORIES_SETTINGS = { 18 | 'ALLOW_SLUG_CHANGE': False, 19 | 'CACHE_VIEW_LENGTH': 0, 20 | 'RELATION_MODELS': [], 21 | 'M2M_REGISTRY': {}, 22 | 'FK_REGISTRY': {}, 23 | 'THUMBNAIL_UPLOAD_PATH': 'uploads/categories/thumbnails', 24 | 'THUMBNAIL_STORAGE': settings.DEFAULT_FILE_STORAGE, 25 | 'THUMBNAIL_STORAGE_ALIAS': 'default', 26 | 'SLUG_TRANSLITERATOR': lambda x: x, 27 | 'ADMIN_FIELDSETS': {} 28 | } 29 | 30 | 31 | .. _ALLOW_SLUG_CHANGE: 32 | 33 | ALLOW_SLUG_CHANGE 34 | ================= 35 | 36 | **Default:** ``False`` 37 | 38 | **Description:** Changing the slug for a category can have serious consequences if it is used as part of a URL. Setting this to ``True`` will allow users to change the slug of a category. 39 | 40 | .. _SLUG_TRANSLITERATOR: 41 | 42 | SLUG_TRANSLITERATOR 43 | =================== 44 | 45 | **Default:** ``lambda x: x`` 46 | 47 | **Description:** Allows the specification of a function to convert non-ASCII characters in the potential slug to ASCII characters. Allows specifying a ``callable()`` or a string in the form of ``'path.to.module.function'``. 48 | 49 | A great tool for this is `Unidecode `_. Use it by setting ``SLUG_TRANSLITERATOR`` to ``'unidecode.unidecode``. 50 | 51 | 52 | .. _CACHE_VIEW_LENGTH: 53 | 54 | CACHE_VIEW_LENGTH 55 | ================= 56 | 57 | **Default:** ``0`` 58 | 59 | **Description:** This setting will be deprecated soon, but in the mean time, it allows you to specify the amount of time each view result is cached. 60 | 61 | .. _RELATION_MODELS: 62 | 63 | RELATION_MODELS 64 | =============== 65 | 66 | **Default:** ``[]`` 67 | 68 | **Description:** Relation models is a set of models that a user can associate with this category. You specify models using ``'app_name.modelname'`` syntax. 69 | 70 | .. _M2M_REGISTRY: 71 | 72 | M2M_REGISTRY 73 | ============ 74 | 75 | **Default:** {} 76 | 77 | **Description:** A dictionary where the keys are in ``'app_name.model_name'`` syntax, and the values are a string, dict, or tuple of dicts. See :ref:`registering_models`\ . 78 | 79 | .. _FK_REGISTRY: 80 | 81 | FK_REGISTRY 82 | ============ 83 | 84 | **Default:** {} 85 | 86 | **Description:** A dictionary where the keys are in ``'app_name.model_name'`` syntax, and the values are a string, dict, or tuple of dicts. See :ref:`registering_models`\ . 87 | 88 | .. _THUMBNAIL_UPLOAD_PATH: 89 | 90 | .. _REGISTER_ADMIN: 91 | 92 | REGISTER_ADMIN 93 | ============== 94 | 95 | **Default:** ``True`` 96 | 97 | **Description:** If you write your own category class by subclassing ``CategoryBase`` then you probably have no use for registering the default ``Category`` class in the admin. 98 | 99 | 100 | THUMBNAIL_UPLOAD_PATH 101 | ===================== 102 | 103 | **Default:** ``'uploads/categories/thumbnails'`` 104 | 105 | **Description:** Where thumbnails for the categories will be saved. 106 | 107 | .. _THUMBNAIL_STORAGE: 108 | 109 | THUMBNAIL_STORAGE 110 | ================= 111 | 112 | **Default:** ``settings.DEFAULT_FILE_STORAGE`` 113 | 114 | **Description:** How to store the thumbnails. Allows for external storage engines like S3. 115 | 116 | .. _THUMBNAIL_STORAGE: 117 | 118 | THUMBNAIL_STORAGE_ALIAS 119 | ======================= 120 | 121 | **Default:** ``default`` 122 | 123 | **Description:** If new STORAGES settings from Django 4.2+ is used, use storage with this alias. 124 | 125 | .. _JAVASCRIPT_URL: 126 | 127 | JAVASCRIPT_URL 128 | ============== 129 | 130 | **Default:** ``STATIC_URL or MEDIA_URL + 'js/'`` 131 | 132 | **Description:** Allows for customization of javascript placement. 133 | 134 | .. _ADMIN_FIELDSETS: 135 | 136 | ADMIN_FIELDSETS 137 | =============== 138 | 139 | **Default:** ``{}`` 140 | 141 | **Description:** Allows for selective customization of the default behavior of adding the fields to the admin class. See :ref:`admin_settings` for more information. 142 | -------------------------------------------------------------------------------- /example/settings-testing.py: -------------------------------------------------------------------------------- 1 | # Django settings for sample project. 2 | import os 3 | import sys 4 | 5 | from django.db import models 6 | 7 | APP = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 8 | PROJ_ROOT = os.path.abspath(os.path.dirname(__file__)) 9 | sys.path.insert(0, APP) 10 | DEBUG = True 11 | 12 | ADMINS = ( 13 | # ('Your Name', 'your_email@domain.com'), 14 | ) 15 | 16 | MANAGERS = ADMINS 17 | 18 | DATABASES = { 19 | "default": { 20 | "ENGINE": "django.db.backends.sqlite3", 21 | "NAME": "dev.db", 22 | "USER": "", 23 | "PASSWORD": "", 24 | "HOST": "", 25 | "PORT": "", 26 | } 27 | } 28 | 29 | INSTALLED_APPS = ( 30 | "django.contrib.admin", 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.sites", 35 | "django.contrib.messages", 36 | "django.contrib.staticfiles", 37 | "django.contrib.flatpages", 38 | "categories", 39 | "categories.editor", 40 | "mptt", 41 | "simpletext", 42 | ) 43 | 44 | TIME_ZONE = "America/Chicago" 45 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 46 | LANGUAGE_CODE = "en-us" 47 | 48 | SITE_ID = 1 49 | 50 | USE_I18N = True 51 | 52 | MEDIA_ROOT = os.path.abspath(os.path.join(PROJ_ROOT, "media", "uploads")) 53 | 54 | MEDIA_URL = "/uploads/" 55 | 56 | STATIC_ROOT = os.path.abspath(os.path.join(PROJ_ROOT, "media", "static")) 57 | 58 | STATIC_URL = "/static/" 59 | 60 | STATICFILES_DIRS = () 61 | 62 | STATICFILES_FINDERS = ( 63 | "django.contrib.staticfiles.finders.FileSystemFinder", 64 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 65 | ) 66 | 67 | SECRET_KEY = "bwq#m)-zsey-fs)0#4*o=2z(v5g!ei=zytl9t-1hesh4b&-u^d" 68 | 69 | MIDDLEWARE = ( 70 | "django.middleware.security.SecurityMiddleware", 71 | "django.contrib.sessions.middleware.SessionMiddleware", 72 | "django.middleware.common.CommonMiddleware", 73 | "django.middleware.csrf.CsrfViewMiddleware", 74 | "django.contrib.auth.middleware.AuthenticationMiddleware", 75 | "django.contrib.messages.middleware.MessageMiddleware", 76 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 77 | ) 78 | 79 | ROOT_URLCONF = "urls" 80 | 81 | TEMPLATES = [ 82 | { 83 | "BACKEND": "django.template.backends.django.DjangoTemplates", 84 | "APP_DIRS": True, 85 | "DIRS": [os.path.abspath(os.path.join(os.path.dirname(__file__), "templates"))], 86 | "OPTIONS": { 87 | "debug": DEBUG, 88 | "context_processors": [ 89 | "django.contrib.auth.context_processors.auth", 90 | "django.template.context_processors.debug", 91 | "django.template.context_processors.i18n", 92 | "django.template.context_processors.media", 93 | "django.template.context_processors.static", 94 | "django.template.context_processors.tz", 95 | "django.contrib.messages.context_processors.messages", 96 | ], 97 | }, 98 | }, 99 | ] 100 | 101 | 102 | STORAGES = { 103 | "default": { 104 | "BACKEND": "django.core.files.storage.FileSystemStorage", 105 | }, 106 | "staticfiles": { 107 | "BACKEND": "django.core.files.storage.FileSystemStorage", 108 | }, 109 | "thumbnails": { 110 | "BACKEND": "example.test_storages.MyTestStorageAlias", 111 | }, 112 | } 113 | 114 | 115 | CATEGORIES_SETTINGS = { 116 | "ALLOW_SLUG_CHANGE": True, 117 | "RELATION_MODELS": ["simpletext.simpletext", "flatpages.flatpage"], 118 | "FK_REGISTRY": { 119 | "flatpages.flatpage": ("category", {"on_delete": models.CASCADE}), 120 | "simpletext.simpletext": ( 121 | "primary_category", 122 | {"name": "secondary_category", "related_name": "simpletext_sec_cat"}, 123 | ), 124 | }, 125 | "M2M_REGISTRY": { 126 | "simpletext.simpletext": {"name": "categories", "related_name": "m2mcats"}, 127 | "flatpages.flatpage": ( 128 | {"name": "other_categories", "related_name": "other_cats"}, 129 | {"name": "more_categories", "related_name": "more_cats"}, 130 | ), 131 | }, 132 | "THUMBNAIL_STORAGE": "example.test_storages.MyTestStorage", 133 | "THUMBNAIL_STORAGE_ALIAS": "thumbnails", 134 | } 135 | 136 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 137 | -------------------------------------------------------------------------------- /categories/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # German translation of django-categories 2 | # Copyright (C) 2012 winniehell 3 | # This file is distributed under the same license as django-categories. 4 | # winniehell , 2012. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-categories 1.1.4\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2012-09-02 15:59+0200\n" 10 | "PO-Revision-Date: 2012-09-01 02:38+0200\n" 11 | "Last-Translator: winniehell \n" 12 | "Language-Team: winniehell \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 18 | 19 | #: admin.py:50 20 | msgid "Meta Data" 21 | msgstr "Meta-Daten" 22 | 23 | #: admin.py:55 24 | msgid "Advanced" 25 | msgstr "Erweitert" 26 | 27 | #: base.py:41 28 | msgid "parent" 29 | msgstr "oberkategorie" 30 | 31 | #: base.py:42 32 | msgid "name" 33 | msgstr "name" 34 | 35 | #: base.py:43 36 | msgid "slug" 37 | msgstr "slug" 38 | 39 | #: base.py:44 40 | msgid "active" 41 | msgstr "aktiv" 42 | 43 | #: base.py:99 44 | msgid "The slug must be unique among the items at its level." 45 | msgstr "Der Slug muss eindeutig innerhalb der Kategorien einer Ebene sein." 46 | 47 | #: base.py:109 48 | msgid "You can't set the parent of the item to itself." 49 | msgstr "Eine Kategorie kann nicht Oberkategorie von sich selbst sein." 50 | 51 | #: base.py:112 52 | msgid "You can't set the parent of the item to a descendant." 53 | msgstr "Die Oberkategorie kann keine untergeordnete Kategorie sein." 54 | 55 | #: base.py:143 56 | msgid "Deactivate selected categories and their children" 57 | msgstr "Markierte Kategorien und ihre Unterkategorien deaktivieren" 58 | 59 | #: base.py:156 60 | msgid "Activate selected categories and their children" 61 | msgstr "Markierte Kategorien und ihre Unterkategorien aktivieren" 62 | 63 | #: migration.py:21 migration.py:91 64 | msgid "%(dependency) must be installed for this command to work" 65 | msgstr "" 66 | "%(dependency) muss installiert sein, damit dieses Kommando funktioniert" 67 | 68 | #: migration.py:43 69 | msgid "Added ForeignKey %(field_name) to %(model_name)" 70 | msgstr "Fremdschlüssel %(field_name) zu %(model_name) hinzugefügt" 71 | 72 | #: migration.py:49 73 | msgid "ForeignKey %(field_name) to %(model_name) already exists" 74 | msgstr "Fremdschlüssel %(field_name) zu %(model_name) existiert bereits" 75 | 76 | #: migration.py:66 77 | msgid "Added Many2Many table between %(model_name) and %(category_table)" 78 | msgstr "M:N-Beziehung zwischen %(model_name) und %(category_table) hinzugefügt" 79 | 80 | #: migration.py:72 81 | msgid "" 82 | "Many2Many table between %(model_name) and %(category_table) already exists" 83 | msgstr "" 84 | "M:N-Beziehung zwischen %(model_name) und %(category_table) existiert bereits" 85 | 86 | #: migration.py:98 87 | msgid "Dropping ForeignKey %(field_name) from %(model_name)" 88 | msgstr "Entferne Fremdschlüssel %(field_name) zu %(model_name)" 89 | 90 | #: migration.py:109 91 | msgid "Dropping Many2Many table between %(model_name) and %(category_table)" 92 | msgstr "Entferne M:N-Beziehung zwischen %(model_name) und %(category_table)" 93 | 94 | #: models.py:91 models.py:122 95 | msgid "category" 96 | msgstr "kategorie" 97 | 98 | #: models.py:92 99 | msgid "categories" 100 | msgstr "kategorien" 101 | 102 | #: models.py:124 103 | msgid "content type" 104 | msgstr "content type" 105 | 106 | #: models.py:125 107 | msgid "object id" 108 | msgstr "objekt-ID" 109 | 110 | #: models.py:127 111 | msgid "relation type" 112 | msgstr "relationstyp" 113 | 114 | #: models.py:131 115 | msgid "A generic text field to tag a relation, like 'leadphoto'." 116 | msgstr "" 117 | "Ein generisches Textfeld um eine Relation zu bezeichnen, z.B. 'leadphoto'" 118 | 119 | #: registration.py:45 120 | #, python-format 121 | msgid "%(key) is not a model" 122 | msgstr "%(key) ist kein Model" 123 | 124 | #: registration.py:54 registration.py:62 125 | msgid "%(settings) doesn't recognize the value of %(key)" 126 | msgstr "%(settings) erkennt den Wert von %(key) nicht" 127 | 128 | #: settings.py:33 129 | msgid "%(transliterator) must be a callable or a string." 130 | msgstr "%(transliterator) muss callable oder string sein" 131 | 132 | #: settings.py:39 133 | #, python-format 134 | msgid "%(deprecated_setting) is deprecated; use %(replacement)s instead." 135 | msgstr "" 136 | "%(deprecated_setting) ist veraltet und wurde durch %(replacement)s ersetzt." 137 | 138 | #: views.py:73 139 | msgid "Category detail view %(view) must be called with a %(path_field)." 140 | msgstr "" 141 | "Kategorie-Detailansicht %(view) muss ein %(path_field) übergeben bekommen." 142 | 143 | #: views.py:80 144 | #, python-format 145 | msgid "No %(verbose_name)s found matching the query" 146 | msgstr "Es wurde kein passendes Objekt für %(verbose_name)s gefunden" 147 | 148 | #: templates/admin/edit_inline/gen_coll_tabular.html:13 149 | msgid "Delete?" 150 | msgstr "Löschen?" 151 | 152 | #: templates/admin/edit_inline/gen_coll_tabular.html:24 153 | msgid "View on site" 154 | msgstr "Anzeigen" 155 | -------------------------------------------------------------------------------- /doc_src/user_guide/registering_models.rst: -------------------------------------------------------------------------------- 1 | .. _registering_models: 2 | 3 | ================== 4 | Registering Models 5 | ================== 6 | 7 | 8 | Registering models in settings.py 9 | ================================= 10 | 11 | It is nice to not have to modify the code of applications to add a relation to categories. You can therefore do all the registering in ``settings.py``\ . For example: 12 | 13 | .. code-block:: python 14 | 15 | CATEGORIES_SETTINGS = { 16 | 'FK_REGISTRY': { 17 | 'app.AModel': 'category', 18 | 'app.MyModel': ( 19 | 'primary_category', 20 | {'name': 'secondary_category', 'related_name': 'mymodel_sec_cat'}, ) 21 | }, 22 | 'M2M_REGISTRY': { 23 | 'app.BModel': 'categories', 24 | 'app.MyModel': ('other_categories', 'more_categories', ), 25 | } 26 | } 27 | 28 | The ``FK_REGISTRY`` is a dictionary whose keys are the model to which to add the new field(s). The value is a string or tuple of strings or dictionaries specifying the necessary information for each field. 29 | 30 | The ``M2M_REGISTRY`` is a dictionary whose keys are the model to which to add the new field(s). The value is a string or tuple of strings specifying the necessary information for each field. 31 | 32 | 33 | Registering one Category field to model 34 | *************************************** 35 | 36 | The simplest way is to specify the name of the field, such as: 37 | 38 | .. code-block:: python 39 | 40 | CATEGORIES_SETTINGS = { 41 | 'FK_REGISTRY': { 42 | 'app.AModel': 'category' 43 | } 44 | } 45 | 46 | This is equivalent to adding the following the ``AModel`` of ``app``\ : 47 | 48 | .. code-block:: python 49 | 50 | category = models.ForeignKey(Category) 51 | 52 | 53 | If you want more control over the new field, you can use a dictionary and pass other ``ForeignKey`` options. The ``name`` key is required: 54 | 55 | .. code-block:: python 56 | 57 | CATEGORIES_SETTINGS = { 58 | 'FK_REGISTRY': { 59 | 'app.AModel': {'name': 'category', 'related_name': 'amodel_cats'} 60 | } 61 | } 62 | 63 | This is equivalent to adding the following the ``AModel`` of ``app``\ : 64 | 65 | .. code-block:: python 66 | 67 | category = models.ForeignKey(Category, related_name='amodel_cats') 68 | 69 | Registering two or more Category fields to a Model 70 | ************************************************** 71 | 72 | When you want more than one relation to ``Category``\ , all but one of the fields must specify a ``related_name`` to avoid conflicts, like so: 73 | 74 | .. code-block:: python 75 | 76 | CATEGORIES_SETTINGS = { 77 | 'FK_REGISTRY': { 78 | 'app.MyModel': ( 79 | 'primary_category', 80 | {'name': 'secondary_category', 'related_name': 'mymodel_sec_cat'}, ) 81 | }, 82 | 83 | Registering one or more Many-to-Many Category fields to a Model 84 | *************************************************************** 85 | 86 | .. code-block:: python 87 | 88 | CATEGORIES_SETTINGS = { 89 | 'M2M_REGISTRY': { 90 | 'app.AModel': 'categories', 91 | 'app.MyModel': ( 92 | {'name': 'other_categories', 'related_name': 'other_cats'}, 93 | {'name': 'more_categories', 'related_name': 'more_cats'}, 94 | ), 95 | } 96 | } 97 | 98 | .. _registering_a_m2one_relationship: 99 | 100 | Registering a many-to-one relationship 101 | ====================================== 102 | 103 | To create a many-to-one relationship (foreign key) between a model and Django Categories, you register your model with the ``register_fk`` function. 104 | 105 | .. py:function:: register_fk(model, field_name='category', extra_params={}]) 106 | 107 | :param model: The Django Model to link to Django Categories 108 | :param field_name: Optional name for the field **default:** category 109 | :param extra_params: Optional dictionary of extra parameters passed to the ``ForeignKey`` class. 110 | 111 | Example, in your ``models.py``:: 112 | 113 | import categories 114 | categories.register_fk(MyModel) 115 | 116 | If you want more than one field on a model you have to have some extra arguments:: 117 | 118 | import categories 119 | categories.register_fk(MyModel, 'primary_category') 120 | categories.register_fk(MyModel, 'secondary_category', {'related_name':'mymodel_sec_cat'}) 121 | 122 | The ``extra_args`` allows you to specify the related_name of one of the fields so it doesn't clash. 123 | 124 | 125 | Registering a many-to-many relationship 126 | ======================================= 127 | 128 | To create a many-to-many relationship between a model and Django Categories, you register your model with the ``register_m2m`` function. 129 | 130 | .. py:function:: register_m2m(model, field_name='categories', extra_params={}]) 131 | 132 | :param model: The Django Model to link to Django Categories 133 | :param field_name: Optional name for the field **default:** categories 134 | :param extra_params: Optional dictionary of extra parameters passed to the ``ManyToManyField`` class. 135 | 136 | Example, in your ``models.py``:: 137 | 138 | import categories 139 | categories.register_m2m(MyModel) 140 | -------------------------------------------------------------------------------- /categories/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.core.files.storage 2 | import mptt.fields 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("contenttypes", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Category", 14 | fields=[ 15 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 16 | ("name", models.CharField(max_length=100, verbose_name="name")), 17 | ("slug", models.SlugField(verbose_name="slug")), 18 | ("active", models.BooleanField(default=True, verbose_name="active")), 19 | ( 20 | "thumbnail", 21 | models.FileField( 22 | storage=django.core.files.storage.FileSystemStorage(), 23 | null=True, 24 | upload_to="uploads/categories/thumbnails", 25 | blank=True, 26 | ), 27 | ), 28 | ("thumbnail_width", models.IntegerField(null=True, blank=True)), 29 | ("thumbnail_height", models.IntegerField(null=True, blank=True)), 30 | ("order", models.IntegerField(default=0)), 31 | ( 32 | "alternate_title", 33 | models.CharField( 34 | default="", 35 | help_text="An alternative title to use on pages with this category.", 36 | max_length=100, 37 | blank=True, 38 | ), 39 | ), 40 | ( 41 | "alternate_url", 42 | models.CharField( 43 | help_text="An alternative URL to use instead of the one derived from the category hierarchy.", 44 | max_length=200, 45 | blank=True, 46 | ), 47 | ), 48 | ("description", models.TextField(null=True, blank=True)), 49 | ( 50 | "meta_keywords", 51 | models.CharField( 52 | default="", 53 | help_text="Comma-separated keywords for search engines.", 54 | max_length=255, 55 | blank=True, 56 | ), 57 | ), 58 | ( 59 | "meta_extra", 60 | models.TextField( 61 | default="", 62 | help_text="(Advanced) Any additional HTML to be placed verbatim in the <head>", 63 | blank=True, 64 | ), 65 | ), 66 | ("lft", models.PositiveIntegerField(editable=False, db_index=True)), 67 | ("rght", models.PositiveIntegerField(editable=False, db_index=True)), 68 | ("tree_id", models.PositiveIntegerField(editable=False, db_index=True)), 69 | ("level", models.PositiveIntegerField(editable=False, db_index=True)), 70 | ( 71 | "parent", 72 | mptt.fields.TreeForeignKey( 73 | related_name="children", 74 | verbose_name="parent", 75 | blank=True, 76 | to="categories.Category", 77 | on_delete=models.CASCADE, 78 | null=True, 79 | ), 80 | ), 81 | ], 82 | options={ 83 | "ordering": ("tree_id", "lft"), 84 | "abstract": False, 85 | "verbose_name": "category", 86 | "verbose_name_plural": "categories", 87 | }, 88 | ), 89 | migrations.CreateModel( 90 | name="CategoryRelation", 91 | fields=[ 92 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 93 | ("object_id", models.PositiveIntegerField(verbose_name="object id")), 94 | ( 95 | "relation_type", 96 | models.CharField( 97 | help_text="A generic text field to tag a relation, like 'leadphoto'.", 98 | max_length="200", 99 | null=True, 100 | verbose_name="relation type", 101 | blank=True, 102 | ), 103 | ), 104 | ( 105 | "category", 106 | models.ForeignKey(verbose_name="category", to="categories.Category", on_delete=models.CASCADE), 107 | ), 108 | ( 109 | "content_type", 110 | models.ForeignKey( 111 | verbose_name="content type", to="contenttypes.ContentType", on_delete=models.CASCADE 112 | ), 113 | ), 114 | ], 115 | ), 116 | migrations.AlterUniqueTogether( 117 | name="category", 118 | unique_together=set([("parent", "name")]), 119 | ), 120 | ] 121 | -------------------------------------------------------------------------------- /categories/models.py: -------------------------------------------------------------------------------- 1 | """Category models.""" 2 | 3 | from functools import reduce 4 | 5 | from django.contrib.contenttypes.fields import GenericForeignKey 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.core.files.images import get_image_dimensions 8 | from django.db import models 9 | from django.urls import reverse 10 | from django.utils.encoding import force_str 11 | from django.utils.translation import gettext_lazy as _ 12 | 13 | from .base import CategoryBase 14 | from .settings import ( 15 | RELATION_MODELS, 16 | RELATIONS, 17 | THUMBNAIL_STORAGE_ALIAS, 18 | THUMBNAIL_UPLOAD_PATH, 19 | ) 20 | 21 | # Determine storage method based on Django version 22 | try: # Django 4.2+ 23 | from django.core.files.storage import storages 24 | 25 | STORAGE = storages[THUMBNAIL_STORAGE_ALIAS] 26 | except ImportError: 27 | from django.core.files.storage import get_storage_class 28 | 29 | from .settings import THUMBNAIL_STORAGE 30 | 31 | STORAGE = get_storage_class(THUMBNAIL_STORAGE)() 32 | 33 | 34 | class Category(CategoryBase): 35 | """A basic category model.""" 36 | 37 | thumbnail = models.FileField( 38 | upload_to=THUMBNAIL_UPLOAD_PATH, 39 | null=True, 40 | blank=True, 41 | storage=STORAGE, 42 | ) 43 | thumbnail_width = models.IntegerField(blank=True, null=True) 44 | thumbnail_height = models.IntegerField(blank=True, null=True) 45 | order = models.IntegerField(default=0) 46 | alternate_title = models.CharField( 47 | blank=True, default="", max_length=100, help_text="An alternative title to use on pages with this category." 48 | ) 49 | alternate_url = models.CharField( 50 | blank=True, 51 | max_length=200, 52 | help_text="An alternative URL to use instead of the one derived from " "the category hierarchy.", 53 | ) 54 | description = models.TextField(blank=True, null=True) 55 | meta_keywords = models.CharField( 56 | blank=True, default="", max_length=255, help_text="Comma-separated keywords for search engines." 57 | ) 58 | meta_extra = models.TextField( 59 | blank=True, default="", help_text="(Advanced) Any additional HTML to be placed verbatim " "in the <head>" 60 | ) 61 | 62 | @property 63 | def short_title(self): 64 | """Return the name.""" 65 | return self.name 66 | 67 | def get_absolute_url(self): 68 | """Return a path.""" 69 | from django.urls import NoReverseMatch 70 | 71 | if self.alternate_url: 72 | return self.alternate_url 73 | try: 74 | prefix = reverse("categories_tree_list") 75 | except NoReverseMatch: 76 | prefix = "/" 77 | ancestors = list(self.get_ancestors()) + [ 78 | self, 79 | ] 80 | return prefix + "/".join([force_str(i.slug) for i in ancestors]) + "/" 81 | 82 | if RELATION_MODELS: 83 | 84 | def get_related_content_type(self, content_type): 85 | """ 86 | Get all related items of the specified content type. 87 | """ 88 | return self.categoryrelation_set.filter(content_type__name=content_type) 89 | 90 | def get_relation_type(self, relation_type): 91 | """ 92 | Get all relations of the specified relation type. 93 | """ 94 | return self.categoryrelation_set.filter(relation_type=relation_type) 95 | 96 | def save(self, *args, **kwargs): 97 | """Save the category.""" 98 | if self.thumbnail: 99 | width, height = get_image_dimensions(self.thumbnail.file) 100 | else: 101 | width, height = None, None 102 | 103 | self.thumbnail_width = width 104 | self.thumbnail_height = height 105 | 106 | super(Category, self).save(*args, **kwargs) 107 | 108 | class Meta(CategoryBase.Meta): 109 | verbose_name = _("category") 110 | verbose_name_plural = _("categories") 111 | 112 | class MPTTMeta: 113 | order_insertion_by = ("order", "name") 114 | 115 | 116 | if RELATIONS: 117 | CATEGORY_RELATION_LIMITS = reduce(lambda x, y: x | y, RELATIONS) 118 | else: 119 | CATEGORY_RELATION_LIMITS = [] 120 | 121 | 122 | class CategoryRelationManager(models.Manager): 123 | """Custom access functions for category relations.""" 124 | 125 | def get_content_type(self, content_type): 126 | """ 127 | Get all the items of the given content type related to this item. 128 | """ 129 | qs = self.get_queryset() 130 | return qs.filter(content_type__name=content_type) 131 | 132 | def get_relation_type(self, relation_type): 133 | """ 134 | Get all the items of the given relationship type related to this item. 135 | """ 136 | qs = self.get_queryset() 137 | return qs.filter(relation_type=relation_type) 138 | 139 | 140 | class CategoryRelation(models.Model): 141 | """Related category item.""" 142 | 143 | category = models.ForeignKey(Category, verbose_name=_("category"), on_delete=models.CASCADE) 144 | content_type = models.ForeignKey( 145 | ContentType, 146 | on_delete=models.CASCADE, 147 | limit_choices_to=CATEGORY_RELATION_LIMITS, 148 | verbose_name=_("content type"), 149 | ) 150 | object_id = models.PositiveIntegerField(verbose_name=_("object id")) 151 | content_object = GenericForeignKey("content_type", "object_id") 152 | relation_type = models.CharField( 153 | verbose_name=_("relation type"), 154 | max_length=200, 155 | blank=True, 156 | null=True, 157 | help_text=_("A generic text field to tag a relation, like 'leadphoto'."), 158 | ) 159 | 160 | objects = CategoryRelationManager() 161 | 162 | def __unicode__(self): 163 | return "CategoryRelation" 164 | -------------------------------------------------------------------------------- /categories/locale/it/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 2 | # This file is distributed under the same license as the PACKAGE package. 3 | # 4 | # Iacopo Spalletti, 2013. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2013-06-05 12:22+0200\n" 10 | "PO-Revision-Date: 2013-06-05 12:48+0200\n" 11 | "Last-Translator: Iacopo Spalletti\n" 12 | "Language-Team: Italian \n" 13 | "Language: it\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 18 | "X-Generator: Lokalize 1.5\n" 19 | 20 | #: admin.py:50 21 | msgid "Meta Data" 22 | msgstr "Metadati" 23 | 24 | #: admin.py:55 25 | msgid "Advanced" 26 | msgstr "Avanzate" 27 | 28 | #: base.py:41 29 | msgid "parent" 30 | msgstr "livello superiore" 31 | 32 | #: base.py:42 33 | msgid "name" 34 | msgstr "nome" 35 | 36 | #: base.py:43 37 | msgid "slug" 38 | msgstr "slug" 39 | 40 | #: base.py:44 41 | msgid "active" 42 | msgstr "attivo" 43 | 44 | #: base.py:104 45 | msgid "The slug must be unique among the items at its level." 46 | msgstr "Lo slug deve essere unico fra gli elementi allo stesso livello." 47 | 48 | #: base.py:114 49 | msgid "You can't set the parent of the item to itself." 50 | msgstr "" 51 | "Non è possibile selezionare come livello superiore di un elemento l'elemento " 52 | "stesso." 53 | 54 | #: base.py:117 55 | msgid "You can't set the parent of the item to a descendant." 56 | msgstr "" 57 | "Non è possibile selezionare come livello superiore di un elemento uno dei " 58 | "suoi discendenti." 59 | 60 | #: base.py:148 61 | msgid "Deactivate selected categories and their children" 62 | msgstr "Disattiva le categorie selezionate e i discendenti" 63 | 64 | #: base.py:161 65 | msgid "Activate selected categories and their children" 66 | msgstr "Attiva le categorie selezionate e i discendenti" 67 | 68 | #: migration.py:21 migration.py:94 69 | msgid "%(dependency) must be installed for this command to work" 70 | msgstr "%(dependency) deve essere installato per far funzionare il comando" 71 | 72 | #: migration.py:46 73 | msgid "Added ForeignKey %(field_name) to %(model_name)" 74 | msgstr "Aggiunta ForeignKey %(field_name) a %(model_name)" 75 | 76 | #: migration.py:52 77 | msgid "ForeignKey %(field_name) to %(model_name) already exists" 78 | msgstr "La ForeignKey %(field_name) a %(model_name) esiste già" 79 | 80 | #: migration.py:69 81 | msgid "Added Many2Many table between %(model_name) and %(category_table)" 82 | msgstr "Aggiunta tabella Many2Many fra %(model_name) e %(category_table)" 83 | 84 | #: migration.py:75 85 | msgid "" 86 | "Many2Many table between %(model_name) and %(category_table) already exists" 87 | msgstr "La tabella Many2Many fra %(model_name) e %(category_table) esiste già" 88 | 89 | #: migration.py:101 90 | msgid "Dropping ForeignKey %(field_name) from %(model_name)" 91 | msgstr "Eliminazione ForeignKey %(field_name) da %(model_name)" 92 | 93 | #: migration.py:112 94 | msgid "Dropping Many2Many table between %(model_name) and %(category_table)" 95 | msgstr "" 96 | "Eliminazione della tabella Many2Many fra %(model_name) e %(category_table)" 97 | 98 | #: models.py:91 models.py:122 99 | msgid "category" 100 | msgstr "categoria" 101 | 102 | #: models.py:92 103 | msgid "categories" 104 | msgstr "categorie" 105 | 106 | #: models.py:124 107 | msgid "content type" 108 | msgstr "content type" 109 | 110 | #: models.py:125 111 | msgid "object id" 112 | msgstr "object id" 113 | 114 | #: models.py:127 115 | msgid "relation type" 116 | msgstr "tipo di relazione" 117 | 118 | #: models.py:131 119 | msgid "A generic text field to tag a relation, like 'leadphoto'." 120 | msgstr "" 121 | "Un campo di testo generico per marcare la relazione, ad es: 'leadphoto'." 122 | 123 | #: registration.py:45 124 | #, python-format 125 | msgid "%(key)s is not a model" 126 | msgstr "%(key)s non è un model" 127 | 128 | #: registration.py:54 registration.py:62 129 | msgid "%(settings)s doesn't recognize the value of %(key)" 130 | msgstr "%(settings)s non riconosce il valore di %(key)" 131 | 132 | #: settings.py:30 133 | msgid "%(transliterator)s must be a callable or a string." 134 | msgstr "%(transliterator)s deve essere un callable o una stringa." 135 | 136 | #: views.py:73 137 | #, python-format 138 | msgid "No %(verbose_name)s found matching the query" 139 | msgstr "Nessun %(verbose_name)s corrispondente alla ricerca" 140 | 141 | #: editor/tree_editor.py:167 142 | msgid "Database error" 143 | msgstr "Errore di database" 144 | 145 | #: editor/tree_editor.py:206 146 | #, python-format 147 | msgid "%(count)s %(name)s was changed successfully." 148 | msgid_plural "%(count)s %(name)s were changed successfully." 149 | msgstr[0] "%(count)s %(name)s modificati." 150 | msgstr[1] "" 151 | 152 | #: editor/tree_editor.py:247 153 | #, python-format 154 | msgid "%(total_count)s selected" 155 | msgid_plural "All %(total_count)s selected" 156 | msgstr[0] "%(total_count)s selezionati" 157 | msgstr[1] "" 158 | 159 | #: editor/tree_editor.py:252 160 | #, python-format 161 | msgid "0 of %(cnt)s selected" 162 | msgstr "selezionati 0 su %(cnt)s" 163 | 164 | #: editor/templates/admin/editor/tree_list_results.html:16 165 | msgid "Remove from sorting" 166 | msgstr "Rimuovi dall'ordinamento" 167 | 168 | #: editor/templates/admin/editor/tree_list_results.html:17 169 | #, python-format 170 | msgid "Sorting priority: %(priority_number)s" 171 | msgstr "Priorità di ordinamento: %(priority_number)s" 172 | 173 | #: editor/templates/admin/editor/tree_list_results.html:18 174 | msgid "Toggle sorting" 175 | msgstr "Inverti ordinamento" 176 | 177 | #: templates/admin/edit_inline/gen_coll_tabular.html:13 178 | msgid "Delete?" 179 | msgstr "Cancella?" 180 | 181 | #: templates/admin/edit_inline/gen_coll_tabular.html:24 182 | msgid "View on site" 183 | msgstr "Vedi sul sito" 184 | -------------------------------------------------------------------------------- /categories/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Olivier Le Brouster, 2015. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2015-05-19 16:53+0200\n" 12 | "PO-Revision-Date: 2015-05-19 16:59+0200\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: fr\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: admin.py:50 22 | msgid "Meta Data" 23 | msgstr "Meta donnée" 24 | 25 | #: admin.py:55 26 | msgid "Advanced" 27 | msgstr "Avancé" 28 | 29 | #: base.py:41 30 | msgid "parent" 31 | msgstr "parent" 32 | 33 | #: base.py:42 34 | msgid "name" 35 | msgstr "nom" 36 | 37 | #: base.py:43 38 | msgid "slug" 39 | msgstr "slug" 40 | 41 | #: base.py:44 42 | msgid "active" 43 | msgstr "actif" 44 | 45 | #: base.py:105 46 | msgid "The slug must be unique among the items at its level." 47 | msgstr "Le slug doit être unique parmi les items d'un même niveau." 48 | 49 | #: base.py:115 50 | msgid "You can't set the parent of the item to itself." 51 | msgstr "Il n'est pas possible choisir l'item lui-même comme parent." 52 | 53 | #: base.py:118 54 | msgid "You can't set the parent of the item to a descendant." 55 | msgstr "Il n'est pas possible de choisir le parent de l'item pour un de ses descendants." 56 | 57 | #: base.py:149 58 | msgid "Deactivate selected categories and their children" 59 | msgstr "Désactiver les catégories sélectionnées et leurs descendants." 60 | 61 | #: base.py:162 62 | msgid "Activate selected categories and their children" 63 | msgstr "Activer les catégories sélectionnées et leurs descendants." 64 | 65 | #: migration.py:21 migration.py:94 66 | msgid "%(dependency) must be installed for this command to work" 67 | msgstr "%(dependency) doit être installée pour que cela fonctionne" 68 | 69 | #: migration.py:46 70 | msgid "Added ForeignKey %(field_name) to %(model_name)" 71 | msgstr "ForeignKey %(field_name) ajoutée à %(model_name)" 72 | 73 | #: migration.py:52 74 | msgid "ForeignKey %(field_name) to %(model_name) already exists" 75 | msgstr "La ForeignKey %(field_name) de %(model_name) existe déjà" 76 | 77 | #: migration.py:69 78 | msgid "Added Many2Many table between %(model_name) and %(category_table)" 79 | msgstr "La table Many2Many entre %(model_name) et %(category_table) a été ajoutée" 80 | 81 | #: migration.py:75 82 | msgid "" 83 | "Many2Many table between %(model_name) and %(category_table) already exists" 84 | msgstr "La table Many2Many entre %(model_name) et %(category_table) existe déjà" 85 | 86 | #: migration.py:101 87 | msgid "Dropping ForeignKey %(field_name) from %(model_name)" 88 | msgstr "Suppression de la ForeignKey %(field_name) de %(model_name)" 89 | 90 | #: migration.py:112 91 | msgid "Dropping Many2Many table between %(model_name) and %(category_table)" 92 | msgstr "Suppression de la table Many2Many entre %(model_name) et %(category_table)" 93 | 94 | #: models.py:91 models.py:122 95 | msgid "category" 96 | msgstr "catégorie" 97 | 98 | #: models.py:92 99 | msgid "categories" 100 | msgstr "catégories" 101 | 102 | #: models.py:124 103 | msgid "content type" 104 | msgstr "type de contenu" 105 | 106 | #: models.py:125 107 | msgid "object id" 108 | msgstr "object id" 109 | 110 | #: models.py:127 111 | msgid "relation type" 112 | msgstr "type de relation" 113 | 114 | #: models.py:131 115 | msgid "A generic text field to tag a relation, like 'leadphoto'." 116 | msgstr "Un champ texte générique pour marquer la relation, comme 'leadphoto'." 117 | 118 | #: registration.py:46 119 | #, python-format 120 | msgid "%(key)s is not a model" 121 | msgstr "%(key)s n'est pas un modèle" 122 | 123 | #: registration.py:55 registration.py:63 124 | #, python-format 125 | msgid "%(settings)s doesn't recognize the value of %(key)s" 126 | msgstr "%(settings)s n'accepte pas la valeure %(key)s" 127 | 128 | #: settings.py:30 129 | msgid "%(transliterator) must be a callable or a string." 130 | msgstr "%(transliterator) doit être un exécutable ou une chaîne." 131 | 132 | #: views.py:73 133 | #, python-format 134 | msgid "No %(verbose_name)s found matching the query" 135 | msgstr "Aucun(e) %(verbose_name)s ne correspond à la requête" 136 | 137 | #: editor/tree_editor.py:167 138 | msgid "Database error" 139 | msgstr "Erreur de base données" 140 | 141 | #: editor/tree_editor.py:206 142 | #, python-format 143 | msgid "%(count)s %(name)s was changed successfully." 144 | msgid_plural "%(count)s %(name)s were changed successfully." 145 | msgstr[0] "%(count)s %(name)s modifié·e avec succès." 146 | msgstr[1] "%(count)s %(name)s modifié·e·s avec succès." 147 | 148 | #: editor/tree_editor.py:247 149 | #, python-format 150 | msgid "%(total_count)s selected" 151 | msgid_plural "All %(total_count)s selected" 152 | msgstr[0] "%(total_count)s sélectionné·e" 153 | msgstr[1] "%(total_count)s sélectionné·e·s" 154 | 155 | #: editor/tree_editor.py:252 156 | #, python-format 157 | msgid "0 of %(cnt)s selected" 158 | msgstr "0 sur %(cnt)s sélectionné·e·s" 159 | 160 | #: editor/templates/admin/editor/tree_list_results.html:16 161 | msgid "Remove from sorting" 162 | msgstr "Enlever du tri" 163 | 164 | #: editor/templates/admin/editor/tree_list_results.html:17 165 | #, python-format 166 | msgid "Sorting priority: %(priority_number)s" 167 | msgstr "Priorité de tri : %(priority_number)s" 168 | 169 | #: editor/templates/admin/editor/tree_list_results.html:18 170 | msgid "Toggle sorting" 171 | msgstr "Inverser le tri" 172 | 173 | #: templates/admin/edit_inline/gen_coll_tabular.html:13 174 | msgid "Delete?" 175 | msgstr "Supprimer ?" 176 | 177 | #: templates/admin/edit_inline/gen_coll_tabular.html:24 178 | msgid "View on site" 179 | msgstr "Voir sur le site" 180 | -------------------------------------------------------------------------------- /categories/views.py: -------------------------------------------------------------------------------- 1 | """View functions for categories.""" 2 | 3 | from typing import Optional 4 | 5 | from django.http import Http404, HttpResponse 6 | from django.shortcuts import get_object_or_404 7 | from django.template.loader import select_template 8 | from django.utils.translation import gettext_lazy as _ 9 | from django.views.generic import DetailView, ListView 10 | 11 | from .models import Category 12 | 13 | 14 | def category_detail( 15 | request, path, template_name="categories/category_detail.html", extra_context: Optional[dict] = None 16 | ): 17 | """Render the detail page for a category.""" 18 | extra_context = extra_context or {} 19 | path_items = path.strip("/").split("/") 20 | if len(path_items) >= 2: 21 | category = get_object_or_404( 22 | Category, slug__iexact=path_items[-1], level=len(path_items) - 1, parent__slug__iexact=path_items[-2] 23 | ) 24 | else: 25 | category = get_object_or_404(Category, slug__iexact=path_items[-1], level=len(path_items) - 1) 26 | 27 | templates = [] 28 | while path_items: 29 | templates.append("categories/%s.html" % "_".join(path_items)) 30 | path_items.pop() 31 | templates.append(template_name) 32 | 33 | context = {"category": category} 34 | if extra_context: 35 | context.update(extra_context) 36 | return HttpResponse(select_template(templates).render(context)) 37 | 38 | 39 | def get_category_for_path(path, queryset=Category.objects.all()): 40 | """Return the category for a path.""" 41 | path_items = path.strip("/").split("/") 42 | if len(path_items) >= 2: 43 | queryset = queryset.filter( 44 | slug__iexact=path_items[-1], level=len(path_items) - 1, parent__slug__iexact=path_items[-2] 45 | ) 46 | else: 47 | queryset = queryset.filter(slug__iexact=path_items[-1], level=len(path_items) - 1) 48 | return queryset.get() 49 | 50 | 51 | class CategoryDetailView(DetailView): 52 | """Detail view for a category.""" 53 | 54 | model = Category 55 | path_field = "path" 56 | 57 | def get_object(self, **kwargs): 58 | """Get the category.""" 59 | if self.path_field not in self.kwargs: 60 | raise AttributeError( 61 | "Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field) 62 | ) 63 | if self.queryset is None: 64 | queryset = self.get_queryset() 65 | try: 66 | return get_category_for_path(self.kwargs[self.path_field], self.model.objects.all()) 67 | except Category.DoesNotExist: 68 | raise Http404( 69 | _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} 70 | ) 71 | 72 | def get_template_names(self): 73 | """Get the potential template names.""" 74 | names = [] 75 | path_items = self.kwargs[self.path_field].strip("/").split("/") 76 | while path_items: 77 | names.append("categories/%s.html" % "_".join(path_items)) 78 | path_items.pop() 79 | names.extend(super(CategoryDetailView, self).get_template_names()) 80 | return names 81 | 82 | 83 | class CategoryRelatedDetail(DetailView): 84 | """Detailed view for a category relation.""" 85 | 86 | path_field = "category_path" 87 | object_name_field = None 88 | 89 | def get_object(self, **kwargs): 90 | """Get the object to render.""" 91 | if self.path_field not in self.kwargs: 92 | raise AttributeError( 93 | "Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field) 94 | ) 95 | queryset = super(CategoryRelatedDetail, self).get_queryset() 96 | try: 97 | category = get_category_for_path(self.kwargs[self.path_field]) 98 | except Category.DoesNotExist: 99 | raise Http404( 100 | _("No %(verbose_name)s found matching the query") % {"verbose_name": queryset.model._meta.verbose_name} 101 | ) 102 | return queryset.get(category=category) 103 | 104 | def get_template_names(self): 105 | """Get all template names.""" 106 | names = [] 107 | opts = self.object._meta 108 | path_items = self.kwargs[self.path_field].strip("/").split("/") 109 | if self.object_name_field: 110 | path_items.append(getattr(self.object, self.object_name_field)) 111 | while path_items: 112 | names.append( 113 | "%s/category_%s_%s%s.html" 114 | % (opts.app_label, "_".join(path_items), opts.object_name.lower(), self.template_name_suffix) 115 | ) 116 | path_items.pop() 117 | names.append("%s/category_%s%s.html" % (opts.app_label, opts.object_name.lower(), self.template_name_suffix)) 118 | names.extend(super(CategoryRelatedDetail, self).get_template_names()) 119 | return names 120 | 121 | 122 | class CategoryRelatedList(ListView): 123 | """List related category items.""" 124 | 125 | path_field = "category_path" 126 | 127 | def get_queryset(self): 128 | """Get the list of items.""" 129 | if self.path_field not in self.kwargs: 130 | raise AttributeError( 131 | "Category detail view %s must be called with " "a %s." % (self.__class__.__name__, self.path_field) 132 | ) 133 | queryset = super(CategoryRelatedList, self).get_queryset() 134 | category = get_category_for_path(self.kwargs[self.path_field]) 135 | return queryset.filter(category=category) 136 | 137 | def get_template_names(self): 138 | """Get the template names.""" 139 | names = [] 140 | if hasattr(self.object_list, "model"): 141 | opts = self.object_list.model._meta 142 | path_items = self.kwargs[self.path_field].strip("/").split("/") 143 | while path_items: 144 | names.append( 145 | "%s/category_%s_%s%s.html" 146 | % (opts.app_label, "_".join(path_items), opts.object_name.lower(), self.template_name_suffix) 147 | ) 148 | path_items.pop() 149 | names.append( 150 | "%s/category_%s%s.html" % (opts.app_label, opts.object_name.lower(), self.template_name_suffix) 151 | ) 152 | names.extend(super(CategoryRelatedList, self).get_template_names()) 153 | return names 154 | -------------------------------------------------------------------------------- /categories/fixtures/genres.txt: -------------------------------------------------------------------------------- 1 | Avant-garde 2 | Experimental music 3 | Minimalist music 4 | Lo-fi 5 | Country 6 | Alternative country 7 | Americana 8 | Country pop 9 | Nashville sound/Countrypolitan 10 | Urban Cowboy 11 | Country rock 12 | Honky tonk 13 | Bakersfield Sound 14 | Progressive country 15 | Neotraditional country 16 | Outlaw country 17 | Rockabilly 18 | Traditional country 19 | Bluegrass 20 | Progressive bluegrass 21 | Traditional bluegrass 22 | Close harmony 23 | String band 24 | Western 25 | Western swing 26 | Jazz 27 | Acid jazz 28 | Asian American jazz 29 | Avant-garde jazz 30 | Bebop 31 | Big band 32 | Crossover jazz 33 | Dixieland 34 | Calypso jazz 35 | Chamber jazz 36 | Cool jazz 37 | Free jazz 38 | Gypsy jazz 39 | Hard bop 40 | Jazz blues 41 | Jazz-funk 42 | Jazz fusion 43 | Jazz rap 44 | Latin jazz 45 | Mainstream jazz 46 | Mini-jazz 47 | Modal jazz 48 | M-Base 49 | Nu jazz 50 | Smooth jazz 51 | Soul jazz 52 | Swing 53 | Trad jazz 54 | West Coast jazz 55 | Blues 56 | Boogie-woogie 57 | Country blues 58 | Delta blues 59 | Electric blues 60 | Jump blues 61 | Easy listening 62 | Background music 63 | Beautiful music 64 | Elevator music 65 | Furniture music 66 | Lounge music 67 | Middle of the road 68 | Electronic 69 | Ambient 70 | Dark ambient 71 | Breakbeat 72 | Dance 73 | Big beat 74 | Dubstep 75 | Drum and bass 76 | Darkcore 77 | Electroclash 78 | Eurodance 79 | Gabba 80 | Garage 81 | Goa trance 82 | Psychedelic trance 83 | Happy hardcore 84 | Hardcore techno 85 | Hi-NRG 86 | House 87 | Acid house 88 | Ambient house 89 | Italo house 90 | Kwaito 91 | IDM 92 | Spacesynth 93 | Trance 94 | Acid 95 | Classic 96 | Euro 97 | Hard 98 | Hardstyle 99 | Progressive trance 100 | Tech 101 | Uplifting 102 | Vocal 103 | Electro 104 | Electronica 105 | Folktronica 106 | Rave 107 | Techno 108 | Acid breaks 109 | Trip hop 110 | Downtempo 111 | Glitch 112 | Industrial music 113 | Progressive electronic 114 | Hip hop/Rap music 115 | Australian hip hop 116 | Canadian hip hop 117 | Crunk 118 | Dirty rap/Pornocore 119 | East Coast hip hop 120 | Gangsta rap 121 | G-funk 122 | Grime 123 | Latin rap 124 | Chicano rap 125 | Miami bass 126 | Midwest hip hop 127 | Political hip hop 128 | Southern hip hop 129 | Turntablism 130 | West Coast hip hop 131 | Latin 132 | Bossa Nova 133 | Mambo 134 | Merengue 135 | Música Popular Brasileira 136 | Reggaeton 137 | Salsa 138 | Samba 139 | Tejano 140 | Tropicalismo 141 | Zouk 142 | Pop 143 | Adult contemporary music 144 | Adult oriented pop music 145 | Afropop 146 | Arab pop 147 | Austropop 148 | Baroque pop 149 | Brazilian pop 150 | Bubblegum pop 151 | Chinese pop 152 | Contemporary Christian 153 | Country pop 154 | Dance-pop 155 | Disco 156 | Disco polo 157 | Electropop/Technopop 158 | Eurobeat 159 | Euro disco 160 | Europop 161 | French pop 162 | Greek Laïkó pop 163 | Hindi pop 164 | Hong Kong and Cantonese pop 165 | Hong Kong English pop 166 | House-pop 167 | Indonesian pop 168 | Italo dance 169 | Italo disco 170 | Jangle pop 171 | Japanese pop 172 | Korean pop 173 | Latin pop 174 | Levenslied 175 | Louisiana swamp pop 176 | Mandarin pop 177 | Manufactured pop 178 | Mexican pop 179 | Nederpop 180 | New romantic 181 | Noise pop 182 | Operatic pop 183 | Persian pop 184 | Pop folk 185 | Pop rap 186 | Psychedelic pop 187 | Russian pop 188 | Schlager 189 | Sophisti-pop 190 | Space age pop 191 | Sunshine pop 192 | Surf pop 193 | Synthpop 194 | Taiwanese pop 195 | Teen pop 196 | Thai pop 197 | Traditional pop music 198 | Turkish pop 199 | US pop 200 | Vispop 201 | Wonky Pop 202 | Yugoslav pop 203 | Modern folk 204 | Indie folk 205 | Neofolk 206 | Progressive folk 207 | Reggae 208 | 2 tone 209 | Dancehall 210 | Dub 211 | Lovers rock 212 | Ragga 213 | Reggaefusion 214 | Ska 215 | Rhythm and blues 216 | Contemporary R&B 217 | Doo wop 218 | Funk 219 | Deep Funk 220 | Go-go 221 | P-Funk 222 | New jack swing 223 | Soul 224 | Hip hop soul 225 | Northern soul 226 | Neo soul 227 | Urban contemporary 228 | Rock 229 | Alternative rock 230 | Britpop 231 | Dream pop 232 | Emo 233 | Gothic rock 234 | Grunge 235 | Post-grunge 236 | Indie rock 237 | C86 238 | Industrial rock 239 | Indie pop 240 | Madchester 241 | Post-rock 242 | Shoegazing 243 | Blues-rock 244 | C-Rock 245 | Dark cabaret 246 | Desert rock 247 | Folk rock 248 | Garage rock 249 | Glam rock 250 | Hard rock 251 | Heavy metal 252 | Black metal 253 | Death metal 254 | Doom metal 255 | Folk metal 256 | Glam metal 257 | Gothic metal 258 | Grindcore 259 | Groove metal 260 | Industrial metal 261 | Metalcore 262 | Nu metal 263 | Power metal 264 | Progressive metal 265 | Rap metal 266 | Sludge metal 267 | Speed metal 268 | Symphonic metal 269 | Thrash metal 270 | J-Rock 271 | New Wave 272 | Paisley Underground 273 | Power pop 274 | Progressive rock 275 | Psychedelic rock 276 | Acid rock 277 | Punk rock 278 | Anarcho punk 279 | Crust punk 280 | Deathrock 281 | Hardcore punk 282 | Post-hardcore 283 | Pop punk 284 | Post-punk 285 | Psychobilly 286 | Rap rock 287 | Rapcore 288 | Rock and roll 289 | Soft rock 290 | Southern rock 291 | Surf rock 292 | World 293 | Worldbeat 294 | Afrobeat 295 | -------------------------------------------------------------------------------- /categories/editor/templatetags/admin_tree_list_tags.py: -------------------------------------------------------------------------------- 1 | """Template tags used to render the tree editor.""" 2 | 3 | from django.contrib.admin.templatetags.admin_list import _boolean_icon, result_headers 4 | from django.contrib.admin.utils import lookup_field 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.db import models 7 | from django.template import Library 8 | from django.utils.encoding import force_str, smart_str 9 | from django.utils.html import conditional_escape, escape, escapejs, format_html 10 | from django.utils.safestring import mark_safe 11 | 12 | from categories.editor import settings 13 | from categories.editor.utils import display_for_field 14 | 15 | register = Library() 16 | 17 | TREE_LIST_RESULTS_TEMPLATE = "admin/editor/tree_list_results.html" 18 | if settings.IS_GRAPPELLI_INSTALLED: 19 | TREE_LIST_RESULTS_TEMPLATE = "admin/editor/grappelli_tree_list_results.html" 20 | 21 | 22 | def get_empty_value_display(cl): 23 | """Get the value to display when empty.""" 24 | if hasattr(cl.model_admin, "get_empty_value_display"): 25 | return cl.model_admin.get_empty_value_display() 26 | 27 | # Django < 1.9 28 | from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE 29 | 30 | return EMPTY_CHANGELIST_VALUE 31 | 32 | 33 | def items_for_tree_result(cl, result, form): 34 | """ 35 | Generates the actual list of data. 36 | """ 37 | first = True 38 | pk = cl.lookup_opts.pk.attname 39 | for field_name in cl.list_display: 40 | row_class = "" 41 | try: 42 | f, attr, value = lookup_field(field_name, result, cl.model_admin) 43 | except (AttributeError, ObjectDoesNotExist): 44 | result_repr = get_empty_value_display(cl) 45 | else: 46 | if f is None: 47 | allow_tags = getattr(attr, "allow_tags", False) 48 | boolean = getattr(attr, "boolean", False) 49 | if boolean: 50 | allow_tags = True 51 | result_repr = _boolean_icon(value) 52 | else: 53 | result_repr = smart_str(value) 54 | # Strip HTML tags in the resulting text, except if the 55 | # function has an "allow_tags" attribute set to True. 56 | if not allow_tags: 57 | result_repr = escape(result_repr) 58 | else: 59 | result_repr = mark_safe(result_repr) 60 | else: 61 | if value is None: 62 | result_repr = get_empty_value_display(cl) 63 | if hasattr(f, "rel") and isinstance(f.rel, models.ManyToOneRel): 64 | result_repr = escape(getattr(result, f.name)) 65 | else: 66 | result_repr = display_for_field(value, f, "") 67 | if isinstance(f, models.DateField) or isinstance(f, models.TimeField): 68 | row_class = ' class="nowrap"' 69 | 70 | if force_str(result_repr) == "": 71 | result_repr = mark_safe(" ") 72 | # If list_display_links not defined, add the link tag to the first field 73 | if (first and not cl.list_display_links) or field_name in cl.list_display_links: 74 | table_tag = {True: "th", False: "td"}[first] 75 | 76 | url = cl.url_for_result(result) 77 | # Convert the pk to something that can be used in Javascript. 78 | # Problem cases are long ints (23L) and non-ASCII strings. 79 | if cl.to_field: 80 | attr = str(cl.to_field) 81 | else: 82 | attr = pk 83 | value = result.serializable_value(attr) 84 | result_id = repr(force_str(value))[1:] 85 | first = False 86 | result_id = escapejs(value) 87 | yield mark_safe( 88 | format_html( 89 | smart_str('<{}{}>{}'), 90 | table_tag, 91 | row_class, 92 | url, 93 | ( 94 | format_html( 95 | ' onclick="opener.dismissRelatedLookupPopup(window, ' ''{}'); return false;"', 96 | result_id, 97 | ) 98 | if cl.is_popup 99 | else "" 100 | ), 101 | result_repr, 102 | table_tag, 103 | ) 104 | ) 105 | 106 | else: 107 | # By default the fields come from ModelAdmin.list_editable, but if we pull 108 | # the fields out of the form instead of list_editable custom admins 109 | # can provide fields on a per request basis 110 | if form and field_name in form.fields: 111 | bf = form[field_name] 112 | result_repr = mark_safe(force_str(bf.errors) + force_str(bf)) 113 | else: 114 | result_repr = conditional_escape(result_repr) 115 | yield mark_safe(smart_str("%s" % (row_class, result_repr))) 116 | if form and not form[cl.model._meta.pk.name].is_hidden: 117 | yield mark_safe(smart_str("%s" % force_str(form[cl.model._meta.pk.name]))) 118 | 119 | 120 | class TreeList(list): 121 | """A list subclass for tree result.""" 122 | 123 | pass 124 | 125 | 126 | def tree_results(cl): 127 | """Generates a list of results for the tree.""" 128 | if cl.formset: 129 | for res, form in zip(cl.result_list, cl.formset.forms): 130 | result = TreeList(items_for_tree_result(cl, res, form)) 131 | if hasattr(res, "pk"): 132 | result.pk = res.pk 133 | if res.parent: 134 | result.parent_pk = res.parent.pk 135 | else: 136 | res.parent_pk = None 137 | yield result 138 | else: 139 | for res in cl.result_list: 140 | result = TreeList(items_for_tree_result(cl, res, None)) 141 | if hasattr(res, "pk"): 142 | result.pk = res.pk 143 | if res.parent: 144 | result.parent_pk = res.parent.pk 145 | else: 146 | res.parent_pk = None 147 | yield result 148 | 149 | 150 | def result_tree_list(cl): 151 | """ 152 | Displays the headers and data list together. 153 | """ 154 | result = {"cl": cl, "result_headers": list(result_headers(cl)), "results": list(tree_results(cl))} 155 | return result 156 | 157 | 158 | result_tree_list = register.inclusion_tag(TREE_LIST_RESULTS_TEMPLATE)(result_tree_list) 159 | --------------------------------------------------------------------------------