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