Merge branch 'reflow'
[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.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         if image_file is not None and not isinstance(image_file, File):
167             image_file = File(open(image_file))
168             close_image_file = True
169
170         if not isinstance(xml_file, File):
171             xml_file = File(open(xml_file))
172             close_xml_file = True
173
174         with transaction.atomic():
175             # use librarian to parse meta-data
176             if image_store is None:
177                 image_store = ImageStore(picture_storage.path('images'))
178             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
179
180             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
181             if not created and not overwrite:
182                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
183
184             picture.areas.all().delete()
185             picture.title = unicode(picture_xml.picture_info.title)
186             picture.extra_info = picture_xml.picture_info.to_dict()
187
188             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
189             motif_tags = set()
190             thing_tags = set()
191
192             area_data = {'themes': {}, 'things': {}}
193
194             # Treat all names in picture XML as in default language.
195             lang = settings.LANGUAGE_CODE
196
197             for part in picture_xml.partiter():
198                 if picture_xml.frame:
199                     c = picture_xml.frame[0]
200                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
201                 if part.get('object', None) is not None:
202                     _tags = set()
203                     for objname in part['object'].split(','):
204                         objname = objname.strip().capitalize()
205                         tag, created = catalogue.models.Tag.objects.get_or_create(
206                             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(
227                                 slug=slughifi(motif), category='theme')
228                             if created:
229                                 tag.name = motif
230                                 tag.sort_key = sortify(tag.name)
231                                 tag.save()
232                             # motif_tags.add(tag)
233                             _tags.add(tag)
234                             area_data['themes'][tag.slug] = {
235                                 'theme': motif,
236                                 'coords': part['coords']
237                                 }
238
239                     logging.debug("coords for theme: %s" % part['coords'])
240                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
241                     area.save()
242                     area.tags = _tags.union(picture_tags)
243
244             picture.tags = picture_tags.union(motif_tags).union(thing_tags)
245             picture.areas_json = area_data
246
247             if image_file is not None:
248                 img = image_file
249             else:
250                 img = picture_xml.image_file()
251
252             modified = cls.crop_to_frame(picture_xml, img)
253             modified = cls.add_source_note(picture_xml, modified)
254
255             picture.width, picture.height = modified.size
256
257             modified_file = StringIO()
258             modified.save(modified_file, format='JPEG', quality=95)
259             # FIXME: hardcoded extension - detect from DC format or orginal filename
260             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
261
262             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
263             picture.save()
264             tasks.generate_picture_html(picture.id)
265
266         if close_xml_file:
267             xml_file.close()
268         if close_image_file:
269             image_file.close()
270
271         return picture
272
273     @classmethod
274     def crop_to_frame(cls, wlpic, image_file):
275         img = Image.open(image_file)
276         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
277             return img
278         img = img.crop(itertools.chain(*wlpic.frame))
279         return img
280
281     @staticmethod
282     def add_source_note(wlpic, img):
283         from PIL import ImageDraw, ImageFont
284         from librarian import get_resource
285
286         annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
287         annotated.paste(img, (0, 0))
288         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
289         ImageDraw.Draw(annotation).text(
290             (30, 15),
291             wlpic.picture_info.source_name,
292             (0, 0, 0),
293             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
294         )
295         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
296         return annotated
297
298     # WTF/unused
299     @classmethod
300     def picture_list(cls, filter=None):
301         """Generates a hierarchical listing of all pictures
302         Pictures are optionally filtered with a test function.
303         """
304
305         pics = cls.objects.all().order_by('sort_key').only('title', 'slug', 'image_file')
306
307         if filter:
308             pics = pics.filter(filter).distinct()
309
310         pics_by_author = SortedDict()
311         orphans = []
312         for tag in catalogue.models.Tag.objects.filter(category='author'):
313             pics_by_author[tag] = []
314
315         for pic in pics.iterator():
316             authors = list(pic.tags.filter(category='author'))
317             if authors:
318                 for author in authors:
319                     pics_by_author[author].append(pic)
320             else:
321                 orphans.append(pic)
322
323         return pics_by_author, orphans
324
325     @property
326     def info(self):
327         if not hasattr(self, '_info'):
328             from librarian import dcparser
329             from librarian import picture
330             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
331             self._info = info
332         return self._info
333
334     def pretty_title(self, html_links=False):
335         picture = self
336         names = [(tag.name, tag.get_absolute_url())
337                  for tag in self.tags.filter(category='author')]
338         names.append((self.title, self.get_absolute_url()))
339
340         if html_links:
341             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
342         else:
343             names = [tag[0] for tag in names]
344         return ', '.join(names)
345
346     def related_themes(self):
347         return catalogue.models.Tag.objects.usage_for_queryset(
348             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
349
350     def flush_includes(self, languages=True):
351         if not languages:
352             return
353         if languages is True:
354             languages = [lc for (lc, _ln) in settings.LANGUAGES]
355         flush_ssi_includes([
356             template % (self.pk, lang)
357             for template in [
358                 '/katalog/p/%d/short.%s.html',
359                 '/katalog/p/%d/mini.%s.html',
360                 ]
361             for lang in languages
362             ])