book mini box: cache instead of ssi
[wolnelektury.git] / src / 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 fnpdjango.utils.text.slughifi import slughifi
14 from ssify import flush_ssi_includes
15 from picture import tasks
16 from StringIO import StringIO
17 import jsonfield
18 import itertools
19 import logging
20 import re
21
22 from PIL import Image
23
24 from django.utils.translation import ugettext_lazy as _
25 from newtagging import managers
26 from os import path
27
28
29 picture_storage = FileSystemStorage(location=path.join(
30         settings.MEDIA_ROOT, 'pictures'),
31         base_url=settings.MEDIA_URL + "pictures/")
32
33
34 class PictureArea(models.Model):
35     picture = models.ForeignKey('picture.Picture', related_name='areas')
36     area = jsonfield.JSONField(_('area'), default={}, editable=False)
37     kind = models.CharField(
38         _('kind'), max_length=10, blank=False, null=False, db_index=True,
39         choices=(('thing', _('thing')), ('theme', _('theme'))))
40
41     objects = models.Manager()
42     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
43     tags = managers.TagDescriptor(catalogue.models.Tag)
44     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
45
46     short_html_url_name = 'picture_area_short'
47
48     @classmethod
49     def rectangle(cls, picture, kind, coords):
50         pa = PictureArea()
51         pa.picture = picture
52         pa.kind = kind
53         pa.area = coords
54         return pa
55
56     def flush_includes(self, languages=True):
57         if not languages:
58             return
59         if languages is True:
60             languages = [lc for (lc, _ln) in settings.LANGUAGES]
61         flush_ssi_includes([
62             template % (self.pk, lang)
63             for template in [
64                 '/katalog/pa/%d/short.%s.html',
65                 ]
66             for lang in languages
67             ])
68
69
70 class Picture(models.Model):
71     """
72     Picture resource.
73
74     """
75     title = models.CharField(_('title'), max_length=32767)
76     slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
77     sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
78     sort_key_author = models.CharField(
79         _('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
80     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
81     changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
82     xml_file = models.FileField(_('xml file'), upload_to="xml", storage=picture_storage)
83     image_file = ImageField(_('image file'), upload_to="images", storage=picture_storage)
84     html_file = models.FileField(_('html file'), upload_to="html", storage=picture_storage)
85     areas_json = jsonfield.JSONField(_('picture areas JSON'), default={}, editable=False)
86     extra_info = jsonfield.JSONField(_('extra information'), default={})
87     culturepl_link = models.CharField(blank=True, max_length=240)
88     wiki_link = models.CharField(blank=True, max_length=240)
89
90     width = models.IntegerField(null=True)
91     height = models.IntegerField(null=True)
92
93     objects = models.Manager()
94     tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
95     tags = managers.TagDescriptor(catalogue.models.Tag)
96     tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
97
98     short_html_url_name = 'picture_short'
99
100     class AlreadyExists(Exception):
101         pass
102
103     class Meta:
104         ordering = ('sort_key',)
105
106         verbose_name = _('picture')
107         verbose_name_plural = _('pictures')
108
109     def save(self, force_insert=False, force_update=False, **kwargs):
110         from sortify import sortify
111
112         self.sort_key = sortify(self.title)[:120]
113
114         try:
115             author = self.authors().first().sort_key
116         except AttributeError:
117             author = u''
118         self.sort_key_author = author
119
120         ret = super(Picture, self).save(force_insert, force_update)
121
122         return ret
123
124     def __unicode__(self):
125         return self.title
126
127     def authors(self):
128         return self.tags.filter(category='author')
129
130     def author_unicode(self):
131         return ", ".join(self.authors().values_list('name', flat=True))
132
133     @permalink
134     def get_absolute_url(self):
135         return 'picture.views.picture_detail', [self.slug]
136
137     def get_initial(self):
138         try:
139             return re.search(r'\w', self.title, re.U).group(0)
140         except AttributeError:
141             return ''
142
143     def get_next(self):
144         try:
145             return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
146         except IndexError:
147             return None
148
149     def get_previous(self):
150         try:
151             return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
152         except IndexError:
153             return None
154
155     @classmethod
156     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False):
157         """
158         Import xml and it's accompanying image file.
159         If image file is missing, it will be fetched by librarian.picture.ImageStore
160         which looks for an image file in the same directory the xml is, with extension matching
161         its mime type.
162         """
163         from sortify import sortify
164         from django.core.files import File
165         from librarian.picture import WLPicture, ImageStore
166         close_xml_file = False
167         close_image_file = False
168
169         if image_file is not None and not isinstance(image_file, File):
170             image_file = File(open(image_file))
171             close_image_file = True
172
173         if not isinstance(xml_file, File):
174             xml_file = File(open(xml_file))
175             close_xml_file = True
176
177         with transaction.atomic():
178             # use librarian to parse meta-data
179             if image_store is None:
180                 image_store = ImageStore(picture_storage.path('images'))
181             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
182
183             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
184             if not created and not overwrite:
185                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
186
187             picture.areas.all().delete()
188             picture.title = unicode(picture_xml.picture_info.title)
189             picture.extra_info = picture_xml.picture_info.to_dict()
190
191             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
192             motif_tags = set()
193             thing_tags = set()
194
195             area_data = {'themes': {}, 'things': {}}
196
197             # Treat all names in picture XML as in default language.
198             lang = settings.LANGUAGE_CODE
199
200             for part in picture_xml.partiter():
201                 if picture_xml.frame:
202                     c = picture_xml.frame[0]
203                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
204                 if part.get('object', None) is not None:
205                     _tags = set()
206                     for objname in part['object'].split(','):
207                         objname = objname.strip().capitalize()
208                         tag, created = catalogue.models.Tag.objects.get_or_create(
209                             slug=slughifi(objname), category='thing')
210                         if created:
211                             tag.name = objname
212                             setattr(tag, 'name_%s' % lang, tag.name)
213                             tag.sort_key = sortify(tag.name)
214                             tag.save()
215                         # thing_tags.add(tag)
216                         area_data['things'][tag.slug] = {
217                             'object': objname,
218                             'coords': part['coords'],
219                             }
220
221                         _tags.add(tag)
222                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
223                     area.save()
224                     area.tags = _tags
225                 else:
226                     _tags = set()
227                     for motifs in part['themes']:
228                         for motif in motifs.split(','):
229                             tag, created = catalogue.models.Tag.objects.get_or_create(
230                                 slug=slughifi(motif), category='theme')
231                             if created:
232                                 tag.name = motif
233                                 tag.sort_key = sortify(tag.name)
234                                 tag.save()
235                             # motif_tags.add(tag)
236                             _tags.add(tag)
237                             area_data['themes'][tag.slug] = {
238                                 'theme': motif,
239                                 'coords': part['coords']
240                                 }
241
242                     logging.debug("coords for theme: %s" % part['coords'])
243                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
244                     area.save()
245                     area.tags = _tags.union(picture_tags)
246
247             picture.tags = picture_tags.union(motif_tags).union(thing_tags)
248             picture.areas_json = area_data
249
250             if image_file is not None:
251                 img = image_file
252             else:
253                 img = picture_xml.image_file()
254
255             modified = cls.crop_to_frame(picture_xml, img)
256             modified = cls.add_source_note(picture_xml, modified)
257
258             picture.width, picture.height = modified.size
259
260             modified_file = StringIO()
261             modified.save(modified_file, format='JPEG', quality=95)
262             # FIXME: hardcoded extension - detect from DC format or orginal filename
263             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
264
265             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
266             picture.save()
267             tasks.generate_picture_html(picture.id)
268
269         if close_xml_file:
270             xml_file.close()
271         if close_image_file:
272             image_file.close()
273
274         return picture
275
276     @classmethod
277     def crop_to_frame(cls, wlpic, image_file):
278         img = Image.open(image_file)
279         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
280             return img
281         img = img.crop(itertools.chain(*wlpic.frame))
282         return img
283
284     @staticmethod
285     def add_source_note(wlpic, img):
286         from PIL import ImageDraw, ImageFont
287         from librarian import get_resource
288
289         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
290         annotated.paste(img, (0, 0))
291         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
292         ImageDraw.Draw(annotation).text(
293             (30, 15),
294             wlpic.picture_info.source_name,
295             (0, 0, 0),
296             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
297         )
298         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
299         return annotated
300
301     # WTF/unused
302     @classmethod
303     def picture_list(cls, filter=None):
304         """Generates a hierarchical listing of all pictures
305         Pictures are optionally filtered with a test function.
306         """
307
308         pics = cls.objects.all().order_by('sort_key').only('title', 'slug', 'image_file')
309
310         if filter:
311             pics = pics.filter(filter).distinct()
312
313         pics_by_author = SortedDict()
314         orphans = []
315         for tag in catalogue.models.Tag.objects.filter(category='author'):
316             pics_by_author[tag] = []
317
318         for pic in pics.iterator():
319             authors = list(pic.authors().only('pk'))
320             if authors:
321                 for author in authors:
322                     pics_by_author[author].append(pic)
323             else:
324                 orphans.append(pic)
325
326         return pics_by_author, orphans
327
328     @property
329     def info(self):
330         if not hasattr(self, '_info'):
331             from librarian import dcparser
332             from librarian import picture
333             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
334             self._info = info
335         return self._info
336
337     def pretty_title(self, html_links=False):
338         names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
339         names.append((self.title, self.get_absolute_url()))
340
341         if html_links:
342             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
343         else:
344             names = [tag[0] for tag in names]
345         return ', '.join(names)
346
347     def related_themes(self):
348         return catalogue.models.Tag.objects.usage_for_queryset(
349             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
350
351     def flush_includes(self, languages=True):
352         if not languages:
353             return
354         if languages is True:
355             languages = [lc for (lc, _ln) in settings.LANGUAGES]
356         flush_ssi_includes([
357             template % (self.pk, lang)
358             for template in [
359                 '/katalog/p/%d/short.%s.html',
360                 '/katalog/p/%d/mini.%s.html',
361                 ]
362             for lang in languages
363             ])