1 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
2 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 from django.db import models, transaction
5 import catalogue.models
6 from sorl.thumbnail import ImageField
7 from django.conf import settings
8 from django.contrib.contenttypes.fields import GenericRelation
9 from django.core.files.storage import FileSystemStorage
10 from django.urls import reverse
11 from slugify import slugify
13 from catalogue.models.tag import prefetched_relations
14 from catalogue.utils import split_tags
15 from picture import tasks
16 from wolnelektury.utils import cached_render, clear_cached_renders
17 from io import BytesIO
25 from django.utils.translation import gettext_lazy as _
26 from newtagging import managers
30 picture_storage = FileSystemStorage(location=path.join(
31 settings.MEDIA_ROOT, 'pictures'),
32 base_url=settings.MEDIA_URL + "pictures/")
35 class PictureArea(models.Model):
36 picture = models.ForeignKey('picture.Picture', models.CASCADE, related_name='areas')
37 area = models.TextField(_('area'), default='{}', editable=False)
38 kind = models.CharField(
39 _('kind'), max_length=10, blank=False, null=False, db_index=True,
40 choices=(('thing', _('thing')), ('theme', _('theme'))))
42 objects = models.Manager()
43 tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
44 tags = managers.TagDescriptor(catalogue.models.Tag)
45 tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
47 short_html_url_name = 'picture_area_short'
50 def rectangle(cls, picture, kind, coords):
54 pa.area = json.dumps(coords)
57 def get_area_json(self):
58 return json.loads(self.area)
60 @cached_render('picture/picturearea_short.html')
62 themes = self.tags.filter(category='theme')
63 things = self.tags.filter(category='thing')
66 'theme': themes[0] if themes else None,
67 'thing': things[0] if things else None,
70 def clear_cache(self):
71 clear_cached_renders(self.midi_box)
74 class Picture(models.Model):
79 title = models.CharField(_('title'), max_length=32767)
80 slug = models.SlugField(_('slug'), max_length=120, db_index=True, unique=True)
81 sort_key = models.CharField(_('sort key'), max_length=120, db_index=True, editable=False)
82 sort_key_author = models.CharField(
83 _('sort key by author'), max_length=120, db_index=True, editable=False, default='')
84 created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
85 changed_at = models.DateTimeField(_('creation date'), auto_now=True, db_index=True)
86 xml_file = models.FileField(_('xml file'), upload_to="xml", storage=picture_storage)
87 image_file = ImageField(_('image file'), upload_to="images", storage=picture_storage)
88 html_file = models.FileField(_('html file'), upload_to="html", storage=picture_storage)
89 areas_json = models.TextField(_('picture areas JSON'), default='{}', editable=False)
90 extra_info = models.TextField(_('extra information'), default='{}')
91 culturepl_link = models.CharField(blank=True, max_length=240)
92 wiki_link = models.CharField(blank=True, max_length=240)
94 width = models.IntegerField(null=True)
95 height = models.IntegerField(null=True)
97 objects = models.Manager()
98 tagged = managers.ModelTaggedItemManager(catalogue.models.Tag)
99 tags = managers.TagDescriptor(catalogue.models.Tag)
100 tag_relations = GenericRelation(catalogue.models.Tag.intermediary_table_model)
102 short_html_url_name = 'picture_short'
106 class AlreadyExists(Exception):
110 ordering = ('sort_key_author', 'sort_key')
112 verbose_name = _('picture')
113 verbose_name_plural = _('pictures')
115 def get_extra_info_json(self):
116 return json.loads(self.extra_info or '{}')
118 def save(self, force_insert=False, force_update=False, **kwargs):
119 from sortify import sortify
121 self.sort_key = sortify(self.title)[:120]
124 author = self.authors().first().sort_key
125 except AttributeError:
127 self.sort_key_author = author
129 ret = super(Picture, self).save(force_insert, force_update)
137 return self.tags.filter(category='author')
140 return self.tags.filter(category='epoch')
143 return self.tags.filter(category='genre')
146 return self.tags.filter(category='kind')
148 def tag_unicode(self, category):
149 relations = prefetched_relations(self, category)
151 return ', '.join(rel.tag.name for rel in relations)
153 return ', '.join(self.tags.filter(category=category).values_list('name', flat=True))
155 def author_unicode(self):
156 return self.tag_unicode('author')
158 def tags_by_category(self):
159 return split_tags(self.tags)
161 def get_absolute_url(self):
162 return reverse('picture_detail', args=[self.slug])
164 def get_initial(self):
166 return re.search(r'\w', self.title, re.U).group(0)
167 except AttributeError:
172 return type(self).objects.filter(sort_key__gt=self.sort_key)[0]
176 def get_previous(self):
178 return type(self).objects.filter(sort_key__lt=self.sort_key).order_by('-sort_key')[0]
183 def from_xml_file(cls, xml_file, image_file=None, image_store=None, overwrite=False):
185 Import xml and it's accompanying image file.
186 If image file is missing, it will be fetched by librarian.picture.ImageStore
187 which looks for an image file in the same directory the xml is, with extension matching
190 from sortify import sortify
191 from django.core.files import File
192 from librarian.picture import WLPicture, ImageStore
193 close_xml_file = False
194 close_image_file = False
196 if image_file is not None and not isinstance(image_file, File):
197 image_file = File(open(image_file, 'rb'))
198 close_image_file = True
200 if not isinstance(xml_file, File):
201 xml_file = File(open(xml_file))
202 close_xml_file = True
204 with transaction.atomic():
205 # use librarian to parse meta-data
206 if image_store is None:
207 image_store = ImageStore(picture_storage.path('images'))
208 picture_xml = WLPicture.from_file(xml_file, image_store=image_store)
210 picture, created = Picture.objects.get_or_create(slug=picture_xml.slug[:120])
211 if not created and not overwrite:
212 raise Picture.AlreadyExists('Picture %s already exists' % picture_xml.slug)
214 picture.areas.all().delete()
215 picture.title = str(picture_xml.picture_info.title)
216 picture.extra_info = json.dumps(picture_xml.picture_info.to_dict())
218 picture_tags = set(catalogue.models.Tag.tags_from_info(picture_xml.picture_info))
219 for tag in picture_tags:
220 if not tag.for_pictures:
221 tag.for_pictures = True
224 area_data = {'themes': {}, 'things': {}}
226 # Treat all names in picture XML as in default language.
227 lang = settings.LANGUAGE_CODE
229 for part in picture_xml.partiter():
230 if picture_xml.frame:
231 c = picture_xml.frame[0]
232 part['coords'] = [[p[0] - c[0], p[1] - c[1]] for p in part['coords']]
233 if part.get('object', None) is not None:
235 for objname in part['object'].split(','):
236 objname = objname.strip()
237 assert objname, 'Empty object name'
238 # str.capitalize() is wrong, because it also lowers letters
239 objname = objname[0].upper() + objname[1:]
240 tag, created = catalogue.models.Tag.objects.get_or_create(
241 slug=slugify(objname), category='thing')
244 setattr(tag, 'name_%s' % lang, tag.name)
245 tag.sort_key = sortify(tag.name)
246 tag.for_pictures = True
248 area_data['things'][tag.slug] = {
250 'coords': part['coords'],
254 if not tag.for_pictures:
255 tag.for_pictures = True
257 area = PictureArea.rectangle(picture, 'thing', part['coords'])
259 # WTF thing area does not inherit tags from picture and theme area does, is it intentional?
263 for motifs in part['themes']:
264 for motif in motifs.split(','):
265 tag, created = catalogue.models.Tag.objects.get_or_create(
266 slug=slugify(motif), category='theme')
269 tag.sort_key = sortify(tag.name)
270 tag.for_pictures = True
272 # motif_tags.add(tag)
274 if not tag.for_pictures:
275 tag.for_pictures = True
277 area_data['themes'][tag.slug] = {
279 'coords': part['coords']
282 logging.debug("coords for theme: %s" % part['coords'])
283 area = PictureArea.rectangle(picture, 'theme', part['coords'])
285 area.tags = _tags.union(picture_tags)
287 picture.tags = picture_tags
288 picture.areas_json = json.dumps(area_data)
290 if image_file is not None:
293 img = picture_xml.image_file()
295 modified = cls.crop_to_frame(picture_xml, img)
296 modified = cls.add_source_note(picture_xml, modified)
298 picture.width, picture.height = modified.size
300 modified_file = BytesIO()
301 modified.save(modified_file, format='JPEG', quality=95)
302 # FIXME: hardcoded extension - detect from DC format or orginal filename
303 picture.image_file.save(path.basename(picture_xml.image_path), File(modified_file))
305 picture.xml_file.save("%s.xml" % picture.slug, File(xml_file))
307 tasks.generate_picture_html(picture.id)
317 def crop_to_frame(cls, wlpic, image_file):
318 img = Image.open(image_file)
319 if wlpic.frame is None or wlpic.frame == [[0, 0], [-1, -1]]:
321 img = img.crop(itertools.chain(*wlpic.frame))
325 def add_source_note(wlpic, img):
326 from PIL import ImageDraw, ImageFont
327 from librarian import get_resource
329 annotated = Image.new(img.mode, (img.size[0], img.size[1] + 40), (255, 255, 255))
330 annotated.paste(img, (0, 0))
331 annotation = Image.new('RGB', (img.size[0] * 3, 120), (255, 255, 255))
332 ImageDraw.Draw(annotation).text(
334 wlpic.picture_info.source_name,
336 font=ImageFont.truetype(get_resource("fonts/DejaVuSerif.ttf"), 75)
338 annotated.paste(annotation.resize((img.size[0], 40), Image.ANTIALIAS), (0, img.size[1]))
343 if not hasattr(self, '_info'):
344 from librarian import dcparser
345 from librarian import picture
346 info = dcparser.parse(self.xml_file.path, picture.PictureInfo)
350 def pretty_title(self, html_links=False):
351 names = [(tag.name, tag.get_absolute_url()) for tag in self.authors().only('name', 'category', 'slug')]
352 names.append((self.title, self.get_absolute_url()))
355 names = ['<a href="%s">%s</a>' % (tag[1], tag[0]) for tag in names]
357 names = [tag[0] for tag in names]
358 return ', '.join(names)
360 @cached_render('picture/picture_mini_box.html')
366 @cached_render('picture/picture_short.html')
372 def related_themes(self):
373 return catalogue.models.Tag.objects.usage_for_queryset(
374 self.areas.all(), counts=True).filter(category__in=('theme', 'thing'))
376 def clear_cache(self):
377 clear_cached_renders(self.mini_box)
378 clear_cached_renders(self.midi_box)