General A/B testing.
[wolnelektury.git] / src / newtagging / models.py
1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
3 #
4 """
5 Models and managers for generic tagging.
6 """
7
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
12
13 qn = connection.ops.quote_name
14
15 tags_updated = Signal(providing_args=["affected_tags"])
16
17
18 def get_queryset_and_model(queryset_or_model):
19     """
20     Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
21     (queryset, model).
22
23     If a ``Model`` is given, the ``QuerySet`` returned will be created
24     using its default manager.
25     """
26     try:
27         return queryset_or_model, queryset_or_model.model
28     except AttributeError:
29         return queryset_or_model.objects.all(), queryset_or_model
30
31
32 ############
33 # Managers #
34 ############
35 class TagManager(models.Manager):
36     def __init__(self):
37         super(TagManager, self).__init__()
38         models.signals.pre_delete.connect(self.target_deleted)
39
40     @property
41     def intermediary_table_model(self):
42         return self.model.intermediary_table_model
43
44     def target_deleted(self, instance, **kwargs):
45         """ clear tag relations before deleting an object """
46         try:
47             int(instance.pk)
48         except ValueError:
49             return
50
51         self.update_tags(instance, [])
52
53     def update_tags(self, obj, tags):
54         """
55         Update tags associated with an object.
56         """
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))
60         updated_tags = tags
61
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,
67                 object_id=obj.pk,
68                 tag__in=tags_for_removal).delete()
69         # Add new tags
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)
74             if not existing:
75                 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
76
77         tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
78
79     def remove_tag(self, obj, tag):
80         """
81         Remove tag from an object.
82         """
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()
86
87     def add_tag(self, obj, tag):
88         """
89         Add tag to an object.
90         """
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)
94         if not relations:
95             self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
96
97     def get_for_object(self, obj):
98         """
99         Create a queryset matching all tags associated with the given
100         object.
101         """
102         ctype = ContentType.objects.get_for_model(obj)
103         return self.filter(items__content_type__pk=ctype.pk,
104                            items__object_id=obj.pk)
105
106     def usage_for_model(self, model, counts=False, filters=None):
107         """
108         Obtain a list of tags associated with instances of the given
109         Model class.
110
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.
114
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.
119         """
120         # TODO: Do we really need this filters stuff?
121         if filters is None:
122             filters = {}
123
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)
128         return usage
129
130     def usage_for_queryset(self, queryset, counts=False):
131         """
132         Obtain a list of tags associated with instances of a model
133         contained in the given queryset.
134
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.
138         """
139         usage = self.model.objects.filter(
140             items__content_type=ContentType.objects.get_for_model(queryset.model),
141             items__object_id__in=queryset)
142         if counts:
143             usage = usage.annotate(count=models.Count('id'))
144         else:
145             usage = usage.distinct()
146         return usage
147
148     def related_for_model(self, tags, model, counts=False):
149         """
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.
152
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.
156         """
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])
160         return qs
161
162
163 class TaggedItemManager(models.Manager):
164     @property
165     def tag_model(self):
166         return self.model.tag_model
167
168     def get_by_model(self, queryset_or_model, tags):
169         """
170         Create a ``QuerySet`` containing instances of the specified
171         model associated with a given tag or list of tags.
172         """
173         queryset, model = get_queryset_and_model(queryset_or_model)
174         if not tags:
175             # No existing tags were given
176             return queryset.none()
177
178         # TODO: presumes reverse generic relation
179         # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
180         for tag in tags:
181             queryset = queryset.filter(tag_relations__tag=tag)
182         return queryset
183
184     def get_union_by_model(self, queryset_or_model, tags):
185         """
186         Create a ``QuerySet`` containing instances of the specified
187         model associated with *any* of the given list of tags.
188         """
189         queryset, model = get_queryset_and_model(queryset_or_model)
190         if not tags:
191             return queryset.none()
192         # TODO: presumes reverse generic relation
193         return queryset.filter(tag_relations__tag__in=tags).distinct()
194
195     def get_related(self, obj, queryset_or_model):
196         """
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.
200         """
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)