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