]> git.openstreetmap.org Git - osqa.git/blob - forum/models/node.py
e8d6334751cab1a31498c76eb377b0d528c3d89b
[osqa.git] / forum / models / node.py
1 # -*- coding: utf-8 -*-
2
3 from base import *
4 import logging
5 import re
6 from tag import Tag
7
8 import markdown
9 from django.utils.encoding import smart_unicode
10 from django.utils.translation import ugettext as _
11 from django.utils.safestring import mark_safe
12 from django.utils.html import strip_tags
13 from forum.utils.html import sanitize_html
14 from forum.utils.userlinking import auto_user_link
15 from forum.settings import SUMMARY_LENGTH
16 from utils import PickledObjectField
17
18 class NodeContent(models.Model):
19     title      = models.CharField(max_length=300)
20     tagnames   = models.CharField(max_length=125)
21     author     = models.ForeignKey(User, related_name='%(class)ss')
22     body       = models.TextField()
23
24     @property
25     def user(self):
26         return self.author
27
28     @property
29     def html(self):
30         return self.body
31
32     def rendered(self, content):
33         return auto_user_link(self, self._as_markdown(content, *['auto_linker']))
34
35     @classmethod
36     def _as_markdown(cls, content, *extensions):
37         try:
38             return mark_safe(sanitize_html(markdown.markdown(content, extensions=extensions)))
39         except Exception, e:
40             import traceback
41             logging.error("Caught exception %s in markdown parser rendering %s %s:\s %s" % (
42                 str(e), cls.__name__, str(e), traceback.format_exc()))
43             return ''
44
45     def as_markdown(self, *extensions):
46         return self._as_markdown(smart_unicode(self.body), *extensions)
47
48     @property
49     def headline(self):
50         title = self.title
51
52         # Replaces multiple spaces with single ones.
53         title = re.sub(' +',' ', title)
54
55         return title
56
57     def tagname_list(self):
58         if self.tagnames:
59             return [name.strip() for name in self.tagnames.split() if name]
60         else:
61             return []
62
63     def tagname_meta_generator(self):
64         return u','.join([tag for tag in self.tagname_list()])
65
66     class Meta:
67         abstract = True
68         app_label = 'forum'
69
70 class NodeMetaClass(BaseMetaClass):
71     types = {}
72
73     def __new__(cls, *args, **kwargs):
74         new_cls = super(NodeMetaClass, cls).__new__(cls, *args, **kwargs)
75
76         if not new_cls._meta.abstract and new_cls.__name__ is not 'Node':
77             NodeMetaClass.types[new_cls.get_type()] = new_cls
78
79         return new_cls
80
81     @classmethod
82     def setup_relations(cls):
83         for node_cls in NodeMetaClass.types.values():
84             NodeMetaClass.setup_relation(node_cls)
85
86     @classmethod
87     def setup_relation(cls, node_cls):
88         name = node_cls.__name__.lower()
89
90         def children(self):
91             return node_cls.objects.filter(parent=self)
92
93         def parent(self):
94             if (self.parent is not None) and self.parent.node_type == name:
95                 return self.parent.leaf
96
97             return None
98
99         Node.add_to_class(name + 's', property(children))
100         Node.add_to_class(name, property(parent))
101
102
103 class NodeQuerySet(CachedQuerySet):
104     def obj_from_datadict(self, datadict):
105         cls = NodeMetaClass.types.get(datadict.get("node_type", ""), None)
106         if cls:
107             obj = cls()
108             obj.__dict__.update(datadict)
109             return obj
110         else:
111             return super(NodeQuerySet, self).obj_from_datadict(datadict)
112
113     def get(self, *args, **kwargs):
114         node = super(NodeQuerySet, self).get(*args, **kwargs).leaf
115
116         if not isinstance(node, self.model):
117             raise self.model.DoesNotExist()
118
119         return node
120
121     def any_state(self, *args):
122         filter = None
123
124         for s in args:
125             s_filter = models.Q(state_string__contains="(%s)" % s)
126             filter = filter and (filter | s_filter) or s_filter
127
128         if filter:
129             return self.filter(filter)
130         else:
131             return self
132
133     def all_states(self, *args):
134         filter = None
135
136         for s in args:
137             s_filter = models.Q(state_string__contains="(%s)" % s)
138             filter = filter and (filter & s_filter) or s_filter
139
140         if filter:
141             return self.filter(filter)
142         else:
143             return self
144
145     def filter_state(self, **kwargs):
146         apply_bool = lambda q, b: b and q or ~q
147         return self.filter(*[apply_bool(models.Q(state_string__contains="(%s)" % s), b) for s, b in kwargs.items()])
148
149     def children_count(self, child_type):
150         return NodeMetaClass.types[child_type].objects.filter_state(deleted=False).filter(parent__in=self).count()
151
152
153 class NodeManager(CachedManager):
154     use_for_related_fields = True
155
156     def get_query_set(self):
157         qs = NodeQuerySet(self.model)
158
159         # If the node is an answer, question or comment we filter the Node model by type
160         if self.model is not Node:
161             qs = qs.filter(node_type=self.model.get_type())
162
163         return qs
164
165     def get_for_types(self, types, *args, **kwargs):
166         kwargs['node_type__in'] = [t.get_type() for t in types]
167         return self.get(*args, **kwargs)
168
169     def filter_state(self, **kwargs):
170         return self.all().filter_state(**kwargs)
171
172
173 class NodeStateDict(object):
174     def __init__(self, node):
175         self.__dict__['_node'] = node
176
177     def __getattr__(self, name):
178         if self.__dict__.get(name, None):
179             return self.__dict__[name]
180
181         try:
182             node = self.__dict__['_node']
183             action = NodeState.objects.get(node=node, state_type=name).action
184             self.__dict__[name] = action
185             return action
186         except:
187             return None
188
189     def __setattr__(self, name, value):
190         current = self.__getattr__(name)
191
192         if value:
193             if current:
194                 current.action = value
195                 current.save()
196             else:
197                 node = self.__dict__['_node']
198                 state = NodeState(node=node, action=value, state_type=name)
199                 state.save()
200                 self.__dict__[name] = value
201
202                 if not "(%s)" % name in node.state_string:
203                     node.state_string = "%s(%s)" % (node.state_string, name)
204                     node.save()
205         else:
206             if current:
207                 node = self.__dict__['_node']
208                 node.state_string = "".join("(%s)" % s for s in re.findall('\w+', node.state_string) if s != name)
209                 node.save()
210                 current.node_state.delete()
211                 del self.__dict__[name]
212
213
214 class NodeStateQuery(object):
215     def __init__(self, node):
216         self.__dict__['_node'] = node
217
218     def __getattr__(self, name):
219         node = self.__dict__['_node']
220         return "(%s)" % name in node.state_string
221
222
223 class Node(BaseModel, NodeContent):
224     __metaclass__ = NodeMetaClass
225
226     node_type            = models.CharField(max_length=16, default='node')
227     parent               = models.ForeignKey('Node', related_name='children', null=True)
228     abs_parent           = models.ForeignKey('Node', related_name='all_children', null=True)
229
230     added_at             = models.DateTimeField(default=datetime.datetime.now)
231     score                 = models.IntegerField(default=0)
232
233     state_string          = models.TextField(default='')
234     last_edited           = models.ForeignKey('Action', null=True, unique=True, related_name="edited_node")
235
236     last_activity_by       = models.ForeignKey(User, null=True)
237     last_activity_at       = models.DateTimeField(null=True, blank=True)
238
239     tags                 = models.ManyToManyField('Tag', related_name='%(class)ss')
240     active_revision       = models.OneToOneField('NodeRevision', related_name='active', null=True)
241
242     extra = PickledObjectField()
243     extra_ref = models.ForeignKey('Node', null=True)
244     extra_count = models.IntegerField(default=0)
245
246     marked = models.BooleanField(default=False)
247
248     comment_count = DenormalizedField("children", node_type="comment", canceled=False)
249     flag_count = DenormalizedField("actions", action_type="flag", canceled=False)
250
251     friendly_name = _("post")
252
253     objects = NodeManager()
254
255     def __unicode__(self):
256         return self.headline
257
258     @classmethod
259     def _generate_cache_key(cls, key, group="node"):
260         return super(Node, cls)._generate_cache_key(key, group)
261         
262     @classmethod
263     def get_type(cls):
264         return cls.__name__.lower()
265
266     @property
267     def leaf(self):
268         leaf_cls = NodeMetaClass.types.get(self.node_type, None)
269
270         if leaf_cls is None:
271             return self
272
273         leaf = leaf_cls()
274         leaf.__dict__ = self.__dict__
275         return leaf
276
277     @property
278     def nstate(self):
279         state = self.__dict__.get('_nstate', None)
280
281         if state is None:
282             state = NodeStateDict(self)
283             self._nstate = state
284
285         return state
286
287     @property
288     def nis(self):
289         nis = self.__dict__.get('_nis', None)
290
291         if nis is None:
292             nis = NodeStateQuery(self)
293             self._nis = nis
294
295         return nis
296
297     @property
298     def last_activity(self):
299         try:
300             return self.actions.order_by('-action_date')[0].action_date
301         except:
302             return self.last_seen
303
304     @property
305     def state_list(self):
306         return [s.state_type for s in self.states.all()]
307
308     @property
309     def deleted(self):
310         return self.nis.deleted
311
312     @property
313     def absolute_parent(self):
314         if not self.abs_parent_id:
315             return self
316
317         return self.abs_parent
318
319     @property
320     def summary(self):
321         content = strip_tags(self.html)
322
323         # Remove multiple spaces.
324         content = re.sub(' +',' ', content)
325
326         # Replace line breaks with a space, we don't need them at all.
327         content = content.replace("\n", ' ')
328
329         # Truncate and all an ellipsis if length greater than summary length.
330         content = (content[:SUMMARY_LENGTH] + '...') if len(content) > SUMMARY_LENGTH else content
331
332         return content
333
334     # Can be used to block subscription notifications for a specific node from a module
335     def _is_notifiable(self):
336         return True
337
338     @property
339     def is_notifiable(self):
340         return self._is_notifiable()
341
342     @models.permalink
343     def get_revisions_url(self):
344         return ('revisions', (), {'id': self.id})
345
346     def update_last_activity(self, user, save=False, time=None):
347         if not time:
348             time = datetime.datetime.now()
349
350         self.last_activity_by = user
351         self.last_activity_at = time
352
353         if self.parent:
354             self.parent.update_last_activity(user, save=True, time=time)
355
356         if save:
357             self.save()
358
359     def _create_revision(self, user, number, **kwargs):
360         revision = NodeRevision(author=user, revision=number, node=self, **kwargs)
361         revision.save()
362         return revision
363
364     def create_revision(self, user, **kwargs):
365         number = self.revisions.aggregate(last=models.Max('revision'))['last'] + 1
366         revision = self._create_revision(user, number, **kwargs)
367         self.activate_revision(user, revision)
368         return revision
369
370     def activate_revision(self, user, revision):
371         self.title = revision.title
372         self.tagnames = revision.tagnames
373
374         self.body = self.rendered(revision.body)
375
376         self.active_revision = revision
377
378         # Try getting the previous revision
379         try:
380             prev_revision = NodeRevision.objects.get(node=self, revision=revision.revision-1)
381
382             update_activity = True
383
384             # Do not update the activity if only the tags are changed
385             if prev_revision.title == revision.title and prev_revision.body == revision.body \
386             and prev_revision.tagnames != revision.tagnames and not settings.UPDATE_LATEST_ACTIVITY_ON_TAG_EDIT:
387                 update_activity = False
388         except NodeRevision.DoesNotExist:
389             update_activity = True
390         finally:
391             if update_activity:
392                 self.update_last_activity(user)
393
394         self.save()
395
396     def get_active_users(self, active_users = None):
397         if not active_users:
398             active_users = set()
399
400         active_users.add(self.author)
401
402         for node in self.children.all():
403             if not node.nis.deleted:
404                 node.get_active_users(active_users)
405
406         return active_users
407
408     def get_last_edited(self):
409         if not self.last_edited:
410             try:
411                 le = self.actions.exclude(action_type__in=('voteup', 'votedown', 'flag'), canceled=True).order_by('-action_date')[0]
412                 self.last_edited = le
413                 self.save()
414             except:
415                 pass
416
417         return self.last_edited
418
419
420     def _list_changes_in_tags(self):
421         dirty = self.get_dirty_fields()
422
423         if not 'tagnames' in dirty:
424             return None
425         else:
426             if self._original_state['tagnames']:
427                 old_tags = set(self._original_state['tagnames'].split())
428             else:
429                 old_tags = set()
430             new_tags = set(self.tagnames.split())
431
432             return dict(
433                     current=list(new_tags),
434                     added=list(new_tags - old_tags),
435                     removed=list(old_tags - new_tags)
436                     )
437
438     def _last_active_user(self):
439         return self.last_edited and self.last_edited.by or self.author
440
441     def _process_changes_in_tags(self):
442         tag_changes = self._list_changes_in_tags()
443
444         if tag_changes is not None:
445             for name in tag_changes['added']:
446                 try:
447                     tag = Tag.objects.get(name=name)
448                 except Tag.DoesNotExist:
449                     tag = Tag.objects.create(name=name, created_by=self._last_active_user())
450
451                 if not self.nis.deleted:
452                     tag.add_to_usage_count(1)
453
454             if not self.nis.deleted:
455                 for name in tag_changes['removed']:
456                     try:
457                         tag = Tag.objects.get(name=name)
458                         tag.add_to_usage_count(-1)
459                     except Tag.DoesNotExist:
460                         pass
461
462             return True
463
464         return False
465
466     def mark_deleted(self, action):
467         self.nstate.deleted = action
468         self.save()
469
470         if action:
471             for tag in self.tags.all():
472                 tag.add_to_usage_count(-1)
473         else:
474             for tag in Tag.objects.filter(name__in=self.tagname_list()):
475                 tag.add_to_usage_count(1)
476
477     def delete(self, *args, **kwargs):
478         for tag in self.tags.all():
479             tag.add_to_usage_count(-1)
480
481         self.active_revision = None
482         self.save()
483
484         for n in self.children.all():
485             n.delete()
486
487         for a in self.actions.all():
488             a.cancel()
489
490         super(Node, self).delete(*args, **kwargs)
491
492     def save(self, *args, **kwargs):
493         if not self.id:
494             self.node_type = self.get_type()
495             super(BaseModel, self).save(*args, **kwargs)
496             self.active_revision = self._create_revision(self.author, 1, title=self.title, tagnames=self.tagnames,
497                                                          body=self.body)
498             self.activate_revision(self.author, self.active_revision)
499             self.update_last_activity(self.author, time=self.added_at)
500
501         if self.parent_id and not self.abs_parent_id:
502             self.abs_parent = self.parent.absolute_parent
503         
504         tags_changed = self._process_changes_in_tags()
505         
506         super(Node, self).save(*args, **kwargs)
507         if tags_changed:
508             if self.tagnames.strip():
509                 self.tags = list(Tag.objects.filter(name__in=self.tagname_list()))
510             else:
511                 self.tags = []
512
513     class Meta:
514         app_label = 'forum'
515
516
517 class NodeRevision(BaseModel, NodeContent):
518     node       = models.ForeignKey(Node, related_name='revisions')
519     summary    = models.CharField(max_length=300)
520     revision   = models.PositiveIntegerField()
521     revised_at = models.DateTimeField(default=datetime.datetime.now)
522
523     class Meta:
524         unique_together = ('node', 'revision')
525         app_label = 'forum'
526
527
528 class NodeState(models.Model):
529     node       = models.ForeignKey(Node, related_name='states')
530     state_type = models.CharField(max_length=16)
531     action     = models.OneToOneField('Action', related_name="node_state")
532
533     class Meta:
534         unique_together = ('node', 'state_type')
535         app_label = 'forum'
536
537