Merge branch 'app'
[wolnelektury.git] / src / newtagging / models.py
1 # -*- coding: utf-8 -*-
2 """
3 Models and managers for generic tagging.
4 """
5
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
10
11 qn = connection.ops.quote_name
12
13 tags_updated = Signal(providing_args=["affected_tags"])
14
15
16 def get_queryset_and_model(queryset_or_model):
17     """
18     Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
19     (queryset, model).
20
21     If a ``Model`` is given, the ``QuerySet`` returned will be created
22     using its default manager.
23     """
24     try:
25         return queryset_or_model, queryset_or_model.model
26     except AttributeError:
27         return queryset_or_model.objects.all(), queryset_or_model
28
29
30 ############
31 # Managers #
32 ############
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)
38
39     def target_deleted(self, instance, **kwargs):
40         """ clear tag relations before deleting an object """
41         try:
42             int(instance.pk)
43         except ValueError:
44             return
45
46         self.update_tags(instance, [])
47
48     def update_tags(self, obj, tags):
49         """
50         Update tags associated with an object.
51         """
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)
56
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,
62                 object_id=obj.pk,
63                 tag__in=tags_for_removal).delete()
64         # Add new tags
65         tags_to_add = [tag for tag in updated_tags if tag not in current_tags]
66         for tag in tags_to_add:
67             existing = self.intermediary_table_model.objects.filter(
68                 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
69             if not existing:
70                 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
71
72         tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
73
74     def remove_tag(self, obj, tag):
75         """
76         Remove tag from an object.
77         """
78         content_type = ContentType.objects.get_for_model(obj)
79         self.intermediary_table_model.objects.filter(
80             content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
81
82     def add_tag(self, obj, tag):
83         """
84         Add tag to an object.
85         """
86         content_type = ContentType.objects.get_for_model(obj)
87         relations = self.intermediary_table_model.objects.filter(
88             content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
89         if not relations:
90             self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
91
92     def get_for_object(self, obj):
93         """
94         Create a queryset matching all tags associated with the given
95         object.
96         """
97         ctype = ContentType.objects.get_for_model(obj)
98         return self.filter(items__content_type__pk=ctype.pk,
99                            items__object_id=obj.pk)
100
101     def usage_for_model(self, model, counts=False, filters=None):
102         """
103         Obtain a list of tags associated with instances of the given
104         Model class.
105
106         If ``counts`` is True, a ``count`` attribute will be added to
107         each tag, indicating how many times it has been used against
108         the Model class in question.
109
110         To limit the tags (and counts, if specified) returned to those
111         used by a subset of the Model's instances, pass a dictionary
112         of field lookups to be applied to the given Model as the
113         ``filters`` argument.
114         """
115         # TODO: Do we really need this filters stuff?
116         if filters is None:
117             filters = {}
118
119         queryset = model.objects.filter()
120         for f in filters.items():
121             queryset.query.add_filter(f)
122         usage = self.usage_for_queryset(queryset, counts)
123         return usage
124
125     def usage_for_queryset(self, queryset, counts=False):
126         """
127         Obtain a list of tags associated with instances of a model
128         contained in the given queryset.
129
130         If ``counts`` is True, a ``count`` attribute will be added to
131         each tag, indicating how many times it has been used against
132         the Model class in question.
133         """
134         usage = self.model.objects.filter(
135             items__content_type=ContentType.objects.get_for_model(queryset.model),
136             items__object_id__in=queryset)
137         if counts:
138             usage = usage.annotate(count=models.Count('id'))
139         else:
140             usage = usage.distinct()
141         return usage
142
143     def related_for_model(self, tags, model, counts=False):
144         """
145         Obtain a list of tags related to a given list of tags - that
146         is, other tags used by items which have all the given tags.
147
148         If ``counts`` is True, a ``count`` attribute will be added to
149         each tag, indicating the number of items which have it in
150         addition to the given list of tags.
151         """
152         objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
153         qs = self.usage_for_queryset(objs, counts)
154         qs = qs.exclude(pk__in=[tag.pk for tag in tags])
155         return qs
156
157
158 class TaggedItemManager(models.Manager):
159     def __init__(self, tag_model):
160         super(TaggedItemManager, self).__init__()
161         self.tag_model = tag_model
162
163     def get_by_model(self, queryset_or_model, tags):
164         """
165         Create a ``QuerySet`` containing instances of the specified
166         model associated with a given tag or list of tags.
167         """
168         queryset, model = get_queryset_and_model(queryset_or_model)
169         tags = self.tag_model.get_tag_list(tags)
170         if not tags:
171             # No existing tags were given
172             return queryset.none()
173
174         # TODO: presumes reverse generic relation
175         # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
176         for tag in tags:
177             queryset = queryset.filter(tag_relations__tag=tag)
178         return queryset
179
180     def get_union_by_model(self, queryset_or_model, tags):
181         """
182         Create a ``QuerySet`` containing instances of the specified
183         model associated with *any* of the given list of tags.
184         """
185         queryset, model = get_queryset_and_model(queryset_or_model)
186         tags = self.tag_model.get_tag_list(tags)
187         if not tags:
188             return queryset.none()
189         # TODO: presumes reverse generic relation
190         return queryset.filter(tag_relations__tag__in=tags).distinct()
191
192     def get_related(self, obj, queryset_or_model):
193         """
194         Retrieve a list of instances of the specified model which share
195         tags with the model instance ``obj``, ordered by the number of
196         shared tags in descending order.
197         """
198         queryset, model = get_queryset_and_model(queryset_or_model)
199         # TODO: presumes reverse generic relation.
200         # Do we know it's 'tags'?
201         return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
202             count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)
203
204
205 ##########
206 # Models #
207 ##########
208
209 class TagMeta(ModelBase):
210     """Metaclass for tag models (models inheriting from TagBase)."""
211     def __new__(mcs, name, bases, attrs):
212         model = super(TagMeta, mcs).__new__(mcs, name, bases, attrs)
213         if not model._meta.abstract:
214             # Register custom managers for concrete models
215             TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
216             TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
217         return model
218
219
220 class TagBase(models.Model):
221     """Abstract class to be inherited by model classes."""
222     __metaclass__ = TagMeta
223
224     class Meta:
225         abstract = True
226
227     @staticmethod
228     def get_tag_list(tag_list):
229         """
230         Utility function for accepting tag input in a flexible manner.
231
232         You should probably override this method in your subclass.
233         """
234         if isinstance(tag_list, TagBase):
235             return [tag_list]
236         else:
237             return tag_list