e42295d7058d675006f54dd6c4e3aecfa59a7422
[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             self.intermediary_table_model.objects.get_or_create(tag=tag, content_object=obj)
68
69         tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
70
71     def remove_tag(self, obj, tag):
72         """
73         Remove tag from an object.
74         """
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()
78
79     def add_tag(self, obj, tag):
80         """
81         Add tag to an object.
82         """
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)
86         if not relations:
87             self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
88
89     def get_for_object(self, obj):
90         """
91         Create a queryset matching all tags associated with the given
92         object.
93         """
94         ctype = ContentType.objects.get_for_model(obj)
95         return self.filter(items__content_type__pk=ctype.pk,
96                            items__object_id=obj.pk)
97
98     def usage_for_model(self, model, counts=False, filters=None):
99         """
100         Obtain a list of tags associated with instances of the given
101         Model class.
102
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.
106
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.
111         """
112         # TODO: Do we really need this filters stuff?
113         if filters is None:
114             filters = {}
115
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)
120         return usage
121
122     def usage_for_queryset(self, queryset, counts=False):
123         """
124         Obtain a list of tags associated with instances of a model
125         contained in the given queryset.
126
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.
130         """
131         usage = self.model.objects.filter(
132             items__content_type=ContentType.objects.get_for_model(queryset.model),
133             items__object_id__in=queryset)
134         if counts:
135             usage = usage.annotate(count=models.Count('id'))
136         else:
137             usage = usage.distinct()
138         return usage
139
140     def related_for_model(self, tags, model, counts=False):
141         """
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.
144
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.
148         """
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])
152         return qs
153
154
155 class TaggedItemManager(models.Manager):
156     def __init__(self, tag_model):
157         super(TaggedItemManager, self).__init__()
158         self.tag_model = tag_model
159
160     def get_by_model(self, queryset_or_model, tags):
161         """
162         Create a ``QuerySet`` containing instances of the specified
163         model associated with a given tag or list of tags.
164         """
165         queryset, model = get_queryset_and_model(queryset_or_model)
166         tags = self.tag_model.get_tag_list(tags)
167         if not tags:
168             # No existing tags were given
169             return queryset.none()
170
171         # TODO: presumes reverse generic relation
172         # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
173         for tag in tags:
174             queryset = queryset.filter(tag_relations__tag=tag)
175         return queryset
176
177     def get_union_by_model(self, queryset_or_model, tags):
178         """
179         Create a ``QuerySet`` containing instances of the specified
180         model associated with *any* of the given list of tags.
181         """
182         queryset, model = get_queryset_and_model(queryset_or_model)
183         tags = self.tag_model.get_tag_list(tags)
184         if not tags:
185             return queryset.none()
186         # TODO: presumes reverse generic relation
187         return queryset.filter(tag_relations__tag__in=tags).distinct()
188
189     def get_related(self, obj, queryset_or_model):
190         """
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.
194         """
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)
200
201
202 ##########
203 # Models #
204 ##########
205
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')
214         return model
215
216
217 class TagBase(models.Model):
218     """Abstract class to be inherited by model classes."""
219     __metaclass__ = TagMeta
220
221     class Meta:
222         abstract = True
223
224     @staticmethod
225     def get_tag_list(tag_list):
226         """
227         Utility function for accepting tag input in a flexible manner.
228
229         You should probably override this method in your subclass.
230         """
231         if isinstance(tag_list, TagBase):
232             return [tag_list]
233         else:
234             return tag_list