1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
5 Models and managers for generic tagging.
8 from django.contrib.contenttypes.models import ContentType
9 from django.db import connection, models
10 from django.db.models.base import ModelBase
11 from django.dispatch import Signal
13 qn = connection.ops.quote_name
15 tags_updated = Signal(providing_args=["affected_tags"])
18 def get_queryset_and_model(queryset_or_model):
20 Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
23 If a ``Model`` is given, the ``QuerySet`` returned will be created
24 using its default manager.
27 return queryset_or_model, queryset_or_model.model
28 except AttributeError:
29 return queryset_or_model.objects.all(), queryset_or_model
35 class TagManager(models.Manager):
37 super(TagManager, self).__init__()
38 models.signals.pre_delete.connect(self.target_deleted)
41 def intermediary_table_model(self):
42 return self.model.intermediary_table_model
44 def target_deleted(self, instance, **kwargs):
45 """ clear tag relations before deleting an object """
51 self.update_tags(instance, [])
53 def update_tags(self, obj, tags):
55 Update tags associated with an object.
57 content_type = ContentType.objects.get_for_model(obj)
58 current_tags = list(self.filter(items__content_type__pk=content_type.pk,
59 items__object_id=obj.pk))
62 # Remove tags which no longer apply
63 tags_for_removal = [tag for tag in current_tags if tag not in updated_tags]
64 if len(tags_for_removal):
65 self.intermediary_table_model.objects.filter(
66 content_type__pk=content_type.pk,
68 tag__in=tags_for_removal).delete()
70 tags_to_add = [tag for tag in updated_tags if tag not in current_tags]
71 for tag in tags_to_add:
72 existing = self.intermediary_table_model.objects.filter(
73 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
75 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
77 tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
79 def remove_tag(self, obj, tag):
81 Remove tag from an object.
83 content_type = ContentType.objects.get_for_model(obj)
84 self.intermediary_table_model.objects.filter(
85 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
87 def add_tag(self, obj, tag):
91 content_type = ContentType.objects.get_for_model(obj)
92 relations = self.intermediary_table_model.objects.filter(
93 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
95 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
97 def get_for_object(self, obj):
99 Create a queryset matching all tags associated with the given
102 ctype = ContentType.objects.get_for_model(obj)
103 return self.filter(items__content_type__pk=ctype.pk,
104 items__object_id=obj.pk)
106 def usage_for_model(self, model, counts=False, filters=None):
108 Obtain a list of tags associated with instances of the given
111 If ``counts`` is True, a ``count`` attribute will be added to
112 each tag, indicating how many times it has been used against
113 the Model class in question.
115 To limit the tags (and counts, if specified) returned to those
116 used by a subset of the Model's instances, pass a dictionary
117 of field lookups to be applied to the given Model as the
118 ``filters`` argument.
120 # TODO: Do we really need this filters stuff?
124 queryset = model.objects.filter()
125 for f in filters.items():
126 queryset.query.add_filter(f)
127 usage = self.usage_for_queryset(queryset, counts)
130 def usage_for_queryset(self, queryset, counts=False):
132 Obtain a list of tags associated with instances of a model
133 contained in the given queryset.
135 If ``counts`` is True, a ``count`` attribute will be added to
136 each tag, indicating how many times it has been used against
137 the Model class in question.
139 usage = self.model.objects.filter(
140 items__content_type=ContentType.objects.get_for_model(queryset.model),
141 items__object_id__in=queryset)
143 usage = usage.annotate(count=models.Count('id'))
145 usage = usage.distinct()
148 def related_for_model(self, tags, model, counts=False):
150 Obtain a list of tags related to a given list of tags - that
151 is, other tags used by items which have all the given tags.
153 If ``counts`` is True, a ``count`` attribute will be added to
154 each tag, indicating the number of items which have it in
155 addition to the given list of tags.
157 objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
158 qs = self.usage_for_queryset(objs, counts)
159 qs = qs.exclude(pk__in=[tag.pk for tag in tags])
163 class TaggedItemManager(models.Manager):
166 return self.model.tag_model
168 def get_by_model(self, queryset_or_model, tags):
170 Create a ``QuerySet`` containing instances of the specified
171 model associated with a given tag or list of tags.
173 queryset, model = get_queryset_and_model(queryset_or_model)
175 # No existing tags were given
176 return queryset.none()
178 # TODO: presumes reverse generic relation
179 # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
181 queryset = queryset.filter(tag_relations__tag=tag)
184 def get_union_by_model(self, queryset_or_model, tags):
186 Create a ``QuerySet`` containing instances of the specified
187 model associated with *any* of the given list of tags.
189 queryset, model = get_queryset_and_model(queryset_or_model)
191 return queryset.none()
192 # TODO: presumes reverse generic relation
193 return queryset.filter(tag_relations__tag__in=tags).distinct()
195 def get_related(self, obj, queryset_or_model):
197 Retrieve a list of instances of the specified model which share
198 tags with the model instance ``obj``, ordered by the number of
199 shared tags in descending order.
201 queryset, model = get_queryset_and_model(queryset_or_model)
202 # TODO: presumes reverse generic relation.
203 # Do we know it's 'tags'?
204 return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
205 count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)