django-icv-taxonomy
v1.3.1 on PyPI
Cross-domain taxonomy, vocabularies, and tagging for Django: hierarchical and flat term trees, generic object tagging, and SKOS-style term relationships.
Cross-domain taxonomy, vocabularies, and tagging for Django: hierarchical and flat term trees, generic object tagging, typed M2M join tables, and SKOS-style term relationships.
Why Does This Exist?
Most Django projects invent their own tagging and categorisation from scratch. The result is a different pattern per app: one uses a CharField, another a flat M2M, a third builds a category tree, and none of them talk to each other.
django-icv-taxonomy provides a single, shared layer. One Vocabulary represents "Genres", another represents "Topics", another "Tags". Any model in any app can be associated with terms from any vocabulary, via a single generic association table or a typed M2M join table for high-throughput paths. Term hierarchies are powered by django-icv-tree (materialised-path trees), which makes ancestor/descendant queries fast without recursive SQL.
Features
Vocabularies
- Three structural types: flat (simple tag lists), hierarchical (trees), faceted (multi-axis classification)
- Open or closed: closed vocabularies reject new terms; useful for controlled vocabularies
- Per-vocabulary cardinality: single-term or multi-term per object
- Optional max depth limit on hierarchical vocabularies
- Type is immutable once terms exist
Terms
- Hierarchical tree structure via django-icv-tree (materialised paths, fast subtree queries)
- Slug unique within vocabulary, auto-generated with collision resolution
- Arbitrary JSON metadata field
- Soft deactivation without data loss
- Business-rule validation on create, update, and move
Generic Tagging
- Tag any Django model with any term via
TermAssociation(DjangoGenericForeignKey) - Supports integer, UUID, and string primary keys
- Ordered associations: terms on an object carry a display order
- Cardinality enforcement for single-term vocabularies
cleanup_orphaned_associations()detects and removes stale rows when tagged objects are deleted
Typed M2M Factory
create_term_m2m()generates a typed, direct-FK join table for any model- Avoids GenericForeignKey overhead on high-read paths
- Same ordering and deduplication semantics as generic associations
Term Relationships
- SKOS-aligned semantic links:
synonym,related,see_also,broader,narrower - Bidirectional types (
synonym,related) automatically create the reciprocal record - Self-relationships forbidden
Term Lifecycle
merge_terms()transfers associations, relationships, and optionally re-parents children; atomic, withon_commitsignalmove_term()delegates to icv-tree'smove_to()service; emitsterm_moved- Import/export: JSON-serialisable round-trip, idempotent by slug
Developer Experience
- Swappable
VocabularyandTermmodels (ICV_TAXONOMY_VOCABULARY_MODEL/ICV_TAXONOMY_TERM_MODEL) - Configurable model base through
ICV_TAXONOMY_BASE_MODELorICV_BASE_MODEL - System checks validate settings at startup
- Django signals for every lifecycle event: vocabulary, term, and tagging
Requirements
- Python 3.11+
- Django 5.2+
- django-icv-tree >= 1.0.0
django.contrib.contenttypes(for generic tagging)
django-icv-taxonomy ships its own standalone _compat.BaseModel, which
provides the default UUID primary key and created_at/updated_at timestamps.
django-icv-core is optional. A stack consumer that uses its base sets one of
the following settings explicitly; installing django-icv-core alone does not
change a taxonomy model's base:
ICV_BASE_MODEL = "icv_core.models.BaseModel"
# or, for this package only:
ICV_TAXONOMY_BASE_MODEL = "myapp.models.TaxonomyBase"
Installation
pip install django-icv-taxonomy
Add to INSTALLED_APPS:
INSTALLED_APPS = [
...
"django.contrib.contenttypes", # required for generic tagging
"icv_tree",
"icv_taxonomy",
...
]
Run migrations:
python manage.py migrate
Upgrading an existing install
UPGRADING.md carries cumulative upgrade notes, indexed by the version you are moving from. Read it when crossing more than one release, since the CHANGELOG entry for the version you are moving to only covers the step before it.
Quick Start
1. Create a vocabulary
from icv_taxonomy.services import create_vocabulary, create_term
# A flat tag vocabulary: open, multi-term per object
tags = create_vocabulary(name="Tags", vocabulary_type="flat")
# A hierarchical category tree: open, single category per object
categories = create_vocabulary(
name="Categories",
vocabulary_type="hierarchical",
allow_multiple=False,
)
2. Add terms
# Flat vocabulary: no parent
python_tag = create_term(vocabulary=tags, name="Python")
django_tag = create_term(vocabulary=tags, name="Django")
# Hierarchical vocabulary: nested
tech = create_term(vocabulary=categories, name="Technology")
web = create_term(vocabulary=categories, name="Web Development", parent=tech)
backend = create_term(vocabulary=categories, name="Backend", parent=web)
3. Tag objects
from icv_taxonomy.services import tag_object, get_terms_for_object, untag_object
article = Article.objects.get(pk=1)
tag_object(python_tag, article)
tag_object(django_tag, article)
tag_object(backend, article)
# Retrieve all terms on the article
terms = get_terms_for_object(article)
# Retrieve only terms from a specific vocabulary
category_terms = get_terms_for_object(article, vocabulary_slug="categories")
# Remove a tag
untag_object(python_tag, article)
4. Query objects by term
from icv_taxonomy.services import get_objects_for_term
# Returns a typed QuerySet of Article
articles = get_objects_for_term(django_tag, model_class=Article)
# Returns a heterogeneous list across all model types
all_tagged = get_objects_for_term(django_tag)
Vocabulary Types
| Type | Constraint | Use Case |
|---|---|---|
flat |
Terms must be root-level (no parent) | Tags, keywords, simple lists |
hierarchical |
Terms form a tree; depth optionally limited | Category trees, navigation, taxonomies |
faceted |
Multi-root tree; no structural restriction | Multi-axis classification (colour + size + material) |
# Flat: terms are root-level only
colours = create_vocabulary(name="Colours", vocabulary_type="flat")
# Hierarchical with a depth limit
locations = create_vocabulary(
name="Locations",
vocabulary_type="hierarchical",
max_depth=2, # continent → country → region, no deeper
)
# Faceted: unrestricted structure, multiple classification axes
attributes = create_vocabulary(name="Attributes", vocabulary_type="faceted")
Typed M2M Join Tables
For models that are tagged at high volume, the GenericForeignKey path can add overhead. create_term_m2m() generates a typed join table with direct FK joins.
# In your app's models.py
from django.db import models
from icv_taxonomy.models import create_term_m2m
class Article(models.Model):
title = models.CharField(max_length=255)
# Generate an abstract join model, then make it concrete
ArticleTermBase = create_term_m2m(Article, related_name="article_terms")
class ArticleTerm(ArticleTermBase):
class Meta(ArticleTermBase.Meta):
app_label = "blog"
db_table = "blog_articleterm"
Query via the typed through model:
from icv_taxonomy.services import get_terms_for_object_typed
terms = get_terms_for_object_typed(article, through_model=ArticleTerm)
terms_in_vocab = get_terms_for_object_typed(article, through_model=ArticleTerm, vocabulary=tags)
Term Relationships
from icv_taxonomy.services import add_relationship, remove_relationship, get_related_terms, get_synonyms
# Synonym: bidirectional; creates both directions automatically
add_relationship(python_tag, py_tag, "synonym")
# Hierarchical semantic links
add_relationship(django_tag, python_tag, "broader")
add_relationship(python_tag, django_tag, "narrower")
# Related: also bidirectional
add_relationship(django_tag, flask_tag, "related")
# See also: directional only
add_relationship(django_tag, drf_tag, "see_also")
# Query relationships
related = get_related_terms(django_tag) # all outgoing
narrower = get_related_terms(django_tag, "narrower") # filtered
synonyms = get_synonyms(python_tag) # shortcut
# Remove
remove_relationship(django_tag, flask_tag, "related") # removes both directions
Term Lifecycle
Merge terms
Transfer all associations and relationships from one term to another, then deactivate the source. Wrapped in transaction.atomic.
from icv_taxonomy.services import merge_terms
result = merge_terms(source=old_term, target=new_term)
# result = {"associations_transferred": 42, "relationships_transferred": 5, "children_reparented": 0}
# Source has children: re-parent them to target before merging
result = merge_terms(source=old_term, target=new_term, children_strategy="reparent")
# Re-parent children up to source's parent instead
result = merge_terms(source=old_term, target=new_term, children_strategy="reparent_up")
Move terms
from icv_taxonomy.services import move_term
# Move term and its entire subtree under a new parent
move_term(term=backend, target=api, position="last-child")
# Position options: "first-child", "last-child", "left", "right"
Deactivate without deleting
from icv_taxonomy.services import deactivate_term
deactivate_term(old_tag)
# is_active=False; existing associations are preserved
# Inactive terms cannot be used for new tagging operations
Import and Export
Round-trip a vocabulary and all its terms and relationships as a JSON-serialisable dict.
from icv_taxonomy.services import export_vocabulary, import_vocabulary
# Export
data = export_vocabulary(categories)
data = export_vocabulary(categories, include_inactive=True)
# Import: creates vocabulary if slug absent, updates if present (idempotent)
result = import_vocabulary(data)
# result = {"created": 12, "updated": 3, "skipped": 0}
# Import into an existing vocabulary
result = import_vocabulary(data, vocabulary=existing_vocab)
The export format:
{
"name": "Categories",
"slug": "categories",
"vocabulary_type": "hierarchical",
"is_open": true,
"allow_multiple": false,
"max_depth": null,
"terms": [
{"name": "Technology", "slug": "technology", "parent_slug": null, "is_active": true},
{"name": "Web Development", "slug": "web-development", "parent_slug": "technology", "is_active": true}
],
"relationships": []
}
Bulk Tagging
from icv_taxonomy.services import bulk_tag_objects
articles = list(Article.objects.filter(topic="python"))
bulk_tag_objects(python_tag, articles)
# Suppress signals for large batches (caller handles downstream updates)
bulk_tag_objects(python_tag, articles, emit_signals=False)
Orphan Cleanup
Generic FK associations have no database-level cascade. When a tagged object is deleted, its TermAssociation rows persist. Clean them up with:
from icv_taxonomy.services import cleanup_orphaned_associations
# Check all content types
result = cleanup_orphaned_associations()
# result = {"checked": 500, "orphaned": 12, "removed": 12}
# Restrict to a single model
result = cleanup_orphaned_associations(model_class=Article)
# Dry run: report without deleting
result = cleanup_orphaned_associations(dry_run=True)
Because there is no database cascade, you are responsible for running this periodically: orphans accumulate silently otherwise. Two automatic options:
# Management command (also runs as part of icv_taxonomy_check --fix)
python manage.py icv_taxonomy_check --fix
# Celery beat: schedule the bundled task (Celery optional; the task is a
# no-op-decorated plain function when Celery is absent and can be called directly)
CELERY_BEAT_SCHEDULE = {
"icv-taxonomy-orphan-cleanup": {
"task": "icv_taxonomy.tasks.cleanup_orphaned_associations_task",
"schedule": 24 * 60 * 60, # daily
},
}
# Or invoke directly / restrict to one model:
from icv_taxonomy.tasks import cleanup_orphaned_associations_task
cleanup_orphaned_associations_task.delay() # all content types
cleanup_orphaned_associations_task.delay("app_label.Model") # one model
Models Reference
Vocabulary
| Field | Type | Default | Description |
|---|---|---|---|
name |
CharField(255) | (required) | Human-readable name. Globally unique. |
slug |
SlugField(255) | auto | URL-safe identifier. Auto-generated from name if blank. |
description |
TextField | "" |
Optional description of the vocabulary's purpose. |
vocabulary_type |
CharField | "flat" |
One of flat, hierarchical, faceted. Immutable once terms exist. |
is_open |
BooleanField | True |
When False, no new terms may be added. |
allow_multiple |
BooleanField | True |
When False, each object may have at most one term from this vocabulary. |
max_depth |
PositiveIntegerField | None |
Maximum term depth (zero-based). Null = unlimited. |
metadata |
JSONField | {} |
Arbitrary key/value pairs. |
is_active |
BooleanField | True |
Inactive vocabularies are hidden from default querysets. |
Managers: objects (active only) · all_objects (unfiltered)
Term
Extends icv_tree.TreeNode: inherits path, depth, order, parent.
| Field | Type | Default | Description |
|---|---|---|---|
vocabulary |
ForeignKey | (required) | The vocabulary this term belongs to. Immutable after creation. |
name |
CharField(255) | (required) | Human-readable label. |
slug |
SlugField(255) | auto | URL-safe identifier, unique within vocabulary. Auto-generated from name if blank. |
description |
TextField | "" |
Optional description. |
is_active |
BooleanField | True |
Inactive terms are hidden from default querysets and blocked from new tagging. |
metadata |
JSONField | {} |
Arbitrary key/value pairs. |
path |
(from TreeNode) | (managed) | Materialised path string. |
depth |
(from TreeNode) | (managed) | Zero-based depth in tree. |
parent |
(from TreeNode) | None |
Parent term, or None for root terms. |
Managers: objects (active only, tree-aware) · all_objects (unfiltered tree manager)
TreeQuerySet methods (inherited from icv-tree):
Term.objects.active() # active terms only
Term.objects.descendants_of(term) # all descendants
Term.objects.ancestors_of(term) # all ancestors
Term.objects.children_of(term) # direct children
Term.objects.roots() # root-level terms
TermRelationship
| Field | Type | Description |
|---|---|---|
term_from |
ForeignKey(Term) | Source term. |
term_to |
ForeignKey(Term) | Target term. |
relationship_type |
CharField | One of synonym, related, see_also, broader, narrower. |
metadata |
JSONField | Arbitrary key/value pairs. |
Unique together: (term_from, term_to, relationship_type).
TermAssociation
| Field | Type | Description |
|---|---|---|
term |
ForeignKey(Term) | The term applied as a tag. |
content_type |
ForeignKey(ContentType) | Content type of the tagged object. |
object_id |
CharField(255) | PK of the tagged object (stored as string; supports int, UUID, and other types). |
content_object |
GenericForeignKey | Resolved reference to the tagged object. |
order |
PositiveIntegerField | Display order among this object's term associations. |
created_at |
DateTimeField | Timestamp when the association was created. |
Unique together: (term, content_type, object_id).
Services Reference
All public functions are importable from icv_taxonomy.services.
Vocabulary Management
| Function | Description |
|---|---|
create_vocabulary(name, slug="", vocabulary_type="flat", **kwargs) |
Create a new vocabulary. Slug auto-generated with collision resolution. |
update_vocabulary(vocabulary, **kwargs) |
Update mutable fields. Guards against type change when terms exist. |
delete_vocabulary(vocabulary) |
Delete a vocabulary and all cascaded terms and associations. |
Term Management
| Function | Description |
|---|---|
create_term(vocabulary, name, slug="", parent=None, **kwargs) |
Create a term. Validates closed vocabulary, flat constraint, max depth, vocabulary boundary. |
update_term(term, **kwargs) |
Update mutable term fields. Guards against vocabulary change. |
move_term(term, target, position="last-child") |
Move term and subtree. Position: first-child, last-child, left, right. |
merge_terms(source, target, children_strategy="refuse") |
Merge source into target. Strategies: refuse, reparent, reparent_up. Returns counts dict. |
deactivate_term(term) |
Set is_active=False. Preserves existing associations. |
delete_term(term) |
Hard-delete term and all cascaded descendants, associations, and relationships. |
Tagging
| Function | Description |
|---|---|
tag_object(term, obj) |
Associate a term with an object. Validates activity, uniqueness, cardinality. |
untag_object(term, obj) |
Remove a term from an object. |
replace_term_on_object(obj, old_term, new_term) |
Atomic swap: untag old, tag new. |
bulk_tag_objects(term, objects, emit_signals=True) |
Tag a list of objects in bulk via bulk_create. Skips duplicates. |
get_terms_for_object(obj, vocabulary=None, vocabulary_slug=None) |
QuerySet of terms on an object, optionally filtered by vocabulary. |
get_objects_for_term(term, model_class=None) |
Typed QuerySet (if model_class given) or heterogeneous list of tagged objects. |
get_terms_for_object_typed(obj, through_model, vocabulary=None) |
Terms via typed M2M through table; no GenericFK overhead. |
cleanup_orphaned_associations(model_class=None, dry_run=False) |
Remove associations whose objects no longer exist. Returns counts dict. |
Term Relationships
| Function | Description |
|---|---|
add_relationship(term_from, term_to, relationship_type) |
Create a typed relationship. Bidirectional types create both directions. |
remove_relationship(term_from, term_to, relationship_type) |
Remove a relationship. Bidirectional types remove both directions. |
get_related_terms(term, relationship_type=None) |
QuerySet of terms on the outgoing side of matching relationships. |
get_synonyms(term) |
Shortcut for get_related_terms(term, "synonym"). |
Import / Export
| Function | Description |
|---|---|
export_vocabulary(vocabulary, include_inactive=False) |
Serialise vocabulary and terms to a JSON-serialisable dict. |
import_vocabulary(data, vocabulary=None) |
Import from dict. Idempotent by slug. Returns created, updated, skipped-term, and skipped-relationship counts. |
Settings Reference
| Setting | Default | Description |
|---|---|---|
ICV_TAXONOMY_VOCABULARY_MODEL |
"icv_taxonomy.Vocabulary" |
Swappable vocabulary model. Dotted app_label.ModelName. |
ICV_TAXONOMY_TERM_MODEL |
"icv_taxonomy.Term" |
Swappable term model. Dotted app_label.ModelName. |
ICV_TAXONOMY_BASE_MODEL |
ICV_BASE_MODEL, then "icv_taxonomy._compat.BaseModel" |
Abstract base for taxonomy models. Use a dotted import path. |
ICV_BASE_MODEL |
"icv_taxonomy._compat.BaseModel" |
Stack-wide fallback abstract model base, used when ICV_TAXONOMY_BASE_MODEL is unset. |
ICV_TAXONOMY_AUTO_SLUG |
True |
Auto-generate slug from name when blank on save. |
ICV_TAXONOMY_SLUG_MAX_LENGTH |
255 |
Maximum length for auto-generated slugs. |
ICV_TAXONOMY_CASE_SENSITIVE_SLUGS |
False |
When False, slugs are lowercased on save. |
ICV_TAXONOMY_ENFORCE_VOCABULARY_TYPE |
True |
When True, flat vocabulary terms must be root-level. Set to False for migration compatibility. |
Swappable Models
Point ICV_TAXONOMY_VOCABULARY_MODEL and ICV_TAXONOMY_TERM_MODEL to your own subclasses to add project-specific fields.
# myapp/models.py
from icv_taxonomy.models import AbstractVocabulary, AbstractTerm
class ProjectVocabulary(AbstractVocabulary):
owner = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True)
class Meta(AbstractVocabulary.Meta):
abstract = False
db_table = "myapp_vocabulary"
class ProjectTerm(AbstractTerm):
icon = models.CharField(max_length=50, blank=True)
colour = models.CharField(max_length=7, blank=True)
class Meta(AbstractTerm.Meta):
abstract = False
db_table = "myapp_term"
# settings.py
ICV_TAXONOMY_VOCABULARY_MODEL = "myapp.ProjectVocabulary"
ICV_TAXONOMY_TERM_MODEL = "myapp.ProjectTerm"
That is the whole integration. Do not route this package's migrations
through MIGRATION_MODULES, and do not vendor a copy of them into your own
tree: a swapped consumer needs no migration routing at all, because the
package's migrations resolve the swapped models through these settings at
migrate time. A vendored copy silently stops tracking later releases, and
losing the "swappable" option from it leaves Django managing orphan
icv_taxonomy_term and icv_taxonomy_vocabulary tables that no tool warns
you about until a later release fails against them. See
UPGRADING.md for how to detect and unwind that state.
Admin Integration
VocabularyAdmin and TermAdmin are registered automatically. No additional configuration is required.
VocabularyAdmin provides:
- List display with active term count (annotated, sortable)
- Filters by
vocabulary_type,is_open, andis_active - Grouped fieldsets: General, Configuration, Metadata, Timestamps
TermAdmin provides:
- Indented title column showing tree depth (via
icv_tree.admin.TreeAdmin) - Filters by vocabulary, active status, and depth
TermRelationshipInlinefor managing relationships directly on the term form- Read-only path, depth, and order fields
- Grouped fieldsets: General, Tree, Metadata, Timestamps
To register against a custom admin site or use the classes directly:
from icv_taxonomy.admin import VocabularyAdmin, TermAdmin
from icv_taxonomy.conf import get_vocabulary_model, get_term_model
my_admin_site.register(get_vocabulary_model(), VocabularyAdmin)
my_admin_site.register(get_term_model(), TermAdmin)
Signals Reference
Connect to these signals from consuming apps to react to taxonomy events (cache invalidation, search re-indexing, audit logging).
from icv_taxonomy import signals
@receiver(signals.object_tagged)
def handle_tagged(sender, term, content_object, content_type, object_id, **kwargs):
search_index.update(content_object)
| Signal | Keyword Arguments | Fired When |
|---|---|---|
vocabulary_created |
vocabulary |
After a new vocabulary is created |
vocabulary_deleted |
vocabulary |
Before a vocabulary is deleted |
term_created |
term, vocabulary |
After a new term is created |
term_moved |
term, old_parent, new_parent, old_path |
After a term is moved in the tree |
term_merged |
source, target, associations_transferred, children_reparented |
After a merge completes (on commit) |
term_deleted |
term, vocabulary |
Before a term is deleted |
object_tagged |
term, content_object, content_type, object_id |
After a term is applied to an object |
object_untagged |
term, content_object, content_type, object_id |
After a term is removed from an object |
System Checks
| ID | Severity | Condition |
|---|---|---|
icv_taxonomy.E001 |
Error | ICV_TAXONOMY_VOCABULARY_MODEL is not a valid dotted app_label.ModelName string |
icv_taxonomy.E002 |
Error | ICV_TAXONOMY_TERM_MODEL is not a valid dotted app_label.ModelName string |
Testing
Fixtures
The conftest at tests/conftest.py provides reusable pytest fixtures:
# Available fixtures
flat_vocabulary # flat vocab with 5 active terms
hierarchical_vocabulary # 3-level tree (root → 3 children → 6 grandchildren)
closed_vocabulary # flat vocab with is_open=False, 3 terms
single_term_vocabulary # flat vocab with allow_multiple=False, 3 terms
article # persisted Article instance
product # persisted Product instance
tagged_article # article tagged with the first term from flat_vocabulary
Writing tests
import pytest
from icv_taxonomy.services import create_vocabulary, create_term, tag_object, get_terms_for_object
@pytest.mark.django_db
def test_tag_round_trip(article):
vocab = create_vocabulary(name="Topics", vocabulary_type="flat")
python = create_term(vocabulary=vocab, name="Python")
tag_object(python, article)
terms = get_terms_for_object(article)
assert list(terms) == [python]
@pytest.mark.django_db
def test_single_term_cardinality(single_term_vocabulary, article):
from icv_taxonomy.exceptions import TaxonomyValidationError
terms = list(single_term_vocabulary.terms.all())
tag_object(terms[0], article)
with pytest.raises(TaxonomyValidationError):
tag_object(terms[1], article)
Settings for test suites
# tests/settings.py
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"icv_tree",
"icv_taxonomy",
]
ICV_TAXONOMY_AUTO_SLUG = True
ICV_TAXONOMY_CASE_SENSITIVE_SLUGS = False
ICV_TAXONOMY_ENFORCE_VOCABULARY_TYPE = True
# icv-tree settings
ICV_TREE_PATH_SEPARATOR = "/"
ICV_TREE_STEP_LENGTH = 4
ICV_TREE_MAX_PATH_LENGTH = 255
ICV_TREE_ENABLE_CTE = False
Licence
MIT