Access search index through singleton object, more selective index updates
[oweals/karmaworld.git] / karmaworld / apps / notes / models.py
1 #!/usr/bin/env python
2 # -*- coding:utf8 -*-
3 # Copyright (C) 2012  FinalsClub Foundation
4
5 """
6     Models for the notes django app.
7     Contains only the minimum for handling files and their representation
8 """
9 import datetime
10 from django.db.models import SET_NULL
11 from django.db.models.signals import post_save, post_delete, pre_save
12 from django.dispatch import receiver
13 import os
14 import urllib
15
16 from django.conf import settings
17 from django.core.files import File
18 from django.core.files.storage import FileSystemStorage
19 from django.db import models
20 from django.template import defaultfilters
21 import django_filepicker
22 from lxml.html import fromstring, tostring
23 from taggit.managers import TaggableManager
24
25 from karmaworld.apps.courses.models import Course
26 from karmaworld.apps.licenses.models import License
27 from karmaworld.apps.notes.search import SearchIndex
28
29 fs = FileSystemStorage(location=settings.MEDIA_ROOT)
30
31 def _choose_upload_to(instance, filename):
32     # /school/course/year/month/day
33     return u"{school}/{course}/{year}/{month}/{day}".format(
34         school=instance.course.school.slug,
35         course=instance.course.slug,
36         year=instance.uploaded_at.year,
37         month=instance.uploaded_at.month,
38         day=instance.uploaded_at.day)
39
40 class Document(models.Model):
41     """ An Abstract Base Class representing a document
42         intended to be subclassed
43     """
44     course          = models.ForeignKey(Course)
45     tags            = TaggableManager(blank=True)
46     name            = models.CharField(max_length=255, blank=True, null=True)
47     slug            = models.SlugField(max_length=255, null=True)
48
49     # license if different from default
50     license         = models.ForeignKey(License, blank=True, null=True)
51
52     # provide an upstream file link
53     upstream_link   = models.URLField(max_length=1024, blank=True, null=True)
54
55     # metadata relevant to the Upload process
56     user            = models.ForeignKey('users.KarmaUser', blank=True, null=True, on_delete=SET_NULL)
57     ip              = models.GenericIPAddressField(blank=True, null=True,
58                         help_text=u"IP address of the uploader")
59     uploaded_at     = models.DateTimeField(null=True, default=datetime.datetime.utcnow)
60
61
62     # if True, NEVER show this file
63     # WARNING: This may throw an error on migration
64     is_hidden       = models.BooleanField(default=False)
65
66     fp_file = django_filepicker.models.FPFileField(
67             upload_to=_choose_upload_to,
68             storage=fs,
69             null=True, blank=True,
70             help_text=u"An uploaded file reference from Filepicker.io")
71     mimetype = models.CharField(max_length=255, blank=True, null=True)
72
73     class Meta:
74         abstract = True
75         ordering = ['-uploaded_at']
76
77     def __unicode__(self):
78         return u"Document: {1} -- {2}".format(self.name, self.uploaded_at)
79
80     def _generate_unique_slug(self):
81         """ generate a unique slug based on name and uploaded_at  """
82         _slug = defaultfilters.slugify(self.name)
83         klass = self.__class__
84         collision = klass.objects.filter(slug=_slug)
85         if collision:
86             _slug = u"{0}-{1}-{2}-{3}".format(
87                     _slug, self.uploaded_at.month,
88                     self.uploaded_at.day, self.uploaded_at.microsecond)
89         self.slug = _slug
90
91     def get_file(self):
92         """ Downloads the file from filepicker.io and returns a
93         Django File wrapper object """
94         # clean up any old downloads that are still hanging around
95         if hasattr(self, 'tempfile'):
96             self.tempfile.close()
97             delattr(self, 'tempfile')
98
99         if hasattr(self, 'filename'):
100             # the file might have been moved in the meantime so
101             # check first
102             if os.path.exists(self.filename):
103                 os.remove(self.filename)
104             delattr(self, 'filename')
105
106         # The temporary file will be created in a directory set by the
107         # environment (TEMP_DIR, TEMP or TMP)
108         self.filename, header = urllib.urlretrieve(self.fp_file.name)
109         name = os.path.basename(self.filename)
110         disposition = header.get('Content-Disposition')
111         if disposition:
112             name = disposition.rpartition("filename=")[2].strip('" ')
113         filename = header.get('X-File-Name')
114         if filename:
115             name = filename
116
117         self.tempfile = open(self.filename, 'r')
118         return File(self.tempfile, name=name)
119
120     def save(self, *args, **kwargs):
121         if self.name and not self.slug:
122             self._generate_unique_slug()
123         super(Document, self).save(*args, **kwargs)
124
125 class Note(Document):
126     """ A django model representing an uploaded file and associated metadata.
127     """
128     # FIXME: refactor file choices after FP.io integration
129     UNKNOWN_FILE = '???'
130     FILE_TYPE_CHOICES = (
131         ('doc', 'MS Word compatible file (.doc, .docx, .rtf, .odf)'),
132         ('img', 'Scan or picture of notes'),
133         ('pdf', 'PDF file'),
134         ('ppt', 'Powerpoint'),
135         ('txt', 'Text'),
136         (UNKNOWN_FILE, 'Unknown file'),
137     )
138
139     file_type       = models.CharField(max_length=15,
140                             choices=FILE_TYPE_CHOICES,
141                             default=UNKNOWN_FILE,
142                             blank=True, null=True)
143
144     # Cache the Google drive file link
145     gdrive_url      = models.URLField(max_length=1024, blank=True, null=True)
146
147     # Upload files to MEDIA_ROOT/notes/YEAR/MONTH/DAY, 2012/10/30/filename
148     pdf_file       = models.FileField(
149                             storage=fs,
150                             upload_to="notes/%Y/%m/%d/",
151                             blank=True, null=True)
152
153     # Generated by Google Drive by saved locally
154     html            = models.TextField(blank=True, null=True)
155     text            = models.TextField(blank=True, null=True)
156
157
158     # Academic year of course
159     year            = models.IntegerField(blank=True, null=True,\
160                         default=datetime.datetime.utcnow().year)
161
162     # Number of times this note has been flagged as abusive/spam.
163     flags           = models.IntegerField(default=0,null=False)
164
165     # Social media tracking
166     tweeted         = models.BooleanField(default=False)
167     thanks          = models.PositiveIntegerField(default=0)
168
169     def __unicode__(self):
170         return u"Note: {0} {1} -- {2}".format(self.file_type, self.name, self.uploaded_at)
171
172
173     def get_absolute_url(self):
174         """ Resolve note url, use 'note' route and slug if slug
175             otherwise use note.id
176         """
177         if self.slug is not None:
178             # return a url ending in slug
179             return u"/{0}/{1}/{2}".format(self.course.school.slug, self.course.slug, self.slug)
180         else:
181             # return a url ending in id
182             return u"/{0}/{1}/{2}".format(self.course.school.slug, self.course.slug, self.id)
183
184     def sanitize_html(self, save=True):
185         """ if self contains html, find all <a> tags and add target=_blank
186             takes self
187             returns True/False on succ/fail and error or count
188         """
189         # build a tag sanitizer
190         def add_attribute_target(tag):
191             tag.attrib['target'] = '_blank'
192
193         # if no html, return false
194         if not self.html:
195             return False, "Note has no html"
196
197         _html = fromstring(self.html)
198         a_tags = _html.findall('.//a') # recursively find all a tags in document tree
199         # if there are a tags
200         if a_tags > 1:
201             #apply the add attribute function
202             map(add_attribute_target, a_tags)
203             self.html = tostring(_html)
204             if save:
205                 self.save()
206             return True, len(a_tags)
207
208     def _update_parent_updated_at(self):
209         """ update the parent Course.updated_at model
210             with the latest uploaded_at """
211         self.course.updated_at = self.uploaded_at
212         self.course.save()
213
214     def save(self, *args, **kwargs):
215         if self.uploaded_at and self.uploaded_at > self.course.updated_at:
216             self._update_parent_updated_at()
217         super(Note, self).save(*args, **kwargs)
218
219
220 def update_note_counts(note_instance):
221     try:
222         # test if the course still exists, or if this is a cascade delete.
223         note_instance.course
224     except Course.DoesNotExist:
225         # this is a cascade delete. there is no course to update
226         pass
227     else:
228         # course exists
229         note_instance.course.update_note_count()
230         note_instance.course.school.update_note_count()
231
232 @receiver(pre_save, sender=Note, weak=False)
233 def note_pre_save_receiver(sender, **kwargs):
234     """Stick an instance of the pre-save value of
235     the given Note instance in the instances itself.
236     This will be looked at in post_save."""
237     if not 'instance' in kwargs:
238         return
239
240     kwargs['instance'].old_instance = Note.objects.get(id=kwargs['instance'].id)
241
242 @receiver(post_save, sender=Note, weak=False)
243 def note_save_receiver(sender, **kwargs):
244     if not 'instance' in kwargs:
245         return
246     note = kwargs['instance']
247
248     index = SearchIndex()
249     if kwargs['created']:
250         update_note_counts(note)
251         index.add_note(note)
252     else:
253         index.update_note(note, note.old_instance)
254
255
256 @receiver(post_delete, sender=Note, weak=False)
257 def note_delete_receiver(sender, **kwargs):
258     if not 'instance' in kwargs:
259         return
260     note = kwargs['instance']
261
262     # Update course and school counts of how
263     # many notes they have
264     update_note_counts(kwargs['instance'])
265
266     # Remove document from search index
267     index = SearchIndex()
268     index.remove_note(note)