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