Roughly implement annotation navigator.
--- a/src/iconolab/forms/annotations.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/forms/annotations.py Mon Feb 20 17:29:55 2017 +0100
@@ -8,28 +8,27 @@
pass
class AnnotationRevisionForm(forms.ModelForm):
- tags = forms.CharField()
- comment = forms.CharField(widget=forms.Textarea)
-
+ description = forms.CharField(required=False)
+ fragment = forms.CharField(required=False)
+ tags = forms.CharField(widget=forms.Textarea, required=False)
+ comment = forms.CharField(widget=forms.Textarea, required=False)
+
class Meta:
model = AnnotationRevision
fields = ('title', 'description', 'fragment')
widgets = {
- 'fragment': forms.HiddenInput(),
- 'tags': forms.HiddenInput()
+ 'fragment': forms.Textarea(),
+ 'tags': forms.Textarea()
}
-
+
def __init__(self, *args, **kwargs):
super(AnnotationRevisionForm, self).__init__(*args, **kwargs)
- for key in self.fields:
- if key != "comment":
- self.fields[key].required = False
-
+
def clean(self, *args, **kwargs):
cleaned_data = super(AnnotationRevisionForm, self).clean(*args, **kwargs)
if not (cleaned_data.get("title", "") or cleaned_data.get("description", "") or json.loads(cleaned_data.get("tags", "[]"))):
raise forms.ValidationError("Au moins un de ces champs doit être renseigné: description, titre ou mot-clé", code="missing_fields")
-
+
def tags_json(self):
if self.instance:
tags_list = []
@@ -41,4 +40,4 @@
})
return json.dumps(tags_list)
else:
- return '[]'
\ No newline at end of file
+ return '[]'
--- a/src/iconolab/forms/comments.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/forms/comments.py Mon Feb 20 17:29:55 2017 +0100
@@ -19,17 +19,15 @@
class IconolabCommentForm(XtdCommentForm):
email = forms.EmailField(required=False)
metacategories = MetaCategoryMultipleChoiceFields(widget=forms.CheckboxSelectMultiple, queryset=None, required=False)
-
+
def __init__(self, *args, **kwargs):
super(IconolabCommentForm, self).__init__(*args, **kwargs)
self.collection = self.target_object.image.item.collection
- logger.debug(self.fields)
self.fields["metacategories"].queryset = self.collection.metacategories.all()
- logger.debug(self.fields["metacategories"].queryset)
self.fields.pop('email')
-
+
def get_comment_create_data(self):
self.cleaned_data['email'] = ''
data = super(IconolabCommentForm, self).get_comment_create_data()
data.update({'user_email': ''})
- return data
\ No newline at end of file
+ return data
--- a/src/iconolab/models.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/models.py Mon Feb 20 17:29:55 2017 +0100
@@ -9,6 +9,7 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
+from rest_framework import serializers
from django.db import models, transaction
from django.utils.text import slugify
from django_comments_xtd.models import XtdComment
@@ -146,6 +147,10 @@
return self.item.metadatas.measurements
@property
+ def latest_annotations(self):
+ return self.annotations.all().order_by('-created')
+
+ @property
def tag_labels(self):
tag_list = []
for annotation in self.annotations.all():
@@ -287,7 +292,6 @@
}
for revision in latest_revision_on_annotations
]
- logger.debug(contributed_list)
return contributed_list
@transaction.atomic
@@ -310,9 +314,7 @@
user_comments = IconolabComment.objects.filter(
user=user, content_type__app_label='iconolab', content_type__model='annotation').order_by('-submit_date')
if ignore_revisions_comments:
- logger.debug(user_comments.count())
user_comments = user_comments.filter(revision__isnull=True)
- logger.debug(user_comments.count())
all_user_comments_data = [
(comment.object_pk, comment.submit_date) for comment in user_comments]
unique_ordered_comments_data = []
@@ -328,7 +330,6 @@
'image__item__collection'
).distinct()
sorted_annotations_list = []
- logger.debug(unique_ordered_comments_data)
for comment_data in unique_ordered_comments_data:
annotation_obj = commented_annotations.get(
id=comment_data["annotation_id"])
@@ -677,6 +678,7 @@
pass
for tag_data in tags_dict:
tag_string = tag_data.get("tag_input")
+ tag_label = tag_data.get("tag_label")
tag_accuracy = tag_data.get("accuracy", 0)
tag_relevancy = tag_data.get("relevancy", 0)
@@ -688,6 +690,7 @@
else:
tag_obj = Tag.objects.create(
link=tag_string,
+ label=tag_label
)
else:
new_tag_link = settings.BASE_URL + '/' + slugify(tag_string)
@@ -710,6 +713,7 @@
relevancy=tag_relevancy
)
+ # FIXME Avoid calling DBPedia all the time
def get_tags_json(self):
"""
This method returns the json data that will be sent to the js to display
@@ -796,6 +800,27 @@
pass
return json.dumps(final_list)
+class AnnotationRevisionSerializer(serializers.ModelSerializer):
+ tags = serializers.SerializerMethodField('get_normalized_tags')
+ annotation_guid = serializers.SerializerMethodField()
+
+ def get_normalized_tags(self, obj):
+ tags = []
+ for tagging_info in obj.tagginginfo_set.all():
+ tags.append({
+ "tag_label": tagging_info.tag.label,
+ "tag_link": tagging_info.tag.link,
+ "accuracy": tagging_info.accuracy,
+ "relevancy": tagging_info.relevancy,
+ })
+ return tags
+
+ def get_annotation_guid(self, obj):
+ return obj.annotation.annotation_guid
+
+ class Meta:
+ model = AnnotationRevision
+ fields = ('annotation_guid', 'title', 'description', 'fragment', 'tags')
class Tag(models.Model):
"""
@@ -814,7 +839,7 @@
return self.link.startswith(settings.INTERNAL_TAGS_URL)
def __str__(self):
- return self.label_slug + ":" + self.label
+ return "Tag:" + self.label
class TaggingInfo(models.Model):
@@ -829,7 +854,7 @@
relevancy = models.IntegerField()
def __str__(self):
- return str(str(self.tag.label_slug) + ":to:" + str(self.revision.revision_guid))
+ return str(str(self.tag.label) + ":to:" + str(self.revision.revision_guid))
class IconolabComment(XtdComment):
@@ -869,6 +894,12 @@
).filter(thread_id__gte=self.thread_id).filter(order__lte=self.order).count() + 1) // settings.COMMENTS_PER_PAGE_DEFAULT + 1
+class IconolabCommentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = IconolabComment
+ fields = '__all__'
+
+
class MetaCategory(models.Model):
"""
Metacategories are objects that can be linked to a comment to augment it
--- a/src/iconolab/settings/dev.py.tmpl Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/settings/dev.py.tmpl Mon Feb 20 17:29:55 2017 +0100
@@ -36,7 +36,7 @@
-BASE_URL = ''
+BASE_URL = 'http://localhost:8000'
if JS_DEV_MODE:
STATIC_URL = 'http://localhost:8001/static/'
else:
@@ -73,7 +73,8 @@
'haystack',
'iconolab.apps.IconolabApp',
'sorl.thumbnail',
- 'notifications'
+ 'notifications',
+ 'rest_framework',
]
--- a/src/iconolab/templates/iconolab/detail_image.html Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/templates/iconolab/detail_image.html Mon Feb 20 17:29:55 2017 +0100
@@ -1,26 +1,160 @@
{% extends 'iconolab_base.html' %}
{% load staticfiles %}
-
{% load thumbnail %}
-
{% load iconolab_tags %}
-{% block content %}
-<div class="row">
- <div class="col-md-10 col-md-offset-1 text-center">
- <a class="btn btn-default btn-sm" href="{% url 'collection_home' collection_name %}"><i class="fa fa-list"></i> Retour à la liste d'objets</a>
- <a class="btn btn-default btn-sm" href="{% url 'item_detail' collection_name image.item.item_guid %}"><i class="fa fa-eye" aria-hidden="true"></i> Voir l'objet de cette image</a>
- <a class="btn btn-default btn-sm" href="{% url 'annotation_create' collection_name image_guid %}"><i class="fa fa-plus"></i> Annoter l'image</a>
- <br><br>
-
- {% thumbnail image.media "800x800" crop=False as im %}
- <img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}">
- {% endthumbnail %}
- <br>
- </div>
+{% block content_fluid %}
+
+<div class="annotation-navigator">
+ <div class="annotation-navigator-list">
+ <div id="annotations-{{image.image_guid}}" class="image-annotations-list">
+ {% if image.annotations %}
+ <div class="list-group">
+ {% for annotation in image.latest_annotations %}
+ <a href="#{{ annotation.annotation_guid }}" class="list-group-item"
+ data-annotation-id="{{ annotation.annotation_guid }}"
+ data-revision-id="{{ annotation.current_revision.revision_guid }}">
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
+ <h4 class="list-group-item-heading">{{ annotation.current_revision.title }}</h4>
+ <p class="list-group-item-text">{{ annotation.current_revision.description }}</p>
+ {% for tagging_info in annotation.current_revision.tagginginfo_set.all %}
+ <span class="label label-warning">{{ tagging_info.tag.label }}</span>
+ {% endfor %}
+ </a>
+ {% endfor %}
+ </div>
+ {% else %}
+ Pas d'annotation pour cette image
+ {% endif %}
+ </div>
+ </div>
+ <div class="annotation-navigator-canvas">
+ {% with image.media as img %}
+ <image-annotator ref="annotator" image="{{ img.url }}"></image-annotator>
+ {% endwith %}
+ <form id="form-annotation" action="{% url 'annotation_create' collection_name image.image_guid %}" method="POST">
+ {% csrf_token %}
+ <input type="hidden" name="{{ form.title.name }}">
+ <input type="hidden" name="{{ form.description.name }}">
+ <input type="hidden" name="{{ form.fragment.name }}">
+ <input type="hidden" name="{{ form.tags.name }}">
+ </form>
+ </div>
+ <div class="annotation-navigator-panel">
+ <div class="panel panel-default">
+ <div class="panel-body">
+ <annotation-form ref="panel"
+ action="{% url 'annotation_edit' collection_name image_guid ':annotation_guid' %}">
+ {% csrf_token %}
+ </annotation-form>
+ <comment-list ref="commentList"></comment-list>
+ <form id="form-comment">
+ <div class="form-group">
+ <textarea class="form-control input-sm" placeholder="Ajouter mon commentaire..." disabled></textarea>
+ </div>
+ <button type="submit" class="btn btn-block btn-sm btn-primary" disabled>Commenter</button>
+ </form>
+ </div>
+ </div>
+ </div>
+
</div>
+{% endblock %}
-{% include "partials/image_annotations_list.html" with annotation_list=image.annotations.all header="Annotations de l'image" %}
+{% block footer_js %}
+<script>
+
+ var currentPath = "{{ request.path }}";
+ var annotationEditURL = "{% url 'annotation_edit' collection_name image_guid ':annotation_guid' %}";
+
+ var vm = new Vue({
+ el: '.annotation-navigator'
+ });
+
+ var annotations = {};
+ {% if image.annotations %}
+ {% for annotation in image.annotations.all %}
+ annotations["{{ annotation.current_revision.revision_guid }}"] = {{ annotation.current_revision|json|safe }}
+ {% endfor %}
+ {% endif %}
+
+ function displayAnnotation($el) {
+
+ var annotation = $el.data('annotation-id');
+ var revision = $el.data('revision-id');
+
+ // Load the form for selected annotation via AJAX
+ $.get('/comments/annotation/' + annotation + '/comment-form', { next: currentPath + '#' + annotation })
+ .then(function(form) {
+ $.getJSON('/comments/annotation/' + annotation + '/comments.json')
+ .then(function(comments) {
+
+ $('#annotation-panel form:last').replaceWith(form);
+
+ $('.list-group a[data-annotation-id]').removeClass('active');
+ $el.addClass('active');
+
+ vm.$refs.annotator.annotation = annotations[revision];
+ vm.$refs.panel.setAnnotation(annotations[revision]);
+ vm.$refs.commentList.comments = comments;
+
+ location.hash = '#' + annotation;
+
+ });
+ });
+ }
-{% endblock %}
\ No newline at end of file
+ $('.list-group a[data-annotation-id]').on('click', function(e) {
+ e.preventDefault();
+ var $el = $(e.currentTarget);
+ displayAnnotation($el);
+ });
+
+ vm.$refs.annotator.$on('save', function(data) {
+ $('#form-annotation').find('input[name="title"]').val(data.title);
+ $('#form-annotation').find('input[name="description"]').val(data.description);
+ $('#form-annotation').find('input[name="fragment"]').val(data.fragment);
+
+ var tags = data.tags.map(function(tag) {
+ return {
+ tag_input: (typeof tag.tag_link === "string" && tag.tag_link.length) ? tag.tag_link : tag.tag_label,
+ tag_label: tag.tag_label,
+ accuracy: tag.accuracy,
+ relevancy: tag.relevancy
+ }
+ })
+ $('#form-annotation').find('input[name="tags"]').val(JSON.stringify(tags));
+ $('#form-annotation').submit();
+ })
+
+ $('.list-group a[data-annotation-id] .close').on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ var $target = $(e.currentTarget);
+ $target.closest('.list-group-item').removeClass('active');
+
+ vm.$refs.annotator.annotation = null;
+ vm.$refs.panel.reset();
+ vm.$refs.commentList.comments = [];
+
+ $('#annotation-panel form:last').find('textarea, [type="submit"]').attr('disabled', true);
+
+ location.hash = '';
+ });
+
+ function router () {
+ var url = location.hash.slice(1);
+ if (url.length) {
+ var annotation = url;
+ var $el = $('.list-group a[data-annotation-id="'+annotation+'"]');
+ displayAnnotation($el);
+ }
+ }
+ // window.addEventListener('hashchange', router);
+ window.addEventListener('load', router);
+
+</script>
+{% endblock %}
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src/iconolab/templates/partials/comment_form.html Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,67 @@
+<form action="{% url 'post_comment' %}" method="POST">
+ {% csrf_token %}
+ {{ comment_form.content_type }}
+ {{ comment_form.object_pk }}
+ {{ comment_form.timestamp }}
+ {{ comment_form.security_hash }}
+
+ <input type="hidden" name="next" value="{{ next }}">
+ <input type="hidden" name="reply_to" value="0"></input>
+
+ <fieldset class="form-group {% if comment_form.comment.errors %}has-error{% endif %}">
+ {% if comment_form.comment.errors %}
+ <div class="alert alert-danger" role="alert">
+ <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+ <span class="sr-only">Erreur :</span>
+ {{ comment_form.comment.errors | striptags }}
+ </div>
+ {% endif %}
+ <textarea class="form-control input-sm"
+ name="{{ comment_form.comment.name }}"
+ id="id_{{ comment_form.comment.name }}"
+ placeholder="Ajouter mon commentaire..."></textarea>
+ </fieldset>
+ <input class="btn btn-block btn-sm btn-primary" type="submit" name="post" class="submit-post" value="Commenter">
+
+ {% comment %}
+ {#
+ <input id="reply-to-field" type="hidden" name="reply_to" value="0"></input>
+ <input type="hidden" name="next" value="{% url 'annotation_detail' collection_name image_guid annotation_guid %}">
+ <h4> Commenter ({{user.username}}) </h4>
+ <div class="reply-to-container">
+ <label id="form-reply-to-label" class="control-label reply-to-label" for="id_{{ comment_form.comment.name }}">En réponse à : </label>
+ <div class="alert alert-info reply-to-alert">
+ <p class="reply-to-content comment-content"></p>
+ <hr class="comment-separator">
+ <div class="reply-to-subtext comment-subtext"></div>
+ <div class="reply-to-label-list comment-metacategories"></div>
+ </div>
+ <a class="btn btn-default btn-sm reply-to-cancel">Annuler et répondre sur le fil principal</a><br><br>
+ </div>
+ <fieldset class="form-group {% if comment_form.comment.errors %}has-error{% endif %}">
+ {% if comment_form.comment.errors %}
+ <div class="alert alert-danger" role="alert">
+ <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
+ <span class="sr-only">Erreur :</span>
+ {{ comment_form.comment.errors | striptags }}
+ </div>
+ {% endif %}
+ <textarea class="form-control"
+ name="{{ comment_form.comment.name }}"
+ id="id_{{ comment_form.comment.name }}" ></textarea>
+ </fieldset>
+ <fieldset class="form-group {% if comment_form.metacategories.errors %}has-error{% endif %}">
+ <div class="input-group">
+ {% for metacategory in comment_form.metacategories %}
+ <label class="checkbox-inline" for="{{metacategory.id_for_label}}">
+ {{ metacategory.tag }} {{metacategory.choice_label}}
+ </label>
+ {% endfor %}
+ </div>
+ </fieldset>
+ <p class="submit">
+ <input class="btn btn-default" type="submit" name="post" class="submit-post" value="Publier"/>
+ </p>
+ #}
+ {% endcomment %}
+</form>
--- a/src/iconolab/templatetags/iconolab_tags.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/templatetags/iconolab_tags.py Mon Feb 20 17:29:55 2017 +0100
@@ -1,5 +1,8 @@
from django.template import Library
-import sys
+from django.core.serializers import serialize
+from iconolab.models import AnnotationRevision, AnnotationRevisionSerializer
+from rest_framework.renderers import JSONRenderer
+
from iconolab import __version__
register = Library()
@@ -25,7 +28,7 @@
else:
path_infos = path.split(";")
if len(path_infos) > 0 :
- result = path_infos[0]
+ result = path_infos[0]
return result
@@ -48,9 +51,16 @@
@register.filter
def get_item(dictionary, key):
return dictionary.get(key)
-
-
+
+
@register.filter
def addstr(arg1, arg2):
"""concatenate arg1 & arg2"""
- return str(arg1) + str(arg2)
\ No newline at end of file
+ return str(arg1) + str(arg2)
+
+@register.filter(name='json')
+def json_dumps(data):
+ if isinstance(data, AnnotationRevision) :
+ serializer = AnnotationRevisionSerializer(data)
+ return JSONRenderer().render(serializer.data)
+ return serialize('json', [data])
--- a/src/iconolab/urls.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/urls.py Mon Feb 20 17:29:55 2017 +0100
@@ -64,6 +64,8 @@
url(r'^search/', include('iconolab.search_indexes.urls', namespace='search_indexes')),
url(r'^comments/', include('django_comments_xtd.urls')),
url(r'^comments/annotation/post', views.comments.post_comment_iconolab, name="post_comment"),
+ url(r'^comments/annotation/(?P<annotation_guid>[^/]+)/comment-form$', views.comments.get_comment_form, name="get_comment_form"),
+ url(r'^comments/annotation/(?P<annotation_guid>[^/]+)/comments.json$', views.comments.get_annotation_comments_json, name="get_annotation_comments_json"),
url(r'^compare/$', views.objects.TestView.as_view(), name="compare_view")
#url(r'^search/', include('haystack.urls'), name="search_iconolab"),
--- a/src/iconolab/views/comments.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/views/comments.py Mon Feb 20 17:29:55 2017 +0100
@@ -1,19 +1,22 @@
from django.apps import apps
+from django.http import HttpResponse
from django.views.decorators.csrf import csrf_protect
-from django.views.decorators.http import require_POST
+from django.views.decorators.http import require_POST, require_GET
from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.shortcuts import render
import datetime
import django_comments
from django_comments import signals
from django_comments.views.utils import next_redirect, confirmation_view
from django_comments.views.comments import CommentPostBadRequest
-from iconolab.models import MetaCategoryInfo
+from iconolab.models import MetaCategoryInfo, Annotation, IconolabComment, IconolabCommentSerializer
+from rest_framework.renderers import JSONRenderer
@csrf_protect
@require_POST
def post_comment_iconolab(request, next=None, using=None):
'''
- Rewriting of a django_comments method to link Iconolab metacategories on comment posting
+ Rewriting of a django_comments method to link Iconolab metacategories on comment posting
'''
# Fill out some initial data fields from an authenticated user, if present
data = request.POST.copy()
@@ -90,13 +93,13 @@
# Save the comment and signal that it was saved
comment.save()
-
+
signals.comment_was_posted.send(
sender=comment.__class__,
comment=comment,
request=request
)
-
+
# Creating metacategories here as apparently there is no way to make it work easily woth django_comments_xtd
for metacategory in form.cleaned_data.get("metacategories", []):
if 'xtd_comment' in comment:
@@ -106,4 +109,23 @@
)
return next_redirect(request, fallback=next or 'comments-comment-done',
- c=comment._get_pk_val())
\ No newline at end of file
+ c=comment._get_pk_val())
+
+@require_GET
+def get_comment_form(request, annotation_guid):
+ # TODO Manage 404
+ annotation = Annotation.objects.get(annotation_guid=annotation_guid)
+ next_url = request.GET.get('next')
+ form = django_comments.get_form()(annotation)
+ return render(request, 'partials/comment_form.html', {
+ 'comment_form': form,
+ 'next': next_url
+ })
+
+@require_GET
+def get_annotation_comments_json(request, annotation_guid):
+ # TODO Manage 404
+ annotation = Annotation.objects.get(annotation_guid=annotation_guid)
+ comments = IconolabComment.objects.for_app_models('iconolab.annotation').filter(object_pk = annotation.pk).order_by('thread_id', '-order')
+ serializer = IconolabCommentSerializer(comments, many=True)
+ return HttpResponse(JSONRenderer().render(serializer.data), content_type="application/json")
--- a/src/iconolab/views/objects.py Mon Feb 20 17:14:08 2017 +0100
+++ b/src/iconolab/views/objects.py Mon Feb 20 17:29:55 2017 +0100
@@ -12,8 +12,9 @@
from django.contrib.sites.models import Site
from django.conf import settings
from notifications.models import Notification
-from iconolab.models import Annotation, AnnotationRevision, Collection, Item, Image, IconolabComment, MetaCategory, MetaCategoryInfo
+from iconolab.models import Annotation, AnnotationRevision, AnnotationRevisionSerializer, Collection, Item, Image, IconolabComment, MetaCategory, MetaCategoryInfo
from iconolab.forms.annotations import AnnotationRevisionForm
+from rest_framework import serializers
import logging
logger = logging.getLogger(__name__)
@@ -325,6 +326,7 @@
"""
success, result = self.check_kwargs(kwargs)
+
if success:
(collection, item) = result
else:
@@ -386,6 +388,7 @@
context['image_guid'] = self.kwargs.get('image_guid', '')
context['collection'] = collection
context['image'] = image
+ context['form'] = AnnotationRevisionForm()
return render(request, 'iconolab/detail_image.html', context)
class CreateAnnotationView(View, ContextMixin, IconolabObjectView):
@@ -427,18 +430,8 @@
fragment = annotation_form.cleaned_data['fragment']
tags_json = annotation_form.cleaned_data['tags']
new_annotation = Annotation.objects.create_annotation(author, image, title=title, description=description, fragment=fragment, tags_json=tags_json)
- revision_comment = annotation_form.cleaned_data['comment']
- IconolabComment.objects.create(
- comment = revision_comment,
- revision = new_annotation.current_revision,
- content_type = ContentType.objects.get(app_label='iconolab', model='annotation'),
- content_object = new_annotation,
- site = Site.objects.get(id=settings.SITE_ID),
- object_pk = new_annotation.id,
- user = request.user,
- user_name = request.user.username
- )
- return RedirectView.as_view(url=reverse('annotation_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid, 'annotation_guid': new_annotation.annotation_guid}))(request)
+ redirect_url = reverse('image_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid})
+ return RedirectView.as_view(url=redirect_url)(request)
context = self.get_context_data(**kwargs)
context['image'] = image
context['form'] = annotation_form
@@ -611,7 +604,8 @@
user = request.user,
user_name = request.user.username
)
- return RedirectView.as_view(url=reverse('annotation_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid, 'annotation_guid': annotation_guid}))(request)
+ redirect_url = reverse('image_detail', kwargs={'collection_name': collection_name, 'image_guid': image_guid})
+ return RedirectView.as_view(url=redirect_url)(request)
context = self.get_context_data(**kwargs)
context['image'] = image
context['form'] = annotation_form
--- a/src/requirements/base.txt Mon Feb 20 17:14:08 2017 +0100
+++ b/src/requirements/base.txt Mon Feb 20 17:29:55 2017 +0100
@@ -5,6 +5,7 @@
django-haystack==2.6.0
django-model-utils==2.6.1
django-notifications-hq==1.2
+djangorestframework==3.5.4
elasticsearch==5.1.0
jsonfield==1.0.3
olefile==0.44
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/AnnotationForm.vue Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,99 @@
+<template>
+ <form v-bind:action="formAction" method="post">
+ <slot>
+ <!-- CSRF token -->
+ </slot>
+ <input type="hidden" name="fragment" v-model="fragment">
+ <div v-if="!annotation" class="alert alert-warning">
+ Aucune annotation sélectionnée.
+ </div>
+ <div v-if="annotation" class="form-group form-group-sm">
+ <label class="control-label">Titre</label>
+ <input type="text" class="form-control" name="title" v-model="title">
+ </div>
+ <div v-if="annotation" class="form-group form-group-sm">
+ <label class="control-label">Description</label>
+ <textarea class="form-control" name="description" v-model="description"></textarea>
+ </div>
+ <div v-if="annotation" class="form-group form-group-sm">
+ <label class="control-label">Mots-clé</label>
+ <tag-list ref="taglist" v-bind:tags="tags" @change="onTagsChange($event.tags)"></tag-list>
+ <input type="hidden" name="tags" v-model="serializedTags">
+ </div>
+ <button type="submit" v-if="annotation" v-bind:class="{ disabled: !hasChanged() }"
+ class="btn btn-block btn-sm btn-primary">Sauvegarder</button>
+ </form>
+</template>
+
+<script>
+
+ import TagList from '../tagform/TagList.vue'
+
+ export default {
+ props: ['action'],
+ components: {
+ 'tag-list': TagList
+ },
+ data() {
+ return {
+ 'title': '',
+ 'description': '',
+ 'fragment': '',
+ 'tags': [],
+ 'annotation': null,
+ }
+ },
+ computed: {
+ formAction: function() {
+ if (this.annotation) {
+ return this.action.replace(':annotation_guid', this.annotation.annotation_guid);
+ }
+ },
+ serializedTags: function() {
+ var tags = this.tags.map(function(tag) {
+ return {
+ tag_input: (typeof tag.tag_link === "string" && tag.tag_link.length) ? tag.tag_link : tag.tag_label,
+ tag_label: tag.tag_label,
+ accuracy: tag.accuracy,
+ relevancy: tag.relevancy
+ }
+ });
+
+ return JSON.stringify(tags);
+ }
+ },
+ methods: {
+ setAnnotation: function(annotation) {
+ const clone = annotation;
+ this.annotation = clone;
+
+ Object.assign(this, annotation);
+ },
+ onTagsChange: function(tags) {
+ this.tags = tags;
+ },
+ reset: function() {
+ Object.assign(this, {
+ 'title': '',
+ 'description': '',
+ 'tags': [],
+ 'annotation': null
+ });
+ },
+ hasChanged: function() {
+ if (!this.annotation) { return false; }
+
+ return this.title !== this.annotation.title
+ || this.description !== this.annotation.description
+ || this.tags !== this.annotation.tags;
+ }
+ }
+ }
+
+</script>
+
+<style scoped>
+form {
+ margin-bottom: 20px;
+}
+</style>
--- a/src_js/iconolab-bundle/src/components/editor/Canvas.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/Canvas.vue Mon Feb 20 17:29:55 2017 +0100
@@ -39,9 +39,15 @@
import ShapeFree from './ShapeFree.vue'
export default {
- props: [
- 'image',
- ],
+ props: {
+ image: String,
+ tooltip: {
+ type: Boolean,
+ default: function () {
+ return false;
+ }
+ },
+ },
components: {
shapeRect: ShapeRect,
shapeFree: ShapeFree
@@ -75,25 +81,8 @@
if (!loaded) { return; }
if (this.annotation) {
-
- var pieces = this.annotation.fragment.split(';');
- var path = pieces[0];
- var mode = pieces[1].toLowerCase();
-
- this.mode = mode;
-
- this.$nextTick(() => {
- path = this.denormalizePath(path);
- if (mode === 'free') {
- this.$refs.free.fromSVGPath(path);
- }
- if (mode === 'rect') {
- this.$refs.rect.fromSVGPath(path);
- }
- });
-
+ this.loadAnnotation();
} else {
-
if (this.mode === 'free') {
this.handleDrawFree();
}
@@ -101,6 +90,11 @@
this.handleDrawRect();
}
}
+ },
+ annotation: function(annotation) {
+ this.reset();
+ if (!this.annotation) { return; }
+ this.loadAnnotation();
}
},
mounted() {
@@ -154,7 +148,17 @@
this.$refs.rect.clear();
this.$refs.free.clear();
- // Remove event handlers
+ this.removeEventHandlers();
+
+ if (this.mode === 'free') {
+ this.handleDrawFree();
+ }
+ if (this.mode === 'rect') {
+ this.handleDrawRect();
+ }
+ },
+
+ removeEventHandlers: function() {
this.paper.unmousedown();
this.paper.unmousemove();
this.paper.unmouseup();
@@ -165,6 +169,26 @@
this.annotation = annotation;
},
+ loadAnnotation: function() {
+ if (this.annotation.fragment.length > 0) {
+ var pieces = this.annotation.fragment.split(';');
+ var path = pieces[0];
+ var mode = pieces[1].toLowerCase();
+
+ this.mode = mode;
+
+ this.$nextTick(() => {
+ path = this.denormalizePath(path);
+ if (mode === 'free') {
+ this.$refs.free.fromSVGPath(path, this.tooltip);
+ }
+ if (mode === 'rect') {
+ this.$refs.rect.fromSVGPath(path, this.tooltip);
+ }
+ });
+ }
+ },
+
getCenter: function() {
return {
x: this.viewBox[0] + (this.viewBox[2] / 2),
@@ -301,8 +325,19 @@
return path;
},
+ toSVGPath: function() {
+ if (this.mode === 'free') {
+ this.$refs.free.toSVGPath();
+ }
+ if (this.mode === 'rect') {
+ this.$refs.rect.toSVGPath();
+ }
+ },
+
handleDrawFree: function() {
+ this.removeEventHandlers();
+
var clickTimeout;
var clickHandler = function (offsetX, offsetY) {
@@ -325,6 +360,8 @@
handleDrawRect: function() {
+ this.removeEventHandlers();
+
var startPosition = { x: 0, y: 0 };
var currentPosition = { x: 0, y: 0 };
var canDraw = false;
@@ -429,7 +466,7 @@
}
.cut-canvas {
width: 100%;
- height: 800px;
+ height: 600px;
}
.canvas--rect:hover {
cursor: crosshair;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/Comment.vue Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,49 @@
+<template>
+ <div class="comment">
+ {{ comment }}
+ <div class="comment-footer">
+ <span class="comment-author">{{ username }}</span>
+ <span class="comment-date">{{ dateFormatted }}</span>
+ </div>
+ </div>
+</template>
+
+<script>
+
+ export default {
+ props: [
+ 'comment',
+ 'username',
+ 'email',
+ 'date'
+ ],
+ computed: {
+ dateFormatted: function () {
+
+ var date = new Date(this.date);
+
+ var day = date.getDate();
+ var month = date.getMonth() + 1;
+ var year = date.getFullYear();
+
+ return day + '/' + month + '/' + year;
+ }
+ }
+ }
+
+</script>
+
+<style scoped>
+.comment {
+ font-size: 12px;
+ padding: 5px 0;
+ border-bottom: 1px solid #ccc;
+}
+.comment-footer {
+ margin-top: 5px;
+ color: #ccc;
+}
+.comment-date {
+ float: right;
+}
+</style>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/src_js/iconolab-bundle/src/components/editor/CommentList.vue Mon Feb 20 17:29:55 2017 +0100
@@ -0,0 +1,27 @@
+<template>
+ <div v-show="comments.length > 0">
+ <label class="form-label">Commentaires</label>
+ <comment ref="comments"
+ v-for="comment in comments"
+ v-bind:comment="comment.comment"
+ v-bind:username="comment.user_name"
+ v-bind:date="comment.submit_date"></comment>
+ </div>
+</template>
+
+<script>
+
+ import Comment from './Comment.vue'
+
+ export default {
+ components: {
+ Comment
+ },
+ data() {
+ return {
+ 'comments': []
+ }
+ },
+ }
+
+</script>
--- a/src_js/iconolab-bundle/src/components/editor/ShapeFree.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeFree.vue Mon Feb 20 17:29:55 2017 +0100
@@ -1,6 +1,8 @@
<template>
<g>
- <path ref="path" v-bind:d="path" stroke="#000000" fill="#bdc3c7" style="stroke-width: 10; stroke-dasharray: 20, 20; opacity: 0.6;"></path>
+ <path ref="path" v-bind:d="path" stroke="#000000" fill="#bdc3c7"
+ v-bind:stroke-width="handlerRadius / 2"
+ v-bind:stroke-dasharray="(handlerRadius / 3) + ',' + (handlerRadius / 3)" style="opacity: 0.6;"></path>
<circle
v-for="(point, key) in points"
:key="key"
@@ -9,7 +11,8 @@
v-bind:cx="point.x"
v-bind:cy="point.y"
v-bind:r="handlerRadius"
- stroke="#000000" stroke-width="10"
+ stroke="#000000"
+ v-bind:stroke-width="handlerRadius / 2"
style="opacity: 0.9;"
v-bind:class="{ handler: true, 'handler--first': key === 0 && !closed }"></circle>
</g>
@@ -42,7 +45,6 @@
closed: function(closed) {
if (closed) {
this.path += ' Z';
- setTimeout(() => this.addTooltip(), 50);
}
},
// Redraw the path when the points have changed
@@ -96,7 +98,7 @@
return this.$refs.path;
},
- fromSVGPath: function(pathString) {
+ fromSVGPath: function(pathString, tooltip) {
var segments = Snap.parsePathString(pathString);
var points = [];
@@ -114,6 +116,10 @@
this.$nextTick(() => {
this.addResizeHandlers();
+ if (tooltip) {
+ // FIXME Race condition with destroy
+ setTimeout(() => this.addTooltip(), 250);
+ }
});
},
@@ -123,14 +129,20 @@
addResizeHandler: function(handler) {
+ var self = this;
+
var circle = new Snap(handler);
var isDragged = false;
- circle.click(function(e) {
- var key = parseInt(this.attr('data-key'), 10);
- if (key === 0 && self.points.length > 2) {
- self.closed = true;
+ circle.click((e) => {
+ var key = parseInt(circle.attr('data-key'), 10);
+ if (key === 0 && this.points.length > 2) {
+ this.closed = true;
+ this.$nextTick(() => {
+ // FIXME Race condition with destroy
+ setTimeout(() => this.addTooltip(), 250);
+ });
}
});
@@ -141,8 +153,6 @@
this.points.splice(key, 1);
});
- var self = this;
-
var dragEvents = {
onMove: function(dx, dy, x, y, e) {
--- a/src_js/iconolab-bundle/src/components/editor/ShapeRect.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/ShapeRect.vue Mon Feb 20 17:29:55 2017 +0100
@@ -38,6 +38,7 @@
props: [
'paper',
'original-annotation',
+ 'tooltip'
],
data() {
return {
@@ -153,7 +154,7 @@
bottomRightHandler.drag(handlerEvents.onMove, handlerEvents.onStart, handlerEvents.onEnd);
},
- fromSVGPath: function(pathString, imageWidth, imageHeight) {
+ fromSVGPath: function(pathString, tooltip) {
var bBox = Snap.path.getBBox(pathString);
Object.assign(this, {
@@ -161,10 +162,13 @@
width: bBox.width, height: bBox.height
});
- setTimeout(() => {
+ this.$nextTick(() => {
this.addResizeHandlers();
- this.addTooltip();
- }, 50);
+ if (tooltip) {
+ // FIXME Race condition with destroy
+ setTimeout(() => this.addTooltip(), 250);
+ }
+ });
},
toSVGPath: function() {
--- a/src_js/iconolab-bundle/src/components/editor/index.js Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/index.js Mon Feb 20 17:29:55 2017 +0100
@@ -1,7 +1,11 @@
import Canvas from './Canvas.vue'
import Annotation from './Annotation.vue'
+import AnnotationForm from './AnnotationForm.vue'
+import CommentList from './CommentList.vue'
export default {
Canvas: Canvas,
Annotation: Annotation,
+ AnnotationForm: AnnotationForm,
+ CommentList: CommentList,
}
--- a/src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/editor/mixins/tooltip.js Mon Feb 20 17:29:55 2017 +0100
@@ -7,6 +7,7 @@
trigger: 'manual',
html: true,
title: '',
+ viewport: '.cut-canvas',
content: '',
}
--- a/src_js/iconolab-bundle/src/components/tagform/TagList.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/TagList.vue Mon Feb 20 17:29:55 2017 +0100
@@ -2,11 +2,13 @@
<div>
<div class="tag-list">
<tag-list-item ref="items"
- v-for="(tag, index) in tags" v-if="tags.length > 0"
+ key="tag.tag_label + tag.accuracy + tag.relevancy"
+ v-for="(tag, index) in tags"
+ v-if="tags.length > 0"
v-bind:label="tag.tag_label"
v-bind:index="index"
- v-bind:original-accuracy="tag.accuracy"
- v-bind:original-relevancy="tag.relevancy"></tag-list-item>
+ v-bind:accuracy="tag.accuracy"
+ v-bind:relevancy="tag.relevancy"></tag-list-item>
</div>
<typeahead ref="typeahead" placeholder="Rechercher"></typeahead>
</div>
@@ -18,25 +20,24 @@
import Typeahead from './Typeahead.vue'
export default {
- props: ['original-tags'],
+ props: {
+ tags: {
+ type: Array,
+ default: function () {
+ return [];
+ }
+ },
+ },
components: {
"typeahead": Typeahead,
"tag-list-item": TagListItem
},
- data() {
- return {
- tags: []
- }
- },
watch: {
tags: function(tags) {
this.$emit('change', { tags: tags });
}
},
mounted() {
- if (this.originalTags) {
- this.tags = this.originalTags;
- }
this.$refs.typeahead.$on('selected', (tag) => {
this.tags.push(tag);
});
--- a/src_js/iconolab-bundle/src/components/tagform/TagListItem.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/TagListItem.vue Mon Feb 20 17:29:55 2017 +0100
@@ -21,14 +21,14 @@
<label>Fiabilité</label>
<small>Êtes-vous sûr de votre tag ?</small>
<color-buttons ref="accuracy"
- @change="accuracy = $event.value"
+ @change="onChange('accuracy', $event.value)"
v-bind:original-value="accuracy"></color-buttons>
</div>
<div>
<label>Pertinence</label>
<small>Votre tag est-il indispensable ?</small>
<color-buttons ref="relevancy"
- @change="relevancy = $event.value"
+ @change="onChange('relevancy', $event.value)"
v-bind:original-value="relevancy"></color-buttons>
</div>
</div>
@@ -45,8 +45,8 @@
props: [
'index',
'label',
- 'original-accuracy',
- 'original-relevancy'
+ 'accuracy',
+ 'relevancy'
],
components: {
"typeahead": Typeahead,
@@ -54,27 +54,29 @@
},
data() {
return {
- accuracy: null,
- relevancy: null,
isNew: true,
}
},
watch: {
accuracy: function(accuracy) {
- this.onChange();
+ if (this.isNew && this.isComplete()) {
+ this.isNew = false;
+ this.hide();
+ }
},
relevancy: function(relevancy) {
- this.onChange();
+ if (this.isNew && this.isComplete()) {
+ this.isNew = false;
+ this.hide();
+ }
}
},
mounted() {
- this.accuracy = this.originalAccuracy;
- this.relevancy = this.originalRelevancy;
- this.isNew = (!this.originalAccuracy && !this.originalRelevancy);
+ this.isNew = (!this.accuracy && !this.relevancy);
- this.$refs.accuracy.value = this.originalAccuracy;
- this.$refs.relevancy.value = this.originalRelevancy;
+ this.$refs.accuracy.value = this.accuracy;
+ this.$refs.relevancy.value = this.relevancy;
$(this.$el).find('.collapse').collapse({ toggle: false });
@@ -89,15 +91,11 @@
isComplete: function() {
return this.accuracy && this.relevancy;
},
- onChange: function() {
- this.$parent.replaceItemAt(this.index, {
- accuracy: this.accuracy,
- relevancy: this.relevancy
- });
- if (this.isNew && this.isComplete()) {
- this.isNew = false;
- this.hide();
- }
+ onChange: function(key, value) {
+ var data = {}
+ data[key] = value;
+
+ this.$parent.replaceItemAt(this.index, data);
},
show: function() {
$(this.$el).find('.collapse').collapse('show');
--- a/src_js/iconolab-bundle/src/components/tagform/Typeahead.vue Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/components/tagform/Typeahead.vue Mon Feb 20 17:29:55 2017 +0100
@@ -1,7 +1,7 @@
<template>
<div>
<input type="text"
- class="form-control"
+ class="form-control input-sm"
v-bind:placeholder="placeholder"
autocomplete="off"
v-model="query"
--- a/src_js/iconolab-bundle/src/iconolab.scss Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/iconolab.scss Mon Feb 20 17:29:55 2017 +0100
@@ -302,3 +302,40 @@
padding: 30px 0;
}
}
+
+.annotation-navigator {
+ display: flex;
+ margin-bottom: 30px;
+ .annotation-navigator-list {
+ width: 20%;
+ max-height: 600px;
+ overflow: scroll;
+ }
+ .annotation-navigator-canvas {
+ width: 60%;
+ padding: 0 15px;
+ }
+ .annotation-navigator-panel {
+ width: 20%;
+ display: flex;
+ > div, .panel-body {
+ display: flex;
+ flex: 1;
+ }
+ .panel {
+ margin-bottom: 0;
+ }
+ .panel-body {
+ flex-direction: column;
+ justify-content: space-between;
+ > * {
+ flex: 1;
+ }
+ form:last-of-type {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ }
+ }
+ }
+}
--- a/src_js/iconolab-bundle/src/main.js Mon Feb 20 17:14:08 2017 +0100
+++ b/src_js/iconolab-bundle/src/main.js Mon Feb 20 17:29:55 2017 +0100
@@ -35,6 +35,8 @@
Vue.component('color-buttons', ColorButtons);
Vue.component('image-annotator', Editor.Canvas);
Vue.component('annotation', Editor.Annotation);
+Vue.component('annotation-form', Editor.AnnotationForm);
+Vue.component('comment-list', Editor.CommentList);
if (!window.iconolab) {
window.iconolab = iconolab;