3 # Copyright (C) 2012 FinalsClub Foundation
6 Models for the notes django app.
7 Contains only the minimum for handling files and their representation
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
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
25 from karmaworld.apps.courses.models import Course
26 from karmaworld.apps.licenses.models import License
27 from karmaworld.apps.notes.search import SearchIndex
29 fs = FileSystemStorage(location=settings.MEDIA_ROOT)
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)
40 class Document(models.Model):
41 """ An Abstract Base Class representing a document
42 intended to be subclassed
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)
49 # license if different from default
50 license = models.ForeignKey(License, blank=True, null=True)
52 # provide an upstream file link
53 upstream_link = models.URLField(max_length=1024, blank=True, null=True)
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)
62 # if True, NEVER show this file
63 # WARNING: This may throw an error on migration
64 is_hidden = models.BooleanField(default=False)
66 fp_file = django_filepicker.models.FPFileField(
67 upload_to=_choose_upload_to,
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)
75 ordering = ['-uploaded_at']
77 def __unicode__(self):
78 return u"Document: {1} -- {2}".format(self.name, self.uploaded_at)
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)
86 _slug = u"{0}-{1}-{2}-{3}".format(
87 _slug, self.uploaded_at.month,
88 self.uploaded_at.day, self.uploaded_at.microsecond)
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'):
97 delattr(self, 'tempfile')
99 if hasattr(self, 'filename'):
100 # the file might have been moved in the meantime so
102 if os.path.exists(self.filename):
103 os.remove(self.filename)
104 delattr(self, 'filename')
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')
112 name = disposition.rpartition("filename=")[2].strip('" ')
113 filename = header.get('X-File-Name')
117 self.tempfile = open(self.filename, 'r')
118 return File(self.tempfile, name=name)
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)
125 class Note(Document):
126 """ A django model representing an uploaded file and associated metadata.
128 # FIXME: refactor file choices after FP.io integration
130 FILE_TYPE_CHOICES = (
131 ('doc', 'MS Word compatible file (.doc, .docx, .rtf, .odf)'),
132 ('img', 'Scan or picture of notes'),
134 ('ppt', 'Powerpoint'),
136 (UNKNOWN_FILE, 'Unknown file'),
139 file_type = models.CharField(max_length=15,
140 choices=FILE_TYPE_CHOICES,
141 default=UNKNOWN_FILE,
142 blank=True, null=True)
144 # Cache the Google drive file link
145 gdrive_url = models.URLField(max_length=1024, blank=True, null=True)
147 # Upload files to MEDIA_ROOT/notes/YEAR/MONTH/DAY, 2012/10/30/filename
148 pdf_file = models.FileField(
150 upload_to="notes/%Y/%m/%d/",
151 blank=True, null=True)
153 # Generated by Google Drive by saved locally
154 html = models.TextField(blank=True, null=True)
155 text = models.TextField(blank=True, null=True)
158 # Academic year of course
159 year = models.IntegerField(blank=True, null=True,\
160 default=datetime.datetime.utcnow().year)
162 # Number of times this note has been flagged as abusive/spam.
163 flags = models.IntegerField(default=0,null=False)
165 # Social media tracking
166 tweeted = models.BooleanField(default=False)
167 thanks = models.PositiveIntegerField(default=0)
169 def __unicode__(self):
170 return u"Note: {0} {1} -- {2}".format(self.file_type, self.name, self.uploaded_at)
173 def get_absolute_url(self):
174 """ Resolve note url, use 'note' route and slug if slug
175 otherwise use note.id
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)
181 # return a url ending in id
182 return u"/{0}/{1}/{2}".format(self.course.school.slug, self.course.slug, self.id)
184 def sanitize_html(self, save=True):
185 """ if self contains html, find all <a> tags and add target=_blank
187 returns True/False on succ/fail and error or count
189 # build a tag sanitizer
190 def add_attribute_target(tag):
191 tag.attrib['target'] = '_blank'
193 # if no html, return false
195 return False, "Note has no html"
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
201 #apply the add attribute function
202 map(add_attribute_target, a_tags)
203 self.html = tostring(_html)
206 return True, len(a_tags)
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
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)
220 def update_note_counts(note_instance):
222 # test if the course still exists, or if this is a cascade delete.
224 except Course.DoesNotExist:
225 # this is a cascade delete. there is no course to update
229 note_instance.course.update_note_count()
230 note_instance.course.school.update_note_count()
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:
240 kwargs['instance'].old_instance = Note.objects.get(id=kwargs['instance'].id)
242 @receiver(post_save, sender=Note, weak=False)
243 def note_save_receiver(sender, **kwargs):
244 if not 'instance' in kwargs:
246 note = kwargs['instance']
248 index = SearchIndex()
249 if kwargs['created']:
250 update_note_counts(note)
253 index.update_note(note, note.old_instance)
256 @receiver(post_delete, sender=Note, weak=False)
257 def note_delete_receiver(sender, **kwargs):
258 if not 'instance' in kwargs:
260 note = kwargs['instance']
262 # Update course and school counts of how
263 # many notes they have
264 update_note_counts(kwargs['instance'])
266 # Remove document from search index
267 index = SearchIndex()
268 index.remove_note(note)