Minor css fixes.
[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):
35         super(TagManager, self).__init__()
36         models.signals.pre_delete.connect(self.target_deleted)
37
38     @property
39     def intermediary_table_model(self):
40         return self.model.intermediary_table_model
41
42     def target_deleted(self, instance, **kwargs):
43         """ clear tag relations before deleting an object """
44         try:
45             int(instance.pk)
46         except ValueError:
47             return
48
49         self.update_tags(instance, [])
50
51     def update_tags(self, obj, tags):
52         """
53         Update tags associated with an object.
54         """
55         content_type = ContentType.objects.get_for_model(obj)
56         current_tags = list(self.filter(items__content_type__pk=content_type.pk,
57                                         items__object_id=obj.pk))
58         updated_tags = tags
59
60         # Remove tags which no longer apply
61         tags_for_removal = [tag for tag in current_tags if tag not in updated_tags]
62         if len(tags_for_removal):
63             self.intermediary_table_model.objects.filter(
64                 content_type__pk=content_type.pk,
65                 object_id=obj.pk,
66                 tag__in=tags_for_removal).delete()
67         # Add new tags
68         tags_to_add = [tag for tag in updated_tags if tag not in current_tags]
69         for tag in tags_to_add:
70             existing = self.intermediary_table_model.objects.filter(
71                 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
72             if not existing:
73                 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
74
75         tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
76
77     def remove_tag(self, obj, tag):
78         """
79         Remove tag from an object.
80         """
81         content_type = ContentType.objects.get_for_model(obj)
82         self.intermediary_table_model.objects.filter(
83             content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
84
85     def add_tag(self, obj, tag):
86         """
87         Add tag to an object.
88         """
89         content_type = ContentType.objects.get_for_model(obj)
90         relations = self.intermediary_table_model.objects.filter(
91             content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
92         if not relations:
93             self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
94
95     def get_for_object(self, obj):
96         """
97         Create a queryset matching all tags associated with the given
98         object.
99         """
100         ctype = ContentType.objects.get_for_model(obj)
101         return self.filter(items__content_type__pk=ctype.pk,
102                            items__object_id=obj.pk)
103
104     def usage_for_model(self, model, counts=False, filters=None):
105         """
106         Obtain a list of tags associated with instances of the given
107         Model class.
108
109         If ``counts`` is True, a ``count`` attribute will be added to
110         each tag, indicating how many times it has been used against
111         the Model class in question.
112
113         To limit the tags (and counts, if specified) returned to those
114         used by a subset of the Model's instances, pass a dictionary
115         of field lookups to be applied to the given Model as the
116         ``filters`` argument.
117         """
118         # TODO: Do we really need this filters stuff?
119         if filters is None:
120             filters = {}
121
122         queryset = model.objects.filter()
123         for f in filters.items():
124             queryset.query.add_filter(f)
125         usage = self.usage_for_queryset(queryset, counts)
126         return usage
127
128     def usage_for_queryset(self, queryset, counts=False):
129         """
130         Obtain a list of tags associated with instances of a model
131         contained in the given queryset.
132
133         If ``counts`` is True, a ``count`` attribute will be added to
134         each tag, indicating how many times it has been used against
135         the Model class in question.
136         """
137         usage = self.model.objects.filter(
138             items__content_type=ContentType.objects.get_for_model(queryset.model),
139             items__object_id__in=queryset)
140         if counts:
141             usage = usage.annotate(count=models.Count('id'))
142         else:
143             usage = usage.distinct()
144         return usage
145
146     def related_for_model(self, tags, model, counts=False):
147         """
148         Obtain a list of tags related to a given list of tags - that
149         is, other tags used by items which have all the given tags.
150
151         If ``counts`` is True, a ``count`` attribute will be added to
152         each tag, indicating the number of items which have it in
153         addition to the given list of tags.
154         """
155         objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
156         qs = self.usage_for_queryset(objs, counts)
157         qs = qs.exclude(pk__in=[tag.pk for tag in tags])
158         return qs
159
160
161 class TaggedItemManager(models.Manager):
162     @property
163     def tag_model(self):
164         return self.model.tag_model
165
166     def get_by_model(self, queryset_or_model, tags):
167         """
168         Create a ``QuerySet`` containing instances of the specified
169         model associated with a given tag or list of tags.
170         """
171         queryset, model = get_queryset_and_model(queryset_or_model)
172         if not tags:
173             # No existing tags were given
174             return queryset.none()
175
176         # TODO: presumes reverse generic relation
177         # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
178         for tag in tags:
179             queryset = queryset.filter(tag_relations__tag=tag)
180         return queryset
181
182     def get_union_by_model(self, queryset_or_model, tags):
183         """
184         Create a ``QuerySet`` containing instances of the specified
185         model associated with *any* of the given list of tags.
186         """
187         queryset, model = get_queryset_and_model(queryset_or_model)
188         if not tags:
189             return queryset.none()
190         # TODO: presumes reverse generic relation
191         return queryset.filter(tag_relations__tag__in=tags).distinct()
192
193     def get_related(self, obj, queryset_or_model):
194         """
195         Retrieve a list of instances of the specified model which share
196         tags with the model instance ``obj``, ordered by the number of
197         shared tags in descending order.
198         """
199         queryset, model = get_queryset_and_model(queryset_or_model)
200         # TODO: presumes reverse generic relation.
201         # Do we know it's 'tags'?
202         return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
203             count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)