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