From 00ca418a8bb2712ded3a12621a6bab19bc4d0c3d Mon Sep 17 00:00:00 2001 From: Jamie Lee Date: Tue, 31 Jul 2018 10:14:38 -0700 Subject: [PATCH] Revert "EXIF/XMP image metadata reading" --- .travis.yml | 5 - dispatch/api/serializers.py | 27 ++-- dispatch/migrations/0013_image_caption.py | 20 --- ...014_user_groups.py => 0013_user_groups.py} | 2 +- ..._management.py => 0014_user_management.py} | 2 +- dispatch/modules/content/image.py | 134 ------------------ dispatch/modules/content/mixins.py | 28 ---- dispatch/modules/content/models.py | 96 ++++++++----- dispatch/modules/content/render.py | 13 +- dispatch/tests/input/test_exif_xmp.jpg | Bin 22720 -> 0 bytes .../tests/input/test_exif_xmp_different.jpg | Bin 22944 -> 0 bytes dispatch/tests/test_api_images.py | 45 +----- setup.py | 1 - 13 files changed, 78 insertions(+), 295 deletions(-) delete mode 100644 dispatch/migrations/0013_image_caption.py rename dispatch/migrations/{0014_user_groups.py => 0013_user_groups.py} (97%) rename dispatch/migrations/{0015_user_management.py => 0014_user_management.py} (96%) delete mode 100644 dispatch/modules/content/image.py delete mode 100644 dispatch/tests/input/test_exif_xmp.jpg delete mode 100644 dispatch/tests/input/test_exif_xmp_different.jpg diff --git a/.travis.yml b/.travis.yml index 53ee59973..3248a9caf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,9 +3,6 @@ jobs: - stage: test language: python python: '2.7' - before_install: - - sudo apt-get update -qq - - sudo apt-get install -qq libexempi3 install: - pip install codecov - python setup.py install @@ -22,8 +19,6 @@ jobs: language: python python: '2.7' before_install: - - sudo apt-get update -qq - - sudo apt-get install -qq libexempi3 - nvm install 6.9.5 - nvm use 6.9.5 install: diff --git a/dispatch/api/serializers.py b/dispatch/api/serializers.py index 352e5e55a..7a34706c0 100644 --- a/dispatch/api/serializers.py +++ b/dispatch/api/serializers.py @@ -213,7 +213,6 @@ class ImageSerializer(serializers.HyperlinkedModelSerializer): filename = serializers.CharField(source='get_filename', read_only=True) title = serializers.CharField(required=False, allow_null=True, allow_blank=True) - caption = serializers.CharField(required=False, allow_null=True, allow_blank=True) url = serializers.CharField(source='get_absolute_url', read_only=True) url_medium = serializers.CharField(source='get_medium_url', read_only=True) @@ -228,7 +227,7 @@ class ImageSerializer(serializers.HyperlinkedModelSerializer): tags = TagSerializer(many=True, read_only=True) tag_ids = serializers.ListField( write_only=True, - required=True, + required=False, child=serializers.IntegerField()) width = serializers.IntegerField(read_only=True) @@ -241,7 +240,6 @@ class Meta: 'img', 'filename', 'title', - 'caption', 'authors', 'author_ids', 'tags', @@ -259,21 +257,16 @@ def create(self, validated_data): return self.update(Image(), validated_data) def update(self, instance, validated_data): - is_new = instance.pk is None - instance = super(ImageSerializer, self).update(instance, validated_data) # Save authors - author_ids = validated_data.get('author_ids') - if author_ids: - instance.save_authors(author_ids) + authors = validated_data.get('author_ids') + if authors: + instance.save_authors(authors) - # Save_tags - tag_ids = validated_data.get('tag_ids') - if tag_ids: - # Do not clear tags for first save. This prevents deletion of EXIF tags - clear = not is_new - instance.save_tags(tag_ids, clear=clear) + tag_ids = validated_data.get('tag_ids', False) + if tag_ids != False: + instance.save_tags(tag_ids) return instance @@ -643,9 +636,9 @@ def update(self, instance, validated_data): if featured_video != False: instance.save_featured_video(featured_video) - author_ids = validated_data.get('author_ids') - if author_ids: - instance.save_authors(author_ids, is_publishable=True) + authors = validated_data.get('author_ids') + if authors: + instance.save_authors(authors, is_publishable=True) tag_ids = validated_data.get('tag_ids', False) if tag_ids != False: diff --git a/dispatch/migrations/0013_image_caption.py b/dispatch/migrations/0013_image_caption.py deleted file mode 100644 index c1da79f47..000000000 --- a/dispatch/migrations/0013_image_caption.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2018-05-31 06:31 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dispatch', '0012_polls'), - ] - - operations = [ - migrations.AddField( - model_name='image', - name='caption', - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/dispatch/migrations/0014_user_groups.py b/dispatch/migrations/0013_user_groups.py similarity index 97% rename from dispatch/migrations/0014_user_groups.py rename to dispatch/migrations/0013_user_groups.py index f07dd45c5..eebe74724 100644 --- a/dispatch/migrations/0014_user_groups.py +++ b/dispatch/migrations/0013_user_groups.py @@ -37,7 +37,7 @@ def remove_groups(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dispatch', '0013_image_caption'), + ('dispatch', '0012_polls'), ] operations = [ diff --git a/dispatch/migrations/0015_user_management.py b/dispatch/migrations/0014_user_management.py similarity index 96% rename from dispatch/migrations/0015_user_management.py rename to dispatch/migrations/0014_user_management.py index 4c38588ce..b22a65012 100644 --- a/dispatch/migrations/0015_user_management.py +++ b/dispatch/migrations/0014_user_management.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ - ('dispatch', '0014_user_groups'), + ('dispatch', '0013_user_groups'), ] operations = [ diff --git a/dispatch/modules/content/image.py b/dispatch/modules/content/image.py deleted file mode 100644 index 62655f407..000000000 --- a/dispatch/modules/content/image.py +++ /dev/null @@ -1,134 +0,0 @@ -import os -from StringIO import StringIO -from tempfile import TemporaryFile - -from libxmp import XMPMeta, XMPError -from PIL import Image as Img - -# EXIF tag list: https://www.sno.phy.queensu.ca/~phil/exiftool/TagNames/EXIF.html -EXIF_DESCRIPTION = 270 -EXIF_ARTIST = 315 - -EXIF_FORMATS = '.(jpg|JPEG|jpeg|JPG|tiff|tif)' - -XMP_TITLE = 'title' -XMP_DESCRIPTION = 'description' -XMP_SUBJECT = 'subject' -XMP_CREATOR = 'creator' - -def get_extension(img): - """Returns the extension of Django ImageField object.""" - - ext = os.path.splitext(img.name)[1] - if ext: - # Remove period from extension - return ext[1:] - return ext - -def parse_exif(img): - """Extract EXIF portion of data from Django ImageField object.""" - - image = Img.open(StringIO(img.read())) - img.seek(0) - metadata = {} - - # EXIF data is only read from files with TIFF or JPEG format, otherwise an error will occur - if get_extension(img) in EXIF_FORMATS: - exif = image._getexif() - - if exif is None: - return metadata, authors - - metadata['description'] = exif.get(EXIF_DESCRIPTION) - - authors = set() - - author_name = exif.get(EXIF_ARTIST) - if author_name is not None: - authors.add(author_name) - - metadata['authors'] = authors - - return metadata - -def parse_xmp(img, metadata={}): - """Extract XMP portion of data from Django ImageField object.""" - - buffer = img.read() - img.seek(0) - b = bytearray(buffer) - - with TemporaryFile() as f: - f.write(b) - f.seek(0) - - xmp_data = '' - xmp_started = False - - for line in f: - if not xmp_started: - xmp_started = ' -1: - break - else: - return metadata - - xmp_open_tag = xmp_data.find('') - xmp_str = xmp_data[xmp_open_tag:xmp_close_tag + 12] - - xmp = XMPMeta() - xmp.parse_from_str(xmp_str) - - if xmp is None: - return metadata - - try: - ns = xmp.get_namespace_for_prefix('dc') - - title = xmp.get_array_item(ns, XMP_TITLE, 1) - if title: - metadata['title'] = title - - description = xmp.get_array_item(ns, XMP_DESCRIPTION, 1) - if description: - metadata['description'] = description - - tags = set() - counter = 1 - tag_name = xmp.get_array_item(ns, XMP_SUBJECT, counter) - while tag_name != '': - tags.add(tag_name) - counter += 1 - - try: - tag_name = xmp.get_array_item(ns, XMP_SUBJECT, counter) - except XMPError: - break - - metadata['tags'] = tags - - authors = metadata.get('authors', set()) - - author_name = xmp.get_array_item(ns, XMP_CREATOR, 1) - if author_name: - authors.add(author_name) - - metadata['authors'] = authors - - except XMPError: - pass - - return metadata - -def parse_metadata(img): - """Extract metadata from Django ImageField object.""" - - metadata = parse_exif(img) - - # Overwrite EXIF keys with XMP data - metadata = parse_xmp(img, metadata) - - return metadata diff --git a/dispatch/modules/content/mixins.py b/dispatch/modules/content/mixins.py index 5542e998b..8394757cc 100644 --- a/dispatch/modules/content/mixins.py +++ b/dispatch/modules/content/mixins.py @@ -82,31 +82,3 @@ def get_author_url(self): """Returns list of authors (including hyperlinks) as a comma-separated string (with 'and' before last author).""" return self.get_author_string(True) - -class TagMixin(object): - def save_tags(self, tag_ids, clear=True): - """Save tags with given ids to this instance.""" - - if clear: - self.tags.clear() - - for tag_id in tag_ids: - try: - tag = self.TagModel.objects.get(id=int(tag_id)) - self.tags.add(tag) - except self.TagModel.DoesNotExist: - pass - -class TopicMixin(object): - def save_topic(self, topic_id): - """Save topic with given id to this instance.""" - - if topic_id is None: - self.topic = None - else: - try: - topic = self.TopicModel.objects.get(id=int(topic_id)) - topic.update_timestamp() - self.topic = topic - except self.TopicModel.DoesNotExist: - pass diff --git a/dispatch/modules/content/models.py b/dispatch/modules/content/models.py index 9ea967ad4..0bb6dcdce 100644 --- a/dispatch/modules/content/models.py +++ b/dispatch/modules/content/models.py @@ -2,10 +2,9 @@ import os import re import uuid -import io -from PIL import Image as Img from jsonfield import JSONField +from PIL import Image as Img from django.db import IntegrityError from django.db import transaction @@ -24,9 +23,8 @@ from dispatch.modules.content.managers import PublishableManager from dispatch.modules.content.render import content_to_html -from dispatch.modules.content.image import parse_metadata, get_extension -from dispatch.modules.content.mixins import AuthorMixin, TagMixin, TopicMixin -from dispatch.modules.auth.models import Person, User +from dispatch.modules.content.mixins import AuthorMixin +from dispatch.modules.auth.models import Person class Tag(Model): name = CharField(max_length=255, unique=True) @@ -52,7 +50,9 @@ class Meta: ordering = ['order'] class Publishable(Model): - """Base model for Article and Page models.""" + """ + Base model for Article and Page models. + """ preview_id = UUIDField(default=uuid.uuid4) revision_id = PositiveIntegerField(default=0, db_index=True) @@ -153,10 +153,12 @@ def unpublish(self): # Overriding def save(self, revision=True, *args, **kwargs): - """Handles the saving/updating of a Publishable instance. + """ + Handles the saving/updating of a Publishable instance. Arguments: - revision - if True, a new version of this Publishable will be created.""" + revision - if True, a new version of this Publishable will be created. + """ if revision: # If this is a revision, set it to be the head of the list and increment the revision id @@ -204,7 +206,8 @@ def save(self, revision=True, *args, **kwargs): return self def save_featured_image(self, data): - """Handles saving the featured image. + """ + Handles saving the featured image. If data is None, the featured image will be removed. @@ -291,7 +294,7 @@ def get_previous_revision(self): class Meta: abstract = True -class Article(Publishable, AuthorMixin, TagMixin, TopicMixin): +class Article(Publishable, AuthorMixin): parent = ForeignKey('Article', related_name='article_parent', blank=True, null=True) @@ -315,8 +318,6 @@ class Article(Publishable, AuthorMixin, TagMixin, TopicMixin): reading_time = CharField(max_length=100, choices=READING_CHOICES, default='anytime') AuthorModel = Author - TagModel = Tag - TopicModel = Topic @property def title(self): @@ -334,8 +335,30 @@ def get_reading_list(self, ref=None, dur=None): 'name': name } + def save_tags(self, tag_ids): + self.tags.clear() + for tag_id in tag_ids: + try: + tag = Tag.objects.get(id=int(tag_id)) + self.tags.add(tag) + except Tag.DoesNotExist: + pass + + def save_topic(self, topic_id): + if topic_id is None: + self.topic = None + else: + try: + topic = Topic.objects.get(id=int(topic_id)) + topic.update_timestamp() + self.topic = topic + except Topic.DoesNotExist: + pass + def get_absolute_url(self): - """Returns article URL.""" + """ + Returns article URL. + """ return "%s%s/%s/" % (settings.BASE_URL, self.section.slug, self.slug) class Page(Publishable): @@ -347,23 +370,23 @@ def get_author_string(self): return None def get_absolute_url(self): - """Returns page URL.""" + """ + Returns page URL. + """ return "%s%s/" % (settings.BASE_URL, self.slug) class Video(Model): title = CharField(max_length=255) url = CharField(max_length=500) -class Image(Model, AuthorMixin, TagMixin): - +class Image(Model, AuthorMixin): img = ImageField(upload_to='images/%Y/%m') title = CharField(max_length=255, blank=True, null=True) - caption = CharField(max_length=255, blank=True, null=True) width = PositiveIntegerField(blank=True, null=True) height = PositiveIntegerField(blank=True, null=True) authors = ManyToManyField(Author, related_name='image_authors') - tags = ManyToManyField(Tag) + tags = ManyToManyField('Tag') created_at = DateTimeField(auto_now_add=True) updated_at = DateTimeField(auto_now=True) @@ -382,7 +405,6 @@ class Image(Model, AuthorMixin, TagMixin): THUMBNAIL_SIZE = 'square' AuthorModel = Author - TagModel = Tag def is_gif(self): """Returns true if image is a gif.""" @@ -398,7 +420,11 @@ def get_name(self): def get_extension(self): """Returns the file extension.""" - return get_extension(self.img) + ext = os.path.splitext(self.img.name)[1] + if ext: + # Remove period from extension + return ext[1:] + return ext def get_absolute_url(self): """Returns the full size image URL.""" @@ -427,28 +453,13 @@ def save(self, **kwargs): super(Image, self).save(**kwargs) if is_new and self.img: - metadata = parse_metadata(self.img) image = Img.open(StringIO.StringIO(self.img.read())) - self.width, self.height = image.size - # Save metadata - self.title = metadata.get('title') - self.caption = metadata.get('description') - - for tag_name in metadata.get('tags', set()): - tag, created = Tag.objects.get_or_create(name=tag_name) - self.tags.add(tag) - - for n, name in enumerate(metadata.get('authors', set())): - person, created = Person.objects.get_or_create(full_name=name) - author = Author.objects.create(person=person, order=n, type='photographer') - self.authors.add(author) - super(Image, self).save() - ext = self.get_extension() name = self.get_name() + ext = self.get_extension() for size in self.SIZES.keys(): self.save_thumbnail(image, self.SIZES[size], name, size, ext) @@ -479,6 +490,15 @@ def save_thumbnail(self, image, size, name, label, file_type): # Save the new file to the default storage system default_storage.save(name, thumb_file) + def save_tags(self, tag_ids): + self.tags.clear() + for tag_id in tag_ids: + try: + tag = Tag.objects.get(id=int(tag_id)) + self.tags.add(tag) + except Tag.DoesNotExist: + pass + class VideoAttachment(Model): article = ForeignKey(Article, blank=True, null=True, related_name='video_article') page = ForeignKey(Page, blank=True, null=True, related_name='video_page') @@ -523,7 +543,9 @@ class File(Model): updated_at = DateTimeField(auto_now=True) def get_absolute_url(self): - """Returns the absolute file URL.""" + """ + Returns the absolute file URL. + """ return settings.MEDIA_URL + str(self.file) class Issue(Model): diff --git a/dispatch/modules/content/render.py b/dispatch/modules/content/render.py index d7de9ee23..593a11b6a 100644 --- a/dispatch/modules/content/render.py +++ b/dispatch/modules/content/render.py @@ -3,11 +3,10 @@ from dispatch.modules.content.embeds import embeds, EmbedException def content_to_html(content, article_id): - """Returns article/page content as HTML.""" + """Returns artilce/page content as HTML""" def render_node(html, node, index): - """Renders node as HTML.""" - + """Renders node as HTML""" if node['type'] == 'paragraph': return html + '

%s

' % node['data'] else: @@ -35,14 +34,14 @@ def render_node(html, node, index): html = render_node(html, node, index) if (node['type'] == 'ad'): index += 1 - + # return mark_safe(reduce(render_node, content, '')) return mark_safe(html) def content_to_json(content): - """Returns article/page content as JSON.""" - + """Returns article/page content as JSON""" + def render_node(node): - """Renders node as JSON.""" + """Renders node as JSON""" if node['type'] == 'paragraph': return node diff --git a/dispatch/tests/input/test_exif_xmp.jpg b/dispatch/tests/input/test_exif_xmp.jpg deleted file mode 100644 index e6ca574f5ef645cef99471a5a6048e70fbd3f362..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22720 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 diff --git a/dispatch/tests/input/test_exif_xmp_different.jpg b/dispatch/tests/input/test_exif_xmp_different.jpg deleted file mode 100644 index ef2eca689a375cb62a600df5fffe8ee464e3e449..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22944 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