box spacing for pictures
[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             picture.width, picture.height = modified.size
237
238             modified_file = StringIO()
239             modified.save(modified_file, format='png', quality=95)
240             # FIXME: hardcoded extension - detect from DC format or orginal filename
241             picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
242
243             picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
244             picture.save()
245             tasks.generate_picture_html(picture.id)
246
247         except Exception, ex:
248             logging.exception("Exception during import, rolling back")
249             transaction.rollback()
250             raise ex
251
252         finally:
253             if close_xml_file:
254                 xml_file.close()
255             if close_image_file:
256                 image_file.close()
257
258         transaction.commit()
259
260         return picture
261
262     @classmethod
263     def crop_to_frame(cls, wlpic, image_file):
264         img = Image.open(image_file)
265         if wlpic.frame is None:
266             return img
267         img = img.crop(itertools.chain(*wlpic.frame))
268         return img
269
270     @classmethod
271     def picture_list(cls, filter=None):
272         """Generates a hierarchical listing of all pictures
273         Pictures are optionally filtered with a test function.
274         """
275
276         pics = cls.objects.all().order_by('sort_key')\
277             .only('title', 'slug', 'image_file')
278
279         if filter:
280             pics = pics.filter(filter).distinct()
281
282         pics_by_author = SortedDict()
283         orphans = []
284         for tag in catalogue.models.Tag.objects.filter(category='author'):
285             pics_by_author[tag] = []
286
287         for pic in pics.iterator():
288             authors = list(pic.tags.filter(category='author'))
289             if authors:
290                 for author in authors:
291                     pics_by_author[author].append(pic)
292             else:
293                 orphans.append(pic)
294
295         return pics_by_author, orphans
296
297     @property
298     def info(self):
299         if not hasattr(self, '_info'):
300             from librarian import dcparser
301             from librarian import picture
302             info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
303             self._info = info
304         return self._info
305
306     def reset_short_html(self):
307         if self.id is None:
308             return
309         
310         type(self).objects.filter(pk=self.pk).update(_related_info=None)
311         for area in self.areas.all().iterator():
312             area.reset_short_html()
313
314         try: 
315             author = self.tags.filter(category='author')[0].sort_key
316         except IndexError:
317             author = u''
318         type(self).objects.filter(pk=self.pk).update(sort_key_author=author)
319
320         cache_key = "Picture.short_html/%d/%s"
321         for lang, langname in settings.LANGUAGES:
322             permanent_cache.delete(cache_key % (self.id, lang))
323
324     def short_html(self):
325         if self.id:
326             cache_key = "Picture.short_html/%d/%s" % (self.id, get_language())
327             short_html = get_cache('permanent').get(cache_key)
328         else:
329             short_html = None
330
331         if short_html is not None:
332             return mark_safe(short_html)
333         else:
334             tags = self.tags.filter(category__in=('author', 'kind', 'epoch', 'genre'))
335             tags = split_tags(tags)
336
337             short_html = unicode(render_to_string(
338                     'picture/picture_short.html',
339                     {'picture': self, 'tags': tags}))
340
341             if self.id:
342                 get_cache('permanent').set(cache_key, short_html)
343             return mark_safe(short_html)
344
345     def pretty_title(self, html_links=False):
346         picture = self
347         # TODO Add translations (related_tag_info)
348         names = [(tag.name,
349                   catalogue.models.Tag.create_url('author', tag.slug))
350                  for tag in self.tags.filter(category='author')]
351         names.append((self.title, self.get_absolute_url()))
352
353         if html_links:
354             names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
355         else:
356             names = [tag[0] for tag in names]
357         return ', '.join(names)
358
359     def related_info(self):
360         """Keeps info about related objects (tags) in cache field."""
361         if self._related_info is not None:
362             return self._related_info
363         else:
364             rel = {'tags': {}}
365
366             tags = self.tags.filter(category__in=(
367                     'author', 'kind', 'genre', 'epoch'))
368             tags = split_tags(tags)
369             for category in tags:
370                 cat = []
371                 for tag in tags[category]:
372                     tag_info = {'slug': tag.slug, 'name': tag.name}
373                     for lc, ln in settings.LANGUAGES:
374                         tag_name = getattr(tag, "name_%s" % lc)
375                         if tag_name:
376                             tag_info["name_%s" % lc] = tag_name
377                     cat.append(tag_info)
378                 rel['tags'][category] = cat
379             
380
381             if self.pk:
382                 type(self).objects.filter(pk=self.pk).update(_related_info=rel)
383             return rel
384
385     # copied from book.py, figure out 
386     def related_themes(self):
387         # self.theme_counter hides a computation, so a line below actually makes sense
388         theme_counter = self.theme_counter
389         picture_themes = list(catalogue.models.Tag.objects.filter(pk__in=theme_counter.keys()))
390         for tag in picture_themes:
391             tag.count = theme_counter[tag.pk]
392         return picture_themes
393
394     def reset_tag_counter(self):
395         if self.id is None:
396             return
397
398         cache_key = "Picture.tag_counter/%d" % self.id
399         permanent_cache.delete(cache_key)
400         if self.parent:
401             self.parent.reset_tag_counter()
402
403     @property
404     def tag_counter(self):
405         if self.id:
406             cache_key = "Picture.tag_counter/%d" % self.id
407             tags = permanent_cache.get(cache_key)
408         else:
409             tags = None
410
411         if tags is None:
412             tags = {}
413             # do we need to do this? there are no children here.
414             for tag in self.tags.exclude(category__in=('book', 'theme', 'thing', 'set')).order_by().iterator():
415                 tags[tag.pk] = 1
416
417             if self.id:
418                 permanent_cache.set(cache_key, tags)
419         return tags
420
421     def reset_theme_counter(self):
422         if self.id is None:
423             return
424
425         cache_key = "Picture.theme_counter/%d" % self.id
426         permanent_cache.delete(cache_key)
427
428     @property
429     def theme_counter(self):
430         if self.id:
431             cache_key = "Picture.theme_counter/%d" % self.id
432             tags = permanent_cache.get(cache_key)
433         else:
434             tags = None
435
436         if tags is None:
437             tags = {}
438             for area in PictureArea.objects.filter(picture=self).order_by().iterator():
439                 for tag in area.tags.filter(category__in=('theme','thing')).order_by().iterator():
440                     tags[tag.pk] = tags.get(tag.pk, 0) + 1
441
442             if self.id:
443                 permanent_cache.set(cache_key, tags)
444         return tags