diff --git a/README.md b/README.md index f403c3df..cd5dbb9a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [License](https://github.com/jplusplus/detective.io/blob/master/LICENSE) • [Test coverage](https://coveralls.io/r/jplusplus/detective.io) • [Documentation](http://docs.detective.io/en/latest/) • -*Version 1.12.3 Frog* +*Version 1.12.4 Gorilla* ## Installation diff --git a/app/detective/bundle/bower.json b/app/detective/bundle/bower.json index d7a92453..d7880716 100644 --- a/app/detective/bundle/bower.json +++ b/app/detective/bundle/bower.json @@ -1,6 +1,6 @@ { "name": "detective", - "version": "1.12.3", + "version": "1.12.4", "dependencies": { "angular": "1.2.16", "angular-cookies": "1.2.16", diff --git a/app/detective/bundle/client/app/base.dj.html b/app/detective/bundle/client/app/base.dj.html index b4486dd0..b14a93fc 100644 --- a/app/detective/bundle/client/app/base.dj.html +++ b/app/detective/bundle/client/app/base.dj.html @@ -18,7 +18,16 @@ {% for picture_url in meta.pictures %} {% endfor %} - + + + diff --git a/app/detective/bundle/client/app/components/header/header.controller.coffee b/app/detective/bundle/client/app/components/header/header.controller.coffee index 72818c90..fdd46e55 100644 --- a/app/detective/bundle/client/app/components/header/header.controller.coffee +++ b/app/detective/bundle/client/app/components/header/header.controller.coffee @@ -7,33 +7,17 @@ class window.HeaderCtrl # Watch current topic @scope.$watch (=>@TopicsFactory.topic), (topic)=> @scope.topic = topic + @scope.shouldShowTopicSearch = @isInTopic @scope.shouldShowAddEntity = => - return false unless @isInTopic() - return @scope.user.hasAddPermission(@TopicsFactory.topic.ontology_as_mod) + @isInTopic() and @scope.user.hasAddPermission(@TopicsFactory.topic.ontology_as_mod) - @scope.shouldShowTopicSearch = => - in_topic = @isInTopic() - in_wrong_state = @isInEmptyState() or @isInInvite() or @isInHome() - in_topic and not in_wrong_state @scope.toggleUserMenu = @toggleUserMenu @scope.closeUserMenu = @closeUserMenu @scope.goToMyProfile = @goToMyProfile @scope.goToMySettings = @goToMySettings - isInTopic: => - topic = @TopicsFactory.topic - topic? and not _.isEmpty(topic) - - isInEmptyState: => - state = @state.current - not state? or _.isEmpty(state) or _.isEmpty(state.name) - - isInInvite: => - @state.current.name is 'user-topic-invite' - - isInHome: => - ((@state.current.name or '').match(/^home/) or []).length > 0 + isInTopic: => @state.params.topic? toggleUserMenu: => @scope.userMenuOpened = not @scope.userMenuOpened diff --git a/app/detective/bundle/client/app/components/header/header.dj.html b/app/detective/bundle/client/app/components/header/header.dj.html index a54a8543..5344d14c 100644 --- a/app/detective/bundle/client/app/components/header/header.dj.html +++ b/app/detective/bundle/client/app/components/header/header.dj.html @@ -54,7 +54,7 @@
diff --git a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/customize-ontology.less b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/customize-ontology.less index e6424131..55b19d33 100644 --- a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/customize-ontology.less +++ b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/customize-ontology.less @@ -96,6 +96,7 @@ } } + } ._jsPlumb_overlay.label { diff --git a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.directive.coffee b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.directive.coffee index 95314416..814a5640 100644 --- a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.directive.coffee +++ b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.directive.coffee @@ -6,8 +6,10 @@ angular.module('detective').directive "modelForm", ()-> modelForm: "=" submit: "&" cancel: "&" - controller: [ '$scope', ($scope)-> - FIELD_TYPES = ['string', 'richtext', 'float', 'datetime', 'url'] + mayLostFieldData: "&" + mayLostModelData: "&" + controller: [ '$scope', 'Modal', ($scope, Modal)-> + FIELD_TYPES = ['string', 'richtext', 'float', 'datetime', 'url', 'boolean'] # Transform the given string into a valid model name toModelName = (verbose_name)-> verbose_name = getSlug verbose_name, titleCase: yes @@ -17,8 +19,11 @@ angular.module('detective').directive "modelForm", ()-> # Sanitize the model to make it ready to be inserted $scope.sanitizeModel = (remove_empty_field=no, populate_empty=no)-> if $scope.model.verbose_name? - # Generate model name - $scope.model.name = toModelName $scope.model.verbose_name + # You may be allowed to change the name of the model + # with no risk of loosing data + if not $scope.mayLostModelData({model: $scope.master}) + # Generate model name + $scope.model.name = toModelName $scope.model.verbose_name else $scope.model.verbose_name = "" # Add field array @@ -29,20 +34,43 @@ angular.module('detective').directive "modelForm", ()-> continue unless $scope.isAllowedType(field) # Use name as default verbose name field.verbose_name = field.verbose_name or field.name if populate_empty - # Generate fields name - field.name = toFieldName field.verbose_name - # Field name exists? - unless field.name? and field.name isnt '' - # Should we remove empty field? - if remove_empty_field - delete $scope.model.fields[index] - $scope.model.fields.splice index, 1 - continue - # Lowercase first letter - if field.name.length < 2 - field.name = do field.name.toLowerCase + # You may be allowed to change the name of the field + # with no risk of loosing data + if not $scope.mayLostFieldData({field: field, model: $scope.master}) + # Generate fields name + field.name = toFieldName field.verbose_name + # Field name exists? + unless field.name? and field.name isnt '' + # Should we remove empty field? + if remove_empty_field + delete $scope.model.fields[index] + $scope.model.fields.splice index, 1 + continue + # Lowercase first letter + if field.name.length < 2 + field.name = do field.name.toLowerCase + else + field.name = field.name.substring(0, 1).toLowerCase() + field.name.substring(1) + # This field might not be changeable without risk else - field.name = field.name.substring(0, 1).toLowerCase() + field.name.substring(1) + # Original field + masterField = _.find($scope.master.fields, { name: field.name }) + # Type changed + if field.type isnt masterField.type + # Closure function to transmit the field type + resetType = (field, masterField)-> + # User cancel the change + (isYes)-> + if isYes + # Update the master to ask the question once + masterField.type = field.type + else + # Restore the field type + field.type = masterField.type + # Ask confirmation + m = Modal("Unconvertible data will be lost. Are you sure?", "Yes, change the type") + # Reset (or no the field's type) + m.then resetType field, masterField # Overide verbose_name_plural value if $scope.model.verbose_name.substr(-1) is 'y' # Name finishing by an y must finish by "ies" in there pluaral form @@ -85,11 +113,11 @@ angular.module('detective').directive "modelForm", ()-> $scope.model.fields.splice(index, 1) # True if the given type is allowed $scope.isAllowedType = (field)-> FIELD_TYPES.indexOf( do field.type.toLowerCase ) > -1 + # Original model + $scope.master = $scope.modelForm # Shortcut to the model object $scope.model = angular.copy $scope.modelForm or {} $scope.model = $scope.sanitizeModel yes, yes - # Original model - $scope.master = $scope.modelForm # Add default fields $scope.model.fields = [] unless $scope.model.fields? # Field that will be added to the list diff --git a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.dj.html b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.dj.html index bcba1dbe..8f32278d 100644 --- a/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.dj.html +++ b/app/detective/bundle/client/app/main/home/dashboard/create/customize-ontology/model-form/model-form.dj.html @@ -55,6 +55,7 @@+ If you delete this relationship, all the fields associated to this relationship will be deleted too. + Are you sure to continue? +
+- + + Add items - + + + Settings + + Explore
diff --git a/app/detective/bundle/client/app/main/user/topic/type/entity/card/card.directive.coffee b/app/detective/bundle/client/app/main/user/topic/type/entity/card/card.directive.coffee index b67bc45a..49b7d2b6 100644 --- a/app/detective/bundle/client/app/main/user/topic/type/entity/card/card.directive.coffee +++ b/app/detective/bundle/client/app/main/user/topic/type/entity/card/card.directive.coffee @@ -26,12 +26,10 @@ angular.module('detective').directive "card", ['Summary', 'Individual', '$sce', id: scope.individual.id # Get the value for the given field name with the current individual scope.get = (name, isrel=no)-> - unless isrel - scope.individual[name] or false - else - scope.relIndividual[name] or false + scope[ if isrel then "relIndividual" else "individual" ][name] or no + scope.getTrusted = (n) -> - val = scope.get n + val = scope.get n, yes if val? and val.length > 0 then ($sce.trustAsHtml val) else "" # True if the given property is a string scope.isString = (f)-> ["CharField", "URLField"].indexOf(f.type) > -1 diff --git a/app/detective/bundle/package.json b/app/detective/bundle/package.json index ff8b32a2..56d76720 100644 --- a/app/detective/bundle/package.json +++ b/app/detective/bundle/package.json @@ -1,6 +1,6 @@ { "name": "detective", - "version": "1.12.3", + "version": "1.12.4", "dependencies": { "bower": "latest", "coffee-script": "1.6.2", diff --git a/app/detective/individual.py b/app/detective/individual.py index 0422f908..e0aedb7b 100644 --- a/app/detective/individual.py +++ b/app/detective/individual.py @@ -11,11 +11,13 @@ from app.detective.topics.common.user import UserNestedResource from app.detective.models import Topic from app.detective.exceptions import UnavailableImage, NotAnImage, OversizedFile +from app.detective.paginator import Paginator, resource_paginator +from django import forms from django.conf import settings from django.conf.urls import url from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist, ValidationError -from django.core.paginator import Paginator, InvalidPage +from django.core.paginator import InvalidPage from django.core.urlresolvers import reverse from django.core.files.storage import default_storage from django.db.models.query import QuerySet @@ -24,7 +26,6 @@ from neo4jrestclient.request import TransactionException from neo4django.db import connection from neo4django.db.models import NodeModel -from neo4django.db.models.properties import DateProperty, BoundProperty from neo4django.db.models.relationships import MultipleNodes from tastypie import fields from tastypie.authentication import Authentication, SessionAuthentication, BasicAuthentication, MultiAuthentication @@ -118,6 +119,7 @@ def dehydrate(self, bundle): del bundle.data["resource_uri"] return bundle + class IndividualResource(ModelResource): field_sources = fields.ToManyField( @@ -128,8 +130,11 @@ class IndividualResource(ModelResource): use_in='detail' ) + def __init__(self, api_name=None): super(IndividualResource, self).__init__(api_name) + # Pass the current instance of the resource to the paginator + self._meta.paginator_class = resource_paginator(self) # By default, tastypie detects detail mode globally: it means that # even into an embeded resource (through a relationship), Tastypie will # serialize it as if we are in it's detail view. @@ -271,6 +276,7 @@ def use_in(self, bundle=None): # Use in detail return self.get_resource_uri(bundle) == bundle.request.path + def dehydrate(self, bundle): # Get the request from the bundle request = bundle.request @@ -337,7 +343,6 @@ def dehydrate(self, bundle): for key, size in settings.THUMBNAIL_SIZES.items() } except InvalidImageFormatError as e: - print e to_add[field + '_thumbnail'] = '' # Convert tuple to array for better serialization @@ -398,13 +403,16 @@ def obj_get(self, **kwargs): request = bundle.request # Current model model = self.get_model() - # Get the node's data using the rest API try: node = connection.nodes.get(pk) # Node not found except client.NotFoundError: raise Http404("Not found.") + # Convert existing properties. + # Since we allow the user to change her data structure we must be able + # to convert the data she already put into the database. + node.properties = self.convert(node.properties) # Create a model istance from the node - return model._neo4j_instance(node) + return model._neo4j_instance( node ) def get_detail(self, request, **kwargs): basic_bundle = self.build_bundle(request=request) @@ -478,38 +486,24 @@ def get_search(self, request, **kwargs): query = request.GET.get('q', '').lower() query = re.sub("\"|'|`|;|:|{|}|\|(|\|)|\|", '', query).strip() limit = int( request.GET.get('limit', 20)) - exclude = int( request.GET.get('exclude', -1) ) + p = int(request.GET.get('page', 1)) # Do the query. results = self._meta.queryset.filter(name__icontains=query) - # Quicker than query exclude - results = [r for r in results if r.id != exclude] - paginator = Paginator(results, limit) + # For retro compatibility we use the django paginator + paginator = resource_paginator(self)(request.GET, results, resource_uri=self.get_resource_uri(), limit=limit, collection_name=self._meta.collection_name) + to_be_serialized = paginator.page() - try: - p = int(request.GET.get('page', 1)) - page = paginator.page(p) - except InvalidPage: - raise Http404("Sorry, no results on that page.") - - objects = [] - - for result in page.object_list: - bundle = self.build_bundle(obj=result, request=request) - bundle = self.full_dehydrate(bundle, for_list=True) - objects.append(bundle) - - object_list = { - 'objects': objects, - 'meta': { - 'q': query, - 'page': p, - 'limit': limit, - 'total_count': paginator.count - } - } + # Dehydrate the bundles in preparation for serialization. + bundles = [] - self.log_throttled_access(request) - return self.create_response(request, object_list) + for obj in to_be_serialized[self._meta.collection_name]: + bundle = self.build_bundle(obj=obj, request=request) + bundles.append(self.full_dehydrate(bundle, for_list=True)) + + to_be_serialized[self._meta.collection_name] = bundles + to_be_serialized = self.alter_list_data_to_serialize(request, to_be_serialized) + + return self.create_response(request, to_be_serialized) def get_mine(self, request, **kwargs): self.method_check(request, allowed=['get']) @@ -518,45 +512,72 @@ def get_mine(self, request, **kwargs): limit = int(request.GET.get('limit', 20)) if request.user.id is None: - object_list = { + return self.create_response(request, { 'objects': [], 'meta': { 'author': request.user, - 'page': 1, 'limit': limit, 'total_count': 0 } - } + }) else: # Do the query. results = self._meta.queryset.filter(_author__contains=request.user.id) - paginator = Paginator(results, limit) + # For retro compatibility we use the django paginator + paginator = resource_paginator(self)(request.GET, results, resource_uri=self.get_resource_uri(), limit=limit, collection_name=self._meta.collection_name) + to_be_serialized = paginator.page() - try: - p = int(request.GET.get('page', 1)) - page = paginator.page(p) - except InvalidPage: - raise Http404("Sorry, no results on that page.") + # Dehydrate the bundles in preparation for serialization. + bundles = [] - objects = [] + for obj in to_be_serialized[self._meta.collection_name]: + bundle = self.build_bundle(obj=obj, request=request) + bundles.append(self.full_dehydrate(bundle, for_list=True)) - for result in page.object_list: - bundle = self.build_bundle(obj=result, request=request) - bundle = self.full_dehydrate(bundle, for_list=True) - objects.append(bundle) + to_be_serialized[self._meta.collection_name] = bundles + to_be_serialized = self.alter_list_data_to_serialize(request, to_be_serialized) - object_list = { - 'objects': objects, - 'meta': { - 'author': request.user, - 'page': p, - 'limit': limit, - 'total_count': paginator.count - } - } + return self.create_response(request, to_be_serialized) - self.log_throttled_access(request) - return self.create_response(request, object_list) + def convert(self, properties, model=None): + if model is None: model = self.get_model() + validate = False + # Iterate until the whole properties object validates + while not validate: + try: + self.validate(properties, model=model) + validate = True + except ValidationError as e: + # Convert each key + for key in e.message_dict.keys(): + value = self.convert_field(key, properties[key], model=model) + # Skip unconvertible values + if value is None: del properties[key] + # Save the value + else: properties[key] = value + return properties + + def convert_field(self, name, value, model=None): + if model is None: model = self.get_model() + # Find the model's field + field = self.get_model_field(name, model) + # Find the field type + fieldtype = field._property.get_internal_type() + # Get the field widget + formfield = field._property.formfield() + # Choose the best way to convert + try: + if fieldtype == 'BooleanField': + return bool(value) + elif fieldtype == 'CharField': + return str(value) + elif fieldtype == 'DateTimeField': + return forms.DateTimeField().clean(value) + else: + return formfield.clean(value) + # Wrong convettion result to a None value + except (ValueError, TypeError, ValidationError): + return None def validate(self, data, model=None, allow_missing=False): if model is None: model = self.get_model() @@ -573,6 +594,18 @@ def validate(self, data, model=None, allow_missing=False): # Skip this field else: continue cleaned_data[field_name] = data[field_name] + # DateTime field must be validate manually + elif field.get_internal_type() == 'DateTimeField': + # Create a native datetimefield + formfield = forms.DateTimeField(input_formats=settings.DATETIME_FORMATS, required=False) + try: + # Validate and clean the data + cleaned_data[field_name] = formfield.clean(data[field_name]) + except ValidationError as e: + # Raise the same error the field name as key + if not allow_missing: raise ValidationError({field_name: 'Must be a valid date/time'}) + # Skip this field + else: continue # Only literal values have a _property attribute elif hasattr(field, "_property"): try: @@ -590,7 +623,7 @@ def validate(self, data, model=None, allow_missing=False): # @warning: this will validate the data for # array of values but not clean them cleaned_data[field_name] = data[field_name] - except ValidationError as e: + except ValidationError: # Raise the same error the field name as key if not allow_missing: raise ValidationError({field_name: e.messages}) # The given value is a relationship @@ -848,7 +881,6 @@ def create_source(individual, data): elif request.method == 'DELETE': delete_source(source_id) - print "Took %f to patch sources" % (time.time() - start_time) return self.create_response(request, source) def get_authors(self, request, **kwargs): diff --git a/app/detective/models.py b/app/detective/models.py index 431b0782..ee398a39 100644 --- a/app/detective/models.py +++ b/app/detective/models.py @@ -205,9 +205,16 @@ def app_label(self): def get_module_token(size=10, chars=string.ascii_uppercase + string.digits): return "topic%s" % ''.join(random.choice(chars) for x in range(size)) - def get_module(self): - from app.detective import topics - return getattr(topics, self.app_label()) + def get_module_path(self): + return "app.detective.topics.%s" % self.module + + def get_module(self, reload_module=True): + if reload_module and self.ontology_as_json: + module = self.reload() + else: + from app.detective import topics + module = getattr(topics, self.app_label()) + return module def get_models_module(self): """ return the module topic_module.models """ @@ -223,26 +230,38 @@ def get_models(self): if inspect.isclass(klass) and issubclass(klass, models.Model): yield klass + def get_model(self, name): + model = None + for m in self.get_models(): + if m.__name__.lower() == name.lower(): + model = m + return model + def clean(self): models.Model.clean(self) def save(self, *args, **kwargs): + try: + # Original model before saving + orig = Topic.objects.get(pk=self.pk) + except Topic.DoesNotExist: + orig = None # Ensure that the module field is populated with app_label() self.ontology_as_mod = self.app_label() - # For automatic slug generation. if not self.slug: self.slug = slugify(self.title)[:50] - # Call the parent save method super(Topic, self).save(*args, **kwargs) - # Refresh the API - #self.reload() + # Ontology changed + if orig is not None and orig.ontology_as_json != self.ontology_as_json: + # Refresh the API + self.reload() def reload(self): from app.detective.register import topic_models # Register the topic's models again - topic_models(self.get_module().__name__, force=True) + return topic_models(self.get_module_path(), force=True) def has_default_ontology(self): try: @@ -310,7 +329,7 @@ def entities_count(self): if response is None: query = """ START a = node(0) - MATCH a-[`<