1 # -*- coding: utf-8 -*-
3 Models and managers for generic tagging.
6 from django.contrib.contenttypes.models import ContentType
7 from django.db import connection, models
8 from django.db.models.base import ModelBase
9 from django.dispatch import Signal
11 qn = connection.ops.quote_name
13 tags_updated = Signal(providing_args=["affected_tags"])
16 def get_queryset_and_model(queryset_or_model):
18 Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
21 If a ``Model`` is given, the ``QuerySet`` returned will be created
22 using its default manager.
25 return queryset_or_model, queryset_or_model.model
26 except AttributeError:
27 return queryset_or_model.objects.all(), queryset_or_model
33 class TagManager(models.Manager):
34 def __init__(self, intermediary_table_model):
35 super(TagManager, self).__init__()
36 self.intermediary_table_model = intermediary_table_model
37 models.signals.pre_delete.connect(self.target_deleted)
39 def target_deleted(self, instance, **kwargs):
40 """ clear tag relations before deleting an object """
46 self.update_tags(instance, [])
48 def update_tags(self, obj, tags):
50 Update tags associated with an object.
52 content_type = ContentType.objects.get_for_model(obj)
53 current_tags = list(self.filter(items__content_type__pk=content_type.pk,
54 items__object_id=obj.pk))
55 updated_tags = self.model.get_tag_list(tags)
57 # Remove tags which no longer apply
58 tags_for_removal = [tag for tag in current_tags if tag not in updated_tags]
59 if len(tags_for_removal):
60 self.intermediary_table_model.objects.filter(
61 content_type__pk=content_type.pk,
63 tag__in=tags_for_removal).delete()
65 tags_to_add = [tag for tag in updated_tags if tag not in current_tags]
66 for tag in tags_to_add:
67 self.intermediary_table_model.objects.get_or_create(tag=tag, content_object=obj)
69 tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
71 def remove_tag(self, obj, tag):
73 Remove tag from an object.
75 content_type = ContentType.objects.get_for_model(obj)
76 self.intermediary_table_model.objects.filter(
77 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
79 def add_tag(self, obj, tag):
83 content_type = ContentType.objects.get_for_model(obj)
84 relations = self.intermediary_table_model.objects.filter(
85 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
87 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
89 def get_for_object(self, obj):
91 Create a queryset matching all tags associated with the given
94 ctype = ContentType.objects.get_for_model(obj)
95 return self.filter(items__content_type__pk=ctype.pk,
96 items__object_id=obj.pk)
98 def usage_for_model(self, model, counts=False, filters=None):
100 Obtain a list of tags associated with instances of the given
103 If ``counts`` is True, a ``count`` attribute will be added to
104 each tag, indicating how many times it has been used against
105 the Model class in question.
107 To limit the tags (and counts, if specified) returned to those
108 used by a subset of the Model's instances, pass a dictionary
109 of field lookups to be applied to the given Model as the
110 ``filters`` argument.
112 # TODO: Do we really need this filters stuff?
116 queryset = model.objects.filter()
117 for f in filters.items():
118 queryset.query.add_filter(f)
119 usage = self.usage_for_queryset(queryset, counts)
122 def usage_for_queryset(self, queryset, counts=False):
124 Obtain a list of tags associated with instances of a model
125 contained in the given queryset.
127 If ``counts`` is True, a ``count`` attribute will be added to
128 each tag, indicating how many times it has been used against
129 the Model class in question.
131 usage = self.model.objects.filter(
132 items__content_type=ContentType.objects.get_for_model(queryset.model),
133 items__object_id__in=queryset)
135 usage = usage.annotate(count=models.Count('id'))
137 usage = usage.distinct()
140 def related_for_model(self, tags, model, counts=False):
142 Obtain a list of tags related to a given list of tags - that
143 is, other tags used by items which have all the given tags.
145 If ``counts`` is True, a ``count`` attribute will be added to
146 each tag, indicating the number of items which have it in
147 addition to the given list of tags.
149 objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
150 qs = self.usage_for_queryset(objs, counts)
151 qs = qs.exclude(pk__in=[tag.pk for tag in tags])
155 class TaggedItemManager(models.Manager):
156 def __init__(self, tag_model):
157 super(TaggedItemManager, self).__init__()
158 self.tag_model = tag_model
160 def get_by_model(self, queryset_or_model, tags):
162 Create a ``QuerySet`` containing instances of the specified
163 model associated with a given tag or list of tags.
165 queryset, model = get_queryset_and_model(queryset_or_model)
166 tags = self.tag_model.get_tag_list(tags)
168 # No existing tags were given
169 return queryset.none()
171 # TODO: presumes reverse generic relation
172 # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
174 queryset = queryset.filter(tag_relations__tag=tag)
177 def get_union_by_model(self, queryset_or_model, tags):
179 Create a ``QuerySet`` containing instances of the specified
180 model associated with *any* of the given list of tags.
182 queryset, model = get_queryset_and_model(queryset_or_model)
183 tags = self.tag_model.get_tag_list(tags)
185 return queryset.none()
186 # TODO: presumes reverse generic relation
187 return queryset.filter(tag_relations__tag__in=tags).distinct()
189 def get_related(self, obj, queryset_or_model):
191 Retrieve a list of instances of the specified model which share
192 tags with the model instance ``obj``, ordered by the number of
193 shared tags in descending order.
195 queryset, model = get_queryset_and_model(queryset_or_model)
196 # TODO: presumes reverse generic relation.
197 # Do we know it's 'tags'?
198 return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
199 count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)
206 class TagMeta(ModelBase):
207 """Metaclass for tag models (models inheriting from TagBase)."""
208 def __new__(mcs, name, bases, attrs):
209 model = super(TagMeta, mcs).__new__(mcs, name, bases, attrs)
210 if not model._meta.abstract:
211 # Register custom managers for concrete models
212 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
213 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
217 class TagBase(models.Model):
218 """Abstract class to be inherited by model classes."""
219 __metaclass__ = TagMeta
225 def get_tag_list(tag_list):
227 Utility function for accepting tag input in a flexible manner.
229 You should probably override this method in your subclass.
231 if isinstance(tag_list, TagBase):