simplify TagRelation
[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
66                        if tag not in current_tags]
67         for tag in tags_to_add:
68             if tag not in current_tags:
69                 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
70
71         tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
72
73     def remove_tag(self, obj, tag):
74         """
75         Remove tag from an object.
76         """
77         content_type = ContentType.objects.get_for_model(obj)
78         self.intermediary_table_model.objects.filter(
79             content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
80
81     def get_for_object(self, obj):
82         """
83         Create a queryset matching all tags associated with the given
84         object.
85         """
86         ctype = ContentType.objects.get_for_model(obj)
87         return self.filter(items__content_type__pk=ctype.pk,
88                            items__object_id=obj.pk)
89
90     def usage_for_model(self, model, counts=False, filters=None):
91         """
92         Obtain a list of tags associated with instances of the given
93         Model class.
94
95         If ``counts`` is True, a ``count`` attribute will be added to
96         each tag, indicating how many times it has been used against
97         the Model class in question.
98
99         To limit the tags (and counts, if specified) returned to those
100         used by a subset of the Model's instances, pass a dictionary
101         of field lookups to be applied to the given Model as the
102         ``filters`` argument.
103         """
104         # TODO: Do we really need this filters stuff?
105         if filters is None:
106             filters = {}
107
108         queryset = model.objects.filter()
109         for f in filters.items():
110             queryset.query.add_filter(f)
111         usage = self.usage_for_queryset(queryset, counts)
112         return usage
113
114     def usage_for_queryset(self, queryset, counts=False):
115         """
116         Obtain a list of tags associated with instances of a model
117         contained in the given queryset.
118
119         If ``counts`` is True, a ``count`` attribute will be added to
120         each tag, indicating how many times it has been used against
121         the Model class in question.
122         """
123         usage = self.model.objects.filter(
124             items__content_type=ContentType.objects.get_for_model(queryset.model),
125             items__object_id__in=queryset)
126         if counts:
127             usage = usage.annotate(count=models.Count('id'))
128         else:
129             usage = usage.distinct()
130         return usage
131
132     def related_for_model(self, tags, model, counts=False):
133         """
134         Obtain a list of tags related to a given list of tags - that
135         is, other tags used by items which have all the given tags.
136
137         If ``counts`` is True, a ``count`` attribute will be added to
138         each tag, indicating the number of items which have it in
139         addition to the given list of tags.
140         """
141         objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
142         qs = self.usage_for_queryset(objs, counts)
143         qs = qs.exclude(pk__in=[tag.pk for tag in tags])
144         return qs
145
146
147 class TaggedItemManager(models.Manager):
148     def __init__(self, tag_model):
149         super(TaggedItemManager, self).__init__()
150         self.tag_model = tag_model
151
152     def get_by_model(self, queryset_or_model, tags):
153         """
154         Create a ``QuerySet`` containing instances of the specified
155         model associated with a given tag or list of tags.
156         """
157         queryset, model = get_queryset_and_model(queryset_or_model)
158         tags = self.tag_model.get_tag_list(tags)
159         if not tags:
160             # No existing tags were given
161             return queryset.none()
162
163         # TODO: presumes reverse generic relation
164         # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
165         for tag in tags:
166             queryset = queryset.filter(tag_relations__tag=tag)
167         return queryset
168
169     def get_union_by_model(self, queryset_or_model, tags):
170         """
171         Create a ``QuerySet`` containing instances of the specified
172         model associated with *any* of the given list of tags.
173         """
174         queryset, model = get_queryset_and_model(queryset_or_model)
175         tags = self.tag_model.get_tag_list(tags)
176         if not tags:
177             return queryset.none()
178         # TODO: presumes reverse generic relation
179         return queryset.filter(tag_relations__tag__in=tags).distinct()
180
181     def get_related(self, obj, queryset_or_model):
182         """
183         Retrieve a list of instances of the specified model which share
184         tags with the model instance ``obj``, ordered by the number of
185         shared tags in descending order.
186         """
187         queryset, model = get_queryset_and_model(queryset_or_model)
188         # TODO: presumes reverse generic relation.
189         # Do we know it's 'tags'?
190         return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
191             count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)
192
193
194 ##########
195 # Models #
196 ##########
197
198 class TagMeta(ModelBase):
199     """Metaclass for tag models (models inheriting from TagBase)."""
200     def __new__(mcs, name, bases, attrs):
201         model = super(TagMeta, mcs).__new__(mcs, name, bases, attrs)
202         if not model._meta.abstract:
203             # Register custom managers for concrete models
204             TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
205             TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
206         return model
207
208
209 class TagBase(models.Model):
210     """Abstract class to be inherited by model classes."""
211     __metaclass__ = TagMeta
212
213     class Meta:
214         abstract = True
215
216     @staticmethod
217     def get_tag_list(tag_list):
218         """
219         Utility function for accepting tag input in a flexible manner.
220
221         You should probably override this method in your subclass.
222         """
223         if isinstance(tag_list, TagBase):
224             return [tag_list]
225         else:
226             return tag_list