Fix for unclickable cites in admin.
[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             # 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         except Exception, ex:
244             logging.exception("Exception during import, rolling back")
245             transaction.rollback()
246             raise ex
247
248         finally:
249             if close_xml_file:
250                 xml_file.close()
251             if close_image_file:
252                 image_file.close()
253
254         transaction.commit()
255
256         return picture
257
258     @classmethod
259     def crop_to_frame(cls, wlpic, image_file):
260         img = Image.open(image_file)
261         if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
262             return img
263         img = img.crop(itertools.chain(*wlpic.frame))
264         return img
265
266     @staticmethod
267     def add_source_note(wlpic, img):
268         from PIL import ImageDraw, ImageFont
269         from librarian import get_resource
270
271         annotated = Image.new(img.mode,
272                 (img.size[0], img.size[1] + 40),
273                 (255, 255, 255)
274             )
275         annotated.paste(img, (0, 0))
276         annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
277         ImageDraw.Draw(annotation).text(
278             (30, 15),
279             wlpic.picture_info.source_name,
280             (0, 0, 0),
281             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
282         )
283         annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
284         return annotated
285
286     @classmethod
287     def picture_list(cls, filter=None):
288         """Generates a hierarchical listing of all pictures
289         Pictures are optionally filtered with a test function.
290         """
291
292         pics = cls.objects.all().order_by('sort_key')\
293             .only('title', 'slug', 'image_file')
294
295         if filter:
296             pics = pics.filter(filter).distinct()
297
298         pics_by_author = SortedDict()
299         orphans = []
300         for tag in catalogue.models.Tag.objects.filter(category='author'):
301             pics_by_author[tag] = []
302
303         for pic in pics.iterator():
304             authors = list(pic.tags.filter(category='author'))
305             if authors:
306                 for author in authors:
307                     pics_by_author[author].append(pic)
308             else:
309                 orphans.append(pic)
310
311         return pics_by_author, orphans
312
313     @property
314     def info(self):
315         if not hasattr(self, '_info'):
316             from librarian import dcparser
317             from librarian import picture
318             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
319             self._info = info
320         return self._info
321
322     def pretty_title(self, html_links=False):
323         picture = self
324         names = [(tag.name, tag.get_absolute_url())
325                  for tag in self.tags.filter(category='author')]
326         names.append((self.title, self.get_absolute_url()))
327
328         if html_links:
329             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
330         else:
331             names = [tag[0] for tag in names]
332         return ', '.join(names)
333
334     def related_themes(self):
335         return catalogue.models.Tag.objects.usage_for_queryset(
336             self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
337
338     def flush_includes(self, languages=True):
339         if not languages:
340             return
341         if languages is True:
342             languages = [lc for (lc, _ln) in settings.LANGUAGES]
343         flush_ssi_includes([
344             template % (self.pk, lang)
345             for template in [
346                 '/katalog/p/%d/short.%s.html',
347                 '/katalog/p/%d/mini.%s.html',
348                 ]
349             for lang in languages
350             ])