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