74d86313f2f85701b31364a274d4e6a62006b80b
[wolnelektury.git] / apps / picture / models.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from django.db import models, transaction
6 import catalogue.models
7 from django.db.models import permalink
8 from sorl.thumbnail import ImageField
9 from django.conf import settings
10 from django.contrib.contenttypes.fields import GenericRelation
11 from django.core.files.storage import FileSystemStorage
12 from django.utils.datastructures import SortedDict
13 from django.template.loader import render_to_string
14 from django.utils.safestring import mark_safe
15 from django.core.cache import caches
16 from catalogue.utils import split_tags
17 from fnpdjango.utils.text.slughifi import slughifi
18 from picture import tasks
19 from StringIO import StringIO
20 import jsonfield
21 import itertools
22 import logging
23
24 from PIL import Image
25
26 from django.utils.translation import get_language, ugettext_lazy as _
27 from newtagging import managers
28 from os import path
29
30
31 permanent_cache = caches['permanent']
32
33 picture_storage = FileSystemStorage(location=path.join(
34         settings.MEDIA_ROOT, 'pictures'),
35         base_url=settings.MEDIA_URL + "pictures/")
36
37
38 class PictureArea(models.Model):
39     picture = models.ForeignKey('picture.Picture', related_name='areas')
40     area = jsonfield.JSONField(_('area'), default={}, editable=False)
41     kind = models.CharField(_('kind'), max_length=10, blank=False,
42                            null=False, db_index=True,
43                            choices=(('thing', _('thing')),
44                                     ('theme', _('theme'))))
45
46     objects     = models.Manager()
47     tagged      = managers.ModelTaggedItemManager(catalogue.models.Tag)
48     tags        = managers.TagDescriptor(catalogue.models.Tag)
49     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
50
51     @classmethod
52     def rectangle(cls, picture, kind, coords):
53         pa = PictureArea()
54         pa.picture = picture
55         pa.kind = kind
56         pa.area = coords
57         return pa
58
59     def reset_short_html(self):
60         if self.id is None:
61             return
62
63         cache_key = "PictureArea.short_html/%d/%s"
64         for lang, langname in settings.LANGUAGES:
65             permanent_cache.delete(cache_key % (self.id, lang))
66
67
68     def short_html(self):
69         if self.id:
70             cache_key = "PictureArea.short_html/%d/%s" % (self.id, get_language())
71             short_html = permanent_cache.get(cache_key)
72         else:
73             short_html = None
74
75         if short_html is not None:
76             return mark_safe(short_html)
77         else:
78             theme = self.tags.filter(category='theme')
79             theme = theme and theme[0] or None
80             thing = self.tags.filter(category='thing')
81             thing = thing and thing[0] or None
82             area = self
83             short_html = unicode(render_to_string(
84                     'picture/picturearea_short.html', locals()))
85             if self.id:
86                 permanent_cache.set(cache_key, short_html)
87             return mark_safe(short_html)
88
89
90 class Picture(models.Model):
91     """
92     Picture resource.
93
94     """
95     title       = models.CharField(_('title'), max_length=120)
96     slug        = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
97     sort_key    = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
98     sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
99     created_at  = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
100     changed_at  = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
101     xml_file    = models.FileField('xml_file', upload_to="xml", storage=picture_storage)
102     image_file  = ImageField(_('image_file'), upload_to="images", storage=picture_storage)
103     html_file   = models.FileField('html_file', upload_to="html", storage=picture_storage)
104     areas_json       = jsonfield.JSONField(_('picture areas JSON'), default={}, editable=False)
105     extra_info    = jsonfield.JSONField(_('extra information'), default={})
106     culturepl_link   = models.CharField(blank=True, max_length=240)
107     wiki_link     = models.CharField(blank=True, max_length=240)
108
109     _related_info = jsonfield.JSONField(blank=True, null=True, editable=False)
110
111     width       = models.IntegerField(null=True)
112     height      = models.IntegerField(null=True)
113
114     objects     = models.Manager()
115     tagged      = managers.ModelTaggedItemManager(catalogue.models.Tag)
116     tags        = managers.TagDescriptor(catalogue.models.Tag)
117     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
118
119     class AlreadyExists(Exception):
120         pass
121
122     class Meta:
123         ordering = ('sort_key',)
124
125         verbose_name = _('picture')
126         verbose_name_plural = _('pictures')
127
128     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
129         from sortify import sortify
130
131         self.sort_key = sortify(self.title)
132
133         ret = super(Picture, self).save(force_insert, force_update)
134
135         if reset_short_html:
136             self.reset_short_html()
137
138         return ret
139
140     def __unicode__(self):
141         return self.title
142
143     @permalink
144     def get_absolute_url(self):
145         return ('picture.views.picture_detail', [self.slug])
146
147     @classmethod
148     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False):
149         """
150         Import xml and it's accompanying image file.
151         If image file is missing, it will be fetched by librarian.picture.ImageStore
152         which looks for an image file in the same directory the xml is, with extension matching
153         its mime type.
154         """
155         from sortify import sortify
156         from django.core.files import File
157         from librarian.picture import WLPicture, ImageStore
158         close_xml_file = False
159         close_image_file = False
160
161
162         if image_file is not None and not isinstance(image_file, File):
163             image_file = File(open(image_file))
164             close_image_file = True
165
166         if not isinstance(xml_file, File):
167             xml_file = File(open(xml_file))
168             close_xml_file = True
169
170         try:
171             # use librarian to parse meta-data
172             if image_store is None:
173                 image_store = ImageStore(picture_storage.path('images'))
174             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
175
176             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug)
177             if not created and not overwrite:
178                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
179
180             picture.areas.all().delete()
181             picture.title = unicode(picture_xml.picture_info.title)
182             picture.extra_info = picture_xml.picture_info.to_dict()
183
184             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
185             motif_tags = set()
186             thing_tags = set()
187
188             area_data = {'themes':{}, 'things':{}}
189
190             for part in picture_xml.partiter():
191                 if picture_xml.frame:
192                     c = picture_xml.frame[0]
193                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
194                 if part.get('object', None) is not None:
195                     objname = part['object']
196                     tag, created = catalogue.models.Tag.objects.get_or_create(slug=slughifi(objname), category='thing')
197                     if created:
198                         tag.name = objname
199                         tag.sort_key = sortify(tag.name)
200                         tag.save()
201                     #thing_tags.add(tag)
202                     area_data['things'][tag.slug] = {
203                         'object': part['object'],
204                         'coords': part['coords'],
205                         }
206                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
207                     area.save()
208                     _tags = set()
209                     _tags.add(tag)
210                     area.tags = _tags
211                 else:
212                     _tags = set()
213                     for motif in part['themes']:
214                         tag, created = catalogue.models.Tag.objects.get_or_create(slug=slughifi(motif), category='theme')
215                         if created:
216                             tag.name = motif
217                             tag.sort_key = sortify(tag.name)
218                             tag.save()
219                         #motif_tags.add(tag)
220                         _tags.add(tag)
221                         area_data['themes'][tag.slug] = {
222                             'theme': motif,
223                             'coords': part['coords']
224                             }
225
226                     logging.debug("coords for theme: %s" % part['coords'])
227                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
228                     area.save()
229                     area.tags = _tags.union(picture_tags)
230
231             picture.tags = picture_tags.union(motif_tags).union(thing_tags)
232             picture.areas_json = area_data
233
234             if image_file is not None:
235                 img = image_file
236             else:
237                 img = picture_xml.image_file()
238
239             modified = cls.crop_to_frame(picture_xml, img)
240             modified = cls.add_source_note(picture_xml, modified)
241
242             picture.width, picture.height = modified.size
243
244             modified_file = StringIO()
245             modified.save(modified_file, format='png', quality=95)
246             # FIXME: hardcoded extension - detect from DC format or orginal filename
247             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
248
249             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
250             picture.save()
251             tasks.generate_picture_html(picture.id)
252
253         except Exception, ex:
254             logging.exception("Exception during import, rolling back")
255             transaction.rollback()
256             raise ex
257
258         finally:
259             if close_xml_file:
260                 xml_file.close()
261             if close_image_file:
262                 image_file.close()
263
264         transaction.commit()
265
266         return picture
267
268     @classmethod
269     def crop_to_frame(cls, wlpic, image_file):
270         img = Image.open(image_file)
271         if wlpic.frame is None:
272             return img
273         img = img.crop(itertools.chain(*wlpic.frame))
274         return img
275
276     @staticmethod
277     def add_source_note(wlpic, img):
278         from PIL import ImageDraw, ImageFont
279         from librarian import get_resource
280
281         annotated = Image.new(img.mode,
282                 (img.size[0], img.size[1] + 40),
283                 (255, 255, 255)
284             )
285         annotated.paste(img, (0, 0))
286         annotation = Image.new(img.mode, (3000, 120), (255, 255, 255))
287         ImageDraw.Draw(annotation).text(
288             (30, 15),
289             wlpic.picture_info.source_name,
290             (0, 0, 0),
291             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
292         )
293         annotated.paste(annotation.resize((1000, 40), Image.ANTIALIAS), (0, img.size[1]))
294         return annotated
295
296     @classmethod
297     def picture_list(cls, filter=None):
298         """Generates a hierarchical listing of all pictures
299         Pictures are optionally filtered with a test function.
300         """
301
302         pics = cls.objects.all().order_by('sort_key')\
303             .only('title', 'slug', 'image_file')
304
305         if filter:
306             pics = pics.filter(filter).distinct()
307
308         pics_by_author = SortedDict()
309         orphans = []
310         for tag in catalogue.models.Tag.objects.filter(category='author'):
311             pics_by_author[tag] = []
312
313         for pic in pics.iterator():
314             authors = list(pic.tags.filter(category='author'))
315             if authors:
316                 for author in authors:
317                     pics_by_author[author].append(pic)
318             else:
319                 orphans.append(pic)
320
321         return pics_by_author, orphans
322
323     @property
324     def info(self):
325         if not hasattr(self, '_info'):
326             from librarian import dcparser
327             from librarian import picture
328             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
329             self._info = info
330         return self._info
331
332     def reset_short_html(self):
333         if self.id is None:
334             return
335
336         type(self).objects.filter(pk=self.pk).update(_related_info=None)
337         for area in self.areas.all().iterator():
338             area.reset_short_html()
339
340         try:
341             author = self.tags.filter(category='author')[0].sort_key
342         except IndexError:
343             author = u''
344         type(self).objects.filter(pk=self.pk).update(sort_key_author=author)
345
346         cache_key = "Picture.short_html/%d/%s"
347         for lang, langname in settings.LANGUAGES:
348             permanent_cache.delete(cache_key % (self.id, lang))
349
350     def short_html(self):
351         if self.id:
352             cache_key = "Picture.short_html/%d/%s" % (self.id, get_language())
353             short_html = get_cache('permanent').get(cache_key)
354         else:
355             short_html = None
356
357         if short_html is not None:
358             return mark_safe(short_html)
359         else:
360             tags = self.tags.filter(category__in=('author', 'kind', 'epoch', 'genre'))
361             tags = split_tags(tags)
362
363             short_html = unicode(render_to_string(
364                     'picture/picture_short.html',
365                     {'picture': self, 'tags': tags}))
366
367             if self.id:
368                 get_cache('permanent').set(cache_key, short_html)
369             return mark_safe(short_html)
370
371     def pretty_title(self, html_links=False):
372         picture = self
373         # TODO Add translations (related_tag_info)
374         names = [(tag.name,
375                   catalogue.models.Tag.create_url('author', tag.slug))
376                  for tag in self.tags.filter(category='author')]
377         names.append((self.title, self.get_absolute_url()))
378
379         if html_links:
380             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
381         else:
382             names = [tag[0] for tag in names]
383         return ', '.join(names)
384
385     def related_info(self):
386         """Keeps info about related objects (tags) in cache field."""
387         if self._related_info is not None:
388             return self._related_info
389         else:
390             rel = {'tags': {}}
391
392             tags = self.tags.filter(category__in=(
393                     'author', 'kind', 'genre', 'epoch'))
394             tags = split_tags(tags)
395             for category in tags:
396                 cat = []
397                 for tag in tags[category]:
398                     tag_info = {'slug': tag.slug, 'name': tag.name}
399                     for lc, ln in settings.LANGUAGES:
400                         tag_name = getattr(tag, "name_%s" % lc)
401                         if tag_name:
402                             tag_info["name_%s" % lc] = tag_name
403                     cat.append(tag_info)
404                 rel['tags'][category] = cat
405
406
407             if self.pk:
408                 type(self).objects.filter(pk=self.pk).update(_related_info=rel)
409             return rel
410
411     # copied from book.py, figure out
412     def related_themes(self):
413         # self.theme_counter hides a computation, so a line below actually makes sense
414         theme_counter = self.theme_counter
415         picture_themes = list(catalogue.models.Tag.objects.filter(pk__in=theme_counter.keys()))
416         for tag in picture_themes:
417             tag.count = theme_counter[tag.pk]
418         return picture_themes
419
420     def reset_tag_counter(self):
421         if self.id is None:
422             return
423
424         cache_key = "Picture.tag_counter/%d" % self.id
425         permanent_cache.delete(cache_key)
426         if self.parent:
427             self.parent.reset_tag_counter()
428
429     @property
430     def tag_counter(self):
431         if self.id:
432             cache_key = "Picture.tag_counter/%d" % self.id
433             tags = permanent_cache.get(cache_key)
434         else:
435             tags = None
436
437         if tags is None:
438             tags = {}
439             # do we need to do this? there are no children here.
440             for tag in self.tags.exclude(category__in=('book', 'theme', 'thing', 'set')).order_by().iterator():
441                 tags[tag.pk] = 1
442
443             if self.id:
444                 permanent_cache.set(cache_key, tags)
445         return tags
446
447     def reset_theme_counter(self):
448         if self.id is None:
449             return
450
451         cache_key = "Picture.theme_counter/%d" % self.id
452         permanent_cache.delete(cache_key)
453
454     @property
455     def theme_counter(self):
456         if self.id:
457             cache_key = "Picture.theme_counter/%d" % self.id
458             tags = permanent_cache.get(cache_key)
459         else:
460             tags = None
461
462         if tags is None:
463             tags = {}
464             for area in PictureArea.objects.filter(picture=self).order_by().iterator():
465                 for tag in area.tags.filter(category__in=('theme', 'thing')).order_by().iterator():
466                     tags[tag.pk] = tags.get(tag.pk, 0) + 1
467
468             if self.id:
469                 permanent_cache.set(cache_key, tags)
470         return tags