Annotate pictures with source name.
[wolnelektury.git] / apps / picture / models.py
1 from django.db import models, transaction
2 import catalogue.models
3 from django.db.models import permalink
4 from sorl.thumbnail import ImageField
5 from django.conf import settings
6 from django.core.files.storage import FileSystemStorage
7 from django.utils.datastructures import SortedDict
8 from django.template.loader import render_to_string
9 from django.utils.safestring import mark_safe
10 from django.core.cache import get_cache
11 from catalogue.utils import split_tags, related_tag_name
12 from django.utils.safestring import mark_safe
13 from fnpdjango.utils.text.slughifi import slughifi
14 from picture import tasks
15 from StringIO import StringIO
16 import jsonfield
17 import itertools
18 import logging
19 from sorl.thumbnail import get_thumbnail, default
20 from .engine import CustomCroppingEngine
21
22 from PIL import Image
23
24 from django.utils.translation import get_language, ugettext_lazy as _
25 from newtagging import managers
26 from os import path
27
28
29 permanent_cache = get_cache('permanent')
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(_('kind'), max_length=10, blank=False, 
40                            null=False, db_index=True, 
41                            choices=(('thing', _('thing')), 
42                                     ('theme', _('theme'))))
43
44     objects     = models.Manager()
45     tagged      = managers.ModelTaggedItemManager(catalogue.models.Tag)
46     tags        = managers.TagDescriptor(catalogue.models.Tag)
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 reset_short_html(self):
57         if self.id is None:
58             return
59
60         cache_key = "PictureArea.short_html/%d/%s"
61         for lang, langname in settings.LANGUAGES:
62             permanent_cache.delete(cache_key % (self.id, lang))
63
64
65     def short_html(self):
66         if self.id:
67             cache_key = "PictureArea.short_html/%d/%s" % (self.id, get_language())
68             short_html = permanent_cache.get(cache_key)
69         else:
70             short_html = None
71     
72         if short_html is not None:
73             return mark_safe(short_html)
74         else:
75             theme = self.tags.filter(category='theme')
76             theme = theme and theme[0] or None
77             thing = self.tags.filter(category='thing')
78             thing = thing and thing[0] or None
79             area = self
80             short_html = unicode(render_to_string(
81                     'picture/picturearea_short.html', locals()))
82             if self.id:
83                 permanent_cache.set(cache_key, short_html)
84             return mark_safe(short_html)
85
86
87 class Picture(models.Model):
88     """
89     Picture resource.
90
91     """
92     title       = models.CharField(_('title'), max_length=120)
93     slug        = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
94     sort_key    = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
95     sort_key_author = models.CharField(_('sort key by author'), max_length=120, db_index=True, editable=False, default=u'')
96     created_at  = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
97     changed_at  = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
98     xml_file    = models.FileField('xml_file', upload_to="xml", storage=picture_storage)
99     image_file  = ImageField(_('image_file'), upload_to="images", storage=picture_storage)
100     html_file   = models.FileField('html_file', upload_to="html", storage=picture_storage)
101     areas_json       = jsonfield.JSONField(_('picture areas JSON'), default={}, editable=False)
102     extra_info    = jsonfield.JSONField(_('extra information'), default={})
103     culturepl_link   = models.CharField(blank=True, max_length=240)
104     wiki_link     = models.CharField(blank=True, max_length=240)
105
106     _related_info = jsonfield.JSONField(blank=True, null=True, editable=False)
107
108     width       = models.IntegerField(null=True)
109     height      = models.IntegerField(null=True)
110
111     objects     = models.Manager()
112     tagged      = managers.ModelTaggedItemManager(catalogue.models.Tag)
113     tags        = managers.TagDescriptor(catalogue.models.Tag)
114
115     class AlreadyExists(Exception):
116         pass
117
118     class Meta:
119         ordering = ('sort_key',)
120
121         verbose_name = _('picture')
122         verbose_name_plural = _('pictures')
123
124     def save(self, force_insert=False, force_update=False, reset_short_html=True, **kwargs):
125         from sortify import sortify
126
127         self.sort_key = sortify(self.title)
128
129         ret = super(Picture, self).save(force_insert, force_update)
130
131         if reset_short_html:
132             self.reset_short_html()
133
134         return ret
135
136     def __unicode__(self):
137         return self.title
138
139     @permalink
140     def get_absolute_url(self):
141         return ('picture.views.picture_detail', [self.slug])
142
143     @classmethod
144     def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False):
145         """
146         Import xml and it's accompanying image file.
147         If image file is missing, it will be fetched by librarian.picture.ImageStore
148         which looks for an image file in the same directory the xml is, with extension matching
149         its mime type.
150         """
151         from sortify import sortify
152         from django.core.files import File
153         from librarian.picture import WLPicture, ImageStore
154         close_xml_file = False
155         close_image_file = False
156
157
158         if image_file is not None and not isinstance(image_file, File):
159             image_file = File(open(image_file))
160             close_image_file = True
161
162         if not isinstance(xml_file, File):
163             xml_file = File(open(xml_file))
164             close_xml_file = True
165         
166         try:
167             # use librarian to parse meta-data
168             if image_store is None:
169                 image_store = ImageStore(picture_storage.path('images'))
170             picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
171
172             picture, created = Picture.objects.get_or_create(slug=picture_xml.slug)
173             if not created and not overwrite:
174                 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
175
176             picture.areas.all().delete()
177             picture.title = unicode(picture_xml.picture_info.title)
178             picture.extra_info = picture_xml.picture_info.to_dict()
179
180             picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
181             motif_tags = set()
182             thing_tags = set()
183
184             area_data = {'themes':{}, 'things':{}}
185
186             for part in picture_xml.partiter():
187                 if picture_xml.frame:
188                     c = picture_xml.frame[0]
189                     part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
190                 if part.get('object', None) is not None:
191                     objname = part['object']
192                     tag, created = catalogue.models.Tag.objects.get_or_create(slug=slughifi(objname), category='thing')
193                     if created:
194                         tag.name = objname
195                         tag.sort_key = sortify(tag.name)
196                         tag.save()
197                     #thing_tags.add(tag)
198                     area_data['things'][tag.slug] = {
199                         'object': part['object'],
200                         'coords': part['coords'],
201                         }
202                     area = PictureArea.rectangle(picture, 'thing', part['coords'])
203                     area.save()
204                     _tags = set()
205                     _tags.add(tag)
206                     area.tags = _tags
207                 else:
208                     _tags = set()
209                     for motif in part['themes']:
210                         tag, created = catalogue.models.Tag.objects.get_or_create(slug=slughifi(motif), category='theme')
211                         if created:
212                             tag.name = motif
213                             tag.sort_key = sortify(tag.name)
214                             tag.save()
215                         #motif_tags.add(tag)
216                         _tags.add(tag)
217                         area_data['themes'][tag.slug] = {
218                             'theme': motif,
219                             'coords': part['coords']
220                             }
221
222                     logging.debug("coords for theme: %s" % part['coords'])
223                     area = PictureArea.rectangle(picture, 'theme', part['coords'])
224                     area.save()
225                     area.tags = _tags.union(picture_tags)
226
227             picture.tags = picture_tags.union(motif_tags).union(thing_tags)
228             picture.areas_json = area_data
229
230             if image_file is not None:
231                 img = image_file
232             else:
233                 img = picture_xml.image_file()
234
235             modified = cls.crop_to_frame(picture_xml, img)
236             modified = cls.add_source_note(picture_xml, modified)
237
238             picture.width, picture.height = modified.size
239
240             modified_file = StringIO()
241             modified.save(modified_file, format='png', quality=95)
242             # FIXME: hardcoded extension - detect from DC format or orginal filename
243             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
244
245             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
246             picture.save()
247             tasks.generate_picture_html(picture.id)
248
249         except Exception, ex:
250             logging.exception("Exception during import, rolling back")
251             transaction.rollback()
252             raise ex
253
254         finally:
255             if close_xml_file:
256                 xml_file.close()
257             if close_image_file:
258                 image_file.close()
259
260         transaction.commit()
261
262         return picture
263
264     @classmethod
265     def crop_to_frame(cls, wlpic, image_file):
266         img = Image.open(image_file)
267         if wlpic.frame is None:
268             return img
269         img = img.crop(itertools.chain(*wlpic.frame))
270         return img
271
272     @staticmethod
273     def add_source_note(wlpic, img):
274         from PIL import ImageDraw, ImageFont
275         from librarian import get_resource
276
277         annotated = Image.new(img.mode, 
278                 (img.size[0], img.size[1] + 40), 
279                 (255, 255, 255)
280             )
281         annotated.paste(img, (0, 0))
282         annotation = Image.new(img.mode, (3000, 120), (255, 255, 255))
283         ImageDraw.Draw(annotation).text(
284             (30, 15),
285             wlpic.picture_info.source_name,
286             (0, 0, 0),
287             font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
288         )
289         annotated.paste(annotation.resize((1000, 40), Image.ANTIALIAS), (0, img.size[1]))
290         return annotated
291
292     @classmethod
293     def picture_list(cls, filter=None):
294         """Generates a hierarchical listing of all pictures
295         Pictures are optionally filtered with a test function.
296         """
297
298         pics = cls.objects.all().order_by('sort_key')\
299             .only('title', 'slug', 'image_file')
300
301         if filter:
302             pics = pics.filter(filter).distinct()
303
304         pics_by_author = SortedDict()
305         orphans = []
306         for tag in catalogue.models.Tag.objects.filter(category='author'):
307             pics_by_author[tag] = []
308
309         for pic in pics.iterator():
310             authors = list(pic.tags.filter(category='author'))
311             if authors:
312                 for author in authors:
313                     pics_by_author[author].append(pic)
314             else:
315                 orphans.append(pic)
316
317         return pics_by_author, orphans
318
319     @property
320     def info(self):
321         if not hasattr(self, '_info'):
322             from librarian import dcparser
323             from librarian import picture
324             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
325             self._info = info
326         return self._info
327
328     def reset_short_html(self):
329         if self.id is None:
330             return
331         
332         type(self).objects.filter(pk=self.pk).update(_related_info=None)
333         for area in self.areas.all().iterator():
334             area.reset_short_html()
335
336         try: 
337             author = self.tags.filter(category='author')[0].sort_key
338         except IndexError:
339             author = u''
340         type(self).objects.filter(pk=self.pk).update(sort_key_author=author)
341
342         cache_key = "Picture.short_html/%d/%s"
343         for lang, langname in settings.LANGUAGES:
344             permanent_cache.delete(cache_key % (self.id, lang))
345
346     def short_html(self):
347         if self.id:
348             cache_key = "Picture.short_html/%d/%s" % (self.id, get_language())
349             short_html = get_cache('permanent').get(cache_key)
350         else:
351             short_html = None
352
353         if short_html is not None:
354             return mark_safe(short_html)
355         else:
356             tags = self.tags.filter(category__in=('author', 'kind', 'epoch', 'genre'))
357             tags = split_tags(tags)
358
359             short_html = unicode(render_to_string(
360                     'picture/picture_short.html',
361                     {'picture': self, 'tags': tags}))
362
363             if self.id:
364                 get_cache('permanent').set(cache_key, short_html)
365             return mark_safe(short_html)
366
367     def pretty_title(self, html_links=False):
368         picture = self
369         # TODO Add translations (related_tag_info)
370         names = [(tag.name,
371                   catalogue.models.Tag.create_url('author', tag.slug))
372                  for tag in self.tags.filter(category='author')]
373         names.append((self.title, self.get_absolute_url()))
374
375         if html_links:
376             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
377         else:
378             names = [tag[0] for tag in names]
379         return ', '.join(names)
380
381     def related_info(self):
382         """Keeps info about related objects (tags) in cache field."""
383         if self._related_info is not None:
384             return self._related_info
385         else:
386             rel = {'tags': {}}
387
388             tags = self.tags.filter(category__in=(
389                     'author', 'kind', 'genre', 'epoch'))
390             tags = split_tags(tags)
391             for category in tags:
392                 cat = []
393                 for tag in tags[category]:
394                     tag_info = {'slug': tag.slug, 'name': tag.name}
395                     for lc, ln in settings.LANGUAGES:
396                         tag_name = getattr(tag, "name_%s" % lc)
397                         if tag_name:
398                             tag_info["name_%s" % lc] = tag_name
399                     cat.append(tag_info)
400                 rel['tags'][category] = cat
401             
402
403             if self.pk:
404                 type(self).objects.filter(pk=self.pk).update(_related_info=rel)
405             return rel
406
407     # copied from book.py, figure out 
408     def related_themes(self):
409         # self.theme_counter hides a computation, so a line below actually makes sense
410         theme_counter = self.theme_counter
411         picture_themes = list(catalogue.models.Tag.objects.filter(pk__in=theme_counter.keys()))
412         for tag in picture_themes:
413             tag.count = theme_counter[tag.pk]
414         return picture_themes
415
416     def reset_tag_counter(self):
417         if self.id is None:
418             return
419
420         cache_key = "Picture.tag_counter/%d" % self.id
421         permanent_cache.delete(cache_key)
422         if self.parent:
423             self.parent.reset_tag_counter()
424
425     @property
426     def tag_counter(self):
427         if self.id:
428             cache_key = "Picture.tag_counter/%d" % self.id
429             tags = permanent_cache.get(cache_key)
430         else:
431             tags = None
432
433         if tags is None:
434             tags = {}
435             # do we need to do this? there are no children here.
436             for tag in self.tags.exclude(category__in=('book', 'theme', 'thing', 'set')).order_by().iterator():
437                 tags[tag.pk] = 1
438
439             if self.id:
440                 permanent_cache.set(cache_key, tags)
441         return tags
442
443     def reset_theme_counter(self):
444         if self.id is None:
445             return
446
447         cache_key = "Picture.theme_counter/%d" % self.id
448         permanent_cache.delete(cache_key)
449
450     @property
451     def theme_counter(self):
452         if self.id:
453             cache_key = "Picture.theme_counter/%d" % self.id
454             tags = permanent_cache.get(cache_key)
455         else:
456             tags = None
457
458         if tags is None:
459             tags = {}
460             for area in PictureArea.objects.filter(picture=self).order_by().iterator():
461                 for tag in area.tags.filter(category__in=('theme','thing')).order_by().iterator():
462                     tags[tag.pk] = tags.get(tag.pk, 0) + 1
463
464             if self.id:
465                 permanent_cache.set(cache_key, tags)
466         return tags