+ }
+
+ """
+ reason = 'Can only patch "default_style" field.'
+ try:
+ body = json.loads(request.body)
+ if 'default_style' not in body:
+ return http.HttpBadRequest(reason=reason)
+ match = resolve(body['default_style'])
+ style_id = match.kwargs['id']
+ api_name = match.kwargs['api_name']
+ resource_name = match.kwargs['resource_name']
+ if not (resource_name == 'styles' and api_name == 'api'):
+ raise Exception()
+
+ from geonode.qgis_server.models import QGISServerStyle
+
+ style = QGISServerStyle.objects.get(id=style_id)
+
+ layer_id = kwargs['id']
+ layer = Layer.objects.get(id=layer_id)
+ except:
+ return http.HttpBadRequest(reason=reason)
+
+ from geonode.qgis_server.views import default_qml_style
+
+ request.method = 'POST'
+ response = default_qml_style(
+ request,
+ layername=layer.name,
+ style_name=style.name)
+
+ if isinstance(response, TemplateResponse):
+ if response.status_code == 200:
+ return HttpResponse(status=200)
+
+ return self.error_response(request, response.content)
+
class Meta(CommonMetaApi):
queryset = Layer.objects.distinct().order_by('-date')
if settings.RESOURCE_PUBLISHING:
queryset = queryset.filter(is_published=True)
resource_name = 'layers'
detail_uri_name = 'id'
+ include_resource_uri = True
+ allowed_methods = ['get', 'patch']
excludes = ['csw_anytext', 'metadata_xml']
+ filtering = CommonMetaApi.filtering
+ # Allow filtering using ID
+ filtering.update({
+ 'id': ALL,
+ 'name': ALL,
+ 'typename': ALL,
+ })
class MapResource(CommonModelApi):
diff --git a/geonode/api/tests.py b/geonode/api/tests.py
index 6bd38fb561c..c3da4df53d3 100644
--- a/geonode/api/tests.py
+++ b/geonode/api/tests.py
@@ -17,12 +17,24 @@
# along with this program. If not, see .
#
#########################################################################
+import json
+import os
+from StringIO import StringIO
+import gisdata
+from django.core.management import call_command
from django.core.urlresolvers import reverse
+from django.test.testcases import LiveServerTestCase
+
+from geonode import geoserver, qgis_server
+from geonode.decorators import on_ogc_backend
+from geonode.layers.utils import file_upload
from tastypie.test import ResourceTestCase
-from geonode.base.populate_test_data import create_models, all_public
+from geonode.base.populate_test_data import create_models, all_public, \
+ reconnect_signals, disconnect_signals
from geonode.layers.models import Layer
+from geonode.utils import check_ogc_backend
class PermissionsApiTests(ResourceTestCase):
@@ -226,3 +238,269 @@ def test_date_filter(self):
resp = self.api_client.get(filter_url)
self.assertValidJSONResponse(resp)
self.assertEquals(len(self.deserialize(resp)['objects']), 4)
+
+
+class LayersStylesApiInteractionTests(LiveServerTestCase, ResourceTestCase):
+
+ """Test Layers"""
+
+ fixtures = ['initial_data.json', 'bobby']
+
+ def setUp(self):
+ super(LayersStylesApiInteractionTests, self).setUp()
+
+ # Reconnect Geoserver signals
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
+ reconnect_signals()
+
+ call_command('loaddata', 'people_data', verbosity=0)
+
+ self.layer_list_url = reverse(
+ 'api_dispatch_list',
+ kwargs={
+ 'api_name': 'api',
+ 'resource_name': 'layers'})
+ self.style_list_url = reverse(
+ 'api_dispatch_list',
+ kwargs={
+ 'api_name': 'api',
+ 'resource_name': 'styles'})
+ filename = os.path.join(gisdata.GOOD_DATA, 'raster/test_grid.tif')
+ self.layer = file_upload(filename)
+ all_public()
+
+ def tearDown(self):
+ Layer.objects.all().delete()
+
+ # Disconnect Geoserver signals
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
+ disconnect_signals()
+
+ def test_layer_interaction(self):
+ """Layer API interaction check."""
+ layer_id = self.layer.id
+
+ layer_detail_url = reverse(
+ 'api_dispatch_detail',
+ kwargs={
+ 'api_name': 'api',
+ 'resource_name': 'layers',
+ 'id': layer_id
+ }
+ )
+ resp = self.api_client.get(layer_detail_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+ # Should have links
+ self.assertTrue('links' in obj and obj['links'])
+ # Should have default style
+ self.assertTrue('default_style' in obj and obj['default_style'])
+ # Should have styles
+ self.assertTrue('styles' in obj and obj['styles'])
+
+ # Test filter layers by id
+ filter_url = self.layer_list_url + '?id=' + str(layer_id)
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ # This is a list url
+ objects = self.deserialize(resp)['objects']
+ self.assertEqual(len(objects), 1)
+ obj = objects[0]
+ # Should have links
+ self.assertTrue('links' in obj)
+ # Should not have styles
+ self.assertTrue('styles' not in obj)
+ # Should have default_style
+ self.assertTrue('default_style' in obj and obj['default_style'])
+ # Should have resource_uri to browse layer detail
+ self.assertTrue('resource_uri' in obj and obj['resource_uri'])
+
+ prev_obj = obj
+ # Test filter layers by name
+ filter_url = self.layer_list_url + '?name=' + self.layer.name
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ # This is a list url
+ objects = self.deserialize(resp)['objects']
+ self.assertEqual(len(objects), 1)
+ obj = objects[0]
+
+ self.assertEqual(obj, prev_obj)
+
+ def test_style_interaction(self):
+ """Style API interaction check."""
+
+ # filter styles by layer id
+ filter_url = self.style_list_url + '?layer__id=' + str(self.layer.id)
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ # This is a list url
+ objects = self.deserialize(resp)['objects']
+
+ self.assertEqual(len(objects), 1)
+
+ # filter styles by layer name
+ filter_url = self.style_list_url + '?layer__name=' + self.layer.name
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ # This is a list url
+ objects = self.deserialize(resp)['objects']
+
+ self.assertEqual(len(objects), 1)
+
+ # Check necessary list fields
+ obj = objects[0]
+ field_list = [
+ 'layer',
+ 'name',
+ 'title',
+ 'style_url',
+ 'type',
+ 'resource_uri'
+ ]
+
+ # Additional field based on OGC Backend
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
+ field_list += [
+ 'version',
+ 'workspace'
+ ]
+ elif check_ogc_backend(qgis_server.BACKEND_PACKAGE):
+ field_list += [
+ 'style_legend_url'
+ ]
+ for f in field_list:
+ self.assertTrue(f in obj)
+
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
+ self.assertEqual(obj['type'], 'sld')
+ elif check_ogc_backend(qgis_server.BACKEND_PACKAGE):
+ self.assertEqual(obj['type'], 'qml')
+
+ # Check style detail
+ detail_url = obj['resource_uri']
+ resp = self.api_client.get(detail_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+
+ # should include body field
+ self.assertTrue('body' in obj and obj['body'])
+
+ @on_ogc_backend(qgis_server.BACKEND_PACKAGE)
+ def test_add_delete_styles(self):
+ """Style API Add/Delete interaction."""
+ # Check styles count
+ style_list_url = reverse(
+ 'api_dispatch_list',
+ kwargs={
+ 'api_name': 'api',
+ 'resource_name': 'styles'
+ }
+ )
+ filter_url = style_list_url + '?layer__name=' + self.layer.name
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ objects = self.deserialize(resp)['objects']
+
+ self.assertEqual(len(objects), 1)
+
+ # Fetch default style
+ layer_detail_url = reverse(
+ 'api_dispatch_detail',
+ kwargs={
+ 'api_name': 'api',
+ 'resource_name': 'layers',
+ 'id': self.layer.id
+ }
+ )
+ resp = self.api_client.get(layer_detail_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+ # Take default style url from Layer detail info
+ default_style_url = obj['default_style']
+ resp = self.api_client.get(default_style_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+ style_body = obj['body']
+
+ style_stream = StringIO(style_body)
+ # Add virtual filename
+ style_stream.name = 'style.qml'
+ data = {
+ 'layer__id': self.layer.id,
+ 'name': 'new_style',
+ 'title': 'New Style',
+ 'style': style_stream
+ }
+ # Use default client to request
+ resp = self.client.post(style_list_url, data=data)
+
+ # Should not be able to add style without authentication
+ self.assertEqual(resp.status_code, 403)
+
+ # Login using anonymous user
+ self.client.login(username='AnonymousUser')
+ style_stream.seek(0)
+ resp = self.client.post(style_list_url, data=data)
+ # Should not be able to add style without correct permission
+ self.assertEqual(resp.status_code, 403)
+ self.client.logout()
+
+ # Use admin credentials
+ self.client.login(username='admin', password='admin')
+ style_stream.seek(0)
+ resp = self.client.post(style_list_url, data=data)
+ self.assertEqual(resp.status_code, 201)
+
+ # Check styles count
+ filter_url = style_list_url + '?layer__name=' + self.layer.name
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ objects = self.deserialize(resp)['objects']
+
+ self.assertEqual(len(objects), 2)
+
+ # Attempt to set default style
+ resp = self.api_client.get(layer_detail_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+ # Get style list and get new default style
+ styles = obj['styles']
+ new_default_style = None
+ for s in styles:
+ if not s == obj['default_style']:
+ new_default_style = s
+ break
+ obj['default_style'] = new_default_style
+ # Put the new update
+ patch_data = {
+ 'default_style': new_default_style
+ }
+ resp = self.client.patch(
+ layer_detail_url,
+ data=json.dumps(patch_data),
+ content_type='application/json')
+ self.assertEqual(resp.status_code, 200)
+
+ # check new default_style
+ resp = self.api_client.get(layer_detail_url)
+ self.assertValidJSONResponse(resp)
+ obj = self.deserialize(resp)
+ self.assertEqual(obj['default_style'], new_default_style)
+
+ # Attempt to delete style
+ filter_url = style_list_url + '?layer__id=%d&name=%s' % (
+ self.layer.id, data['name'])
+ resp = self.api_client.get(filter_url)
+ self.assertValidJSONResponse(resp)
+ objects = self.deserialize(resp)['objects']
+
+ resource_uri = objects[0]['resource_uri']
+
+ resp = self.client.delete(resource_uri)
+ self.assertEqual(resp.status_code, 204)
+
+ resp = self.api_client.get(filter_url)
+ meta = self.deserialize(resp)['meta']
+
+ self.assertEqual(meta['total_count'], 0)
diff --git a/geonode/api/urls.py b/geonode/api/urls.py
index 074acec4695..f959d854c74 100644
--- a/geonode/api/urls.py
+++ b/geonode/api/urls.py
@@ -20,9 +20,7 @@
from tastypie.api import Api
-from geonode import qgis_server
-from geonode.api.api import QGISStyleResource
-from geonode.utils import check_ogc_backend
+from geonode.api.api import StyleResource
from .api import TagResource, TopicCategoryResource, ProfileResource, \
GroupResource, RegionResource, OwnersResource, ThesaurusKeywordResource, \
GroupCategoryResource
@@ -44,6 +42,4 @@
api.register(OwnersResource())
api.register(ThesaurusKeywordResource())
api.register(GroupCategoryResource())
-
-if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
- api.register(QGISStyleResource())
+api.register(StyleResource())
diff --git a/geonode/base/populate_test_data.py b/geonode/base/populate_test_data.py
index 89643383286..8ea302ae923 100644
--- a/geonode/base/populate_test_data.py
+++ b/geonode/base/populate_test_data.py
@@ -38,7 +38,8 @@
import os.path
-if 'geonode.geoserver' in settings.INSTALLED_APPS:
+def disconnect_signals():
+ """Disconnect signals for test class purposes."""
from django.db.models import signals
from geonode.geoserver.signals import geoserver_pre_save_maplayer
from geonode.geoserver.signals import geoserver_post_save_map
@@ -49,6 +50,23 @@
signals.pre_save.disconnect(geoserver_pre_save, sender=Layer)
signals.post_save.disconnect(geoserver_post_save, sender=Layer)
+
+def reconnect_signals():
+ """Reconnect signals for test class purposes."""
+ from django.db.models import signals
+ from geonode.geoserver.signals import geoserver_pre_save_maplayer
+ from geonode.geoserver.signals import geoserver_post_save_map
+ from geonode.geoserver.signals import geoserver_pre_save
+ from geonode.geoserver.signals import geoserver_post_save
+ signals.pre_save.connect(geoserver_pre_save_maplayer, sender=MapLayer)
+ signals.post_save.connect(geoserver_post_save_map, sender=Map)
+ signals.pre_save.connect(geoserver_pre_save, sender=Layer)
+ signals.post_save.connect(geoserver_post_save, sender=Layer)
+
+
+if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ disconnect_signals()
+
# This is used to populate the database with the search fixture data. This is
# primarily used as a first step to generate the json data for the fixture using
# django's dumpdata
diff --git a/geonode/layers/forms.py b/geonode/layers/forms.py
index b7ae67e0c8f..c47c140c3ff 100644
--- a/geonode/layers/forms.py
+++ b/geonode/layers/forms.py
@@ -25,6 +25,10 @@
from django.conf import settings
from django import forms
+
+from geonode import geoserver, qgis_server
+from geonode.utils import check_ogc_backend
+
try:
import json
except ImportError:
@@ -193,9 +197,9 @@ def write_files(self):
class NewLayerUploadForm(LayerUploadForm):
- if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
sld_file = forms.FileField(required=False)
- if 'geonode.qgis_server' in settings.INSTALLED_APPS:
+ if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
qml_file = forms.FileField(required=False)
xml_file = forms.FileField(required=False)
@@ -213,9 +217,9 @@ class NewLayerUploadForm(LayerUploadForm):
"xml_file",
]
# Adding style file based on the backend
- if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
spatial_files.append('sld_file')
- if 'geonode.qgis_server' in settings.INSTALLED_APPS:
+ if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
spatial_files.append('qml_file')
spatial_files = tuple(spatial_files)
diff --git a/geonode/layers/models.py b/geonode/layers/models.py
index 573b519c0e3..5127fb61af6 100644
--- a/geonode/layers/models.py
+++ b/geonode/layers/models.py
@@ -35,7 +35,9 @@
from geonode.people.utils import get_valid_user
from agon_ratings.models import OverallRating
from geonode.utils import check_shp_columnnames
-from geonode.security.models import remove_object_permissions
+from geonode.security.models import (
+ remove_object_permissions,
+ PermissionLevelMixin)
logger = logging.getLogger("geonode.layers.models")
@@ -59,7 +61,7 @@
}
-class Style(models.Model):
+class Style(models.Model, PermissionLevelMixin):
"""Model for storing styles.
"""
@@ -89,6 +91,16 @@ def absolute_url(self):
logger.error("SLD URL is empty for Style %s" % self.name.encode('utf-8'))
return None
+ def get_self_resource(self):
+ """Get associated resource base."""
+ # Associate this model with resource
+ try:
+ layer = self.layer_styles.first()
+ """:type: Layer"""
+ return layer.get_self_resource()
+ except:
+ return None
+
class LayerManager(ResourceBaseManager):
diff --git a/geonode/layers/views.py b/geonode/layers/views.py
index 324c3017168..2e8f70c3356 100644
--- a/geonode/layers/views.py
+++ b/geonode/layers/views.py
@@ -41,6 +41,9 @@
from django.conf import settings
from django.template import RequestContext
from django.utils.translation import ugettext as _
+
+from geonode import geoserver, qgis_server
+
try:
import json
except ImportError:
@@ -61,7 +64,7 @@
from geonode.base.models import TopicCategory
from geonode.groups.models import GroupProfile
-from geonode.utils import default_map_config
+from geonode.utils import default_map_config, check_ogc_backend
from geonode.utils import GXPLayer
from geonode.utils import GXPMap
from geonode.layers.utils import file_upload, is_raster, is_vector
@@ -77,7 +80,7 @@
if 'geonode.geoserver' in settings.INSTALLED_APPS:
from geonode.geoserver.helpers import _render_thumbnail
-if 'geonode.qgis_server' in settings.INSTALLED_APPS:
+if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
from geonode.qgis_server.models import QGISServerLayer
CONTEXT_LOG_FILE = ogc_server_settings.LOG_FILE
@@ -202,6 +205,7 @@ def layer_upload(request, template='upload/layer_upload.html'):
out['url'] = reverse(
'layer_detail', args=[
saved_layer.service_typename])
+ out['ogc_backend'] = settings.OGC_SERVER['default']['BACKEND']
upload_session = saved_layer.upload_session
upload_session.processed = True
upload_session.save()
@@ -765,17 +769,19 @@ def layer_replace(request, layername, template='layers/layer_replace.html'):
out['success'] = False
out['errors'] = _("You are attempting to replace a raster layer with a vector.")
else:
- if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
# delete geoserver's store before upload
cat = gs_catalog
cascading_delete(cat, layer.typename)
- elif 'geonode.qgis_server' in settings.INSTALLED_APPS:
+ out['ogc_backend'] = geoserver.BACKEND_PACKAGE
+ elif check_ogc_backend(qgis_server.BACKEND_PACKAGE):
try:
qgis_layer = QGISServerLayer.objects.get(
layer=layer)
qgis_layer.delete()
except QGISServerLayer.DoesNotExist:
pass
+ out['ogc_backend'] = qgis_server.BACKEND_PACKAGE
saved_layer = file_upload(
base_file,
diff --git a/geonode/maps/templates/maps/map_detail.html b/geonode/maps/templates/maps/map_detail.html
index 32a3a7e3518..9563a7aa13a 100644
--- a/geonode/maps/templates/maps/map_detail.html
+++ b/geonode/maps/templates/maps/map_detail.html
@@ -264,7 +264,7 @@ {% trans "Embed Iframe" %}
To embed this map, add the following code snippet and customize its properties (scrolling, width,
height) based on your needs to your site:
- <iframe scrolling="yes" src="{{ SITEURL }}{% url "map_embed" resource.id %}"
+ <iframe scrolling="yes" src="{{ SITEURL|slice:":-1" }}{% url "map_embed" resource.id %}"
width="480px" height="360px"></iframe>
@@ -310,7 +310,6 @@ {% trans "Embed Widget" %}
-
{% endblock %}
{% block extra_script %}
diff --git a/geonode/maps/urls.py b/geonode/maps/urls.py
index c133109a0cb..3f7c89dde14 100644
--- a/geonode/maps/urls.py
+++ b/geonode/maps/urls.py
@@ -19,10 +19,12 @@
#########################################################################
from django.conf.urls import patterns, url
-from django.conf import settings
from django.views.generic import TemplateView
-from geonode.maps.qgis_server_views import MapCreateView, MapDetailView, MapEmbedView
+from geonode import geoserver, qgis_server
+from geonode.maps.qgis_server_views import MapCreateView, \
+ MapDetailView, MapEmbedView
+from geonode.utils import check_ogc_backend
js_info_dict = {
'packages': ('geonode.maps',),
@@ -31,12 +33,12 @@
new_map_view = 'new_map'
existing_map_view = 'map_view'
-if 'geonode.geoserver' in settings.INSTALLED_APPS:
+if check_ogc_backend(geoserver.BACKEND_PACKAGE):
new_map_view = 'new_map'
existing_map_view = 'map_view'
map_embed = 'map_embed'
-elif 'geonode.qgis_server' in settings.INSTALLED_APPS:
+elif check_ogc_backend(qgis_server.BACKEND_PACKAGE):
new_map_view = MapCreateView.as_view()
existing_map_view = MapDetailView.as_view()
map_embed = MapEmbedView.as_view()
diff --git a/geonode/qgis_server/helpers.py b/geonode/qgis_server/helpers.py
index c67eba5de41..5e0c5f86a4b 100644
--- a/geonode/qgis_server/helpers.py
+++ b/geonode/qgis_server/helpers.py
@@ -23,6 +23,7 @@
import re
import shutil
import urllib
+import json
from urlparse import urljoin
import requests
@@ -468,6 +469,98 @@ def legend_url(layer, layertitle=False, style=None, internal=True):
return url
+def qgs_url(layer, style=None, internal=True):
+ """Construct QGIS Server url to fetch QGS.
+
+ :param layer: Layer to use
+ :type layer: Layer
+
+ :param style: Layer style to choose
+ :type style: str
+
+ :param internal: Flag to switch between public url and internal url.
+ Public url will be served by Django Geonode (proxified).
+ :type internal: bool
+
+ :return: QGIS Server request url for QGS
+ :rtype: str
+ """
+ qgis_server_url = qgis_server_endpoint(internal)
+
+ ogc_url = reverse('qgis_server:layer-request',
+ kwargs={'layername': layer.name})
+ url = settings.SITEURL + ogc_url.replace("/", "", 1)
+
+ # for now, style always set to ''
+ if not style:
+ style = ''
+ else:
+ style = 'default'
+
+ layers = [{
+ 'type': 'raster',
+ 'display': layer.name,
+ 'driver': 'wms',
+ 'crs': 'EPSG:4326',
+ 'format': 'image/png',
+ 'styles': style,
+ 'layers': layer.name,
+ 'url': url
+ }]
+
+ json_layers = json.dumps(layers)
+
+ url_server = qgis_server_url + \
+ '?SERVICE=PROJECTDEFINITIONS&LAYERS=' + json_layers
+
+ return url_server
+
+
+def qlr_url(layer, style=None, internal=True):
+ """Construct QGIS Server url to fetch QLR.
+
+ :param layer: Layer to use
+ :type layer: Layer
+
+ :param style: Layer style to choose
+ :type style: str
+
+ :param internal: Flag to switch between public url and internal url.
+ Public url will be served by Django Geonode (proxified).
+ :type internal: bool
+
+ :return: QGIS Server request url for QLR
+ :rtype: str
+ """
+ qgis_server_url = qgis_server_endpoint(internal)
+ ogc_url = reverse('qgis_server:layer-request',
+ kwargs={'layername': layer.name})
+ url = settings.SITEURL + ogc_url.replace("/", "", 1)
+
+ # for now, style always set to ''
+ if not style:
+ style = ''
+ else:
+ style = 'default'
+
+ layers = [{
+ 'type': 'raster',
+ 'display': layer.name,
+ 'driver': 'wms',
+ 'crs': 'EPSG:4326',
+ 'format': 'image/png',
+ 'styles': style,
+ 'layers': layer.name,
+ 'url': url
+ }]
+ json_layers = json.dumps(layers)
+
+ url_server = qgis_server_url + \
+ '?SERVICE=LAYERDEFINITIONS&LAYERS=' + json_layers
+
+ return url_server
+
+
def wms_get_capabilities_url(layer=None, internal=True):
"""Construct WMS GetCapabilities request.
diff --git a/geonode/qgis_server/management/commands/import_qgis_styles.py b/geonode/qgis_server/management/commands/import_qgis_styles.py
new file mode 100644
index 00000000000..77c390767b2
--- /dev/null
+++ b/geonode/qgis_server/management/commands/import_qgis_styles.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+#########################################################################
+#
+# Copyright (C) 2016 OSGeo
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+#########################################################################
+
+from django.core.management.base import BaseCommand
+from geonode.layers.models import Layer
+from geonode.qgis_server.helpers import style_list
+
+
+class Command(BaseCommand):
+ help = ("Import QGIS Server styles associated with layers.")
+
+ def handle(self, *args, **options):
+ layers = Layer.objects.all()
+
+ for l in layers:
+ try:
+ l.qgis_layer
+ except:
+ print 'Layer %s has no associated qgis_layer' % l.name
+ continue
+
+ if l.qgis_layer:
+ print 'Fetching styles for layer %s' % l.name
+
+ try:
+ styles = style_list(l, internal=False)
+ except:
+ print 'Failed to fetch styles'
+ continue
+
+ print 'Successfully fetch %d style(s)' % len(styles)
+ print ''
diff --git a/geonode/qgis_server/management/commands/tests.py b/geonode/qgis_server/management/commands/tests.py
index 0d4ddf59339..edf3c6f724f 100644
--- a/geonode/qgis_server/management/commands/tests.py
+++ b/geonode/qgis_server/management/commands/tests.py
@@ -24,7 +24,7 @@
import gisdata
from django.core.management import call_command
from django.test import LiveServerTestCase
-from geonode.qgis_server.models import QGISServerLayer
+from geonode.qgis_server.models import QGISServerLayer, QGISServerStyle
from geonode.layers.models import Layer
@@ -134,3 +134,43 @@ def test_import_layers(self):
# Cleanup
layer.delete()
+
+ def test_import_qgis_styles(self):
+ """import_qgis_styles management commands should run properly."""
+ filename = os.path.join(
+ gisdata.GOOD_DATA,
+ 'vector/san_andres_y_providencia_administrative.shp')
+ call_command('importlayers', filename, overwrite=True)
+
+ # Check layer have default style after importing
+ layer = Layer.objects.get(
+ name='san_andres_y_providencia_administrative')
+
+ qgis_layer = layer.qgis_layer
+ self.assertTrue(qgis_layer.default_style)
+
+ self.assertTrue(QGISServerStyle.objects.count() == 1)
+
+ # Delete styles
+ qgis_layer.default_style.delete()
+
+ self.assertTrue(QGISServerStyle.objects.count() == 0)
+
+ qgis_layer.refresh_from_db()
+
+ self.assertFalse(qgis_layer.default_style)
+
+ # Import styles
+
+ call_command('import_qgis_styles')
+
+ layer.refresh_from_db()
+ qgis_layer = layer.qgis_layer
+ qgis_layer.refresh_from_db()
+
+ self.assertTrue(qgis_layer.default_style)
+
+ self.assertTrue(QGISServerStyle.objects.count() == 1)
+
+ # Cleanup
+ layer.delete()
diff --git a/geonode/qgis_server/migrations/0005_auto_20170823_0341.py b/geonode/qgis_server/migrations/0005_auto_20170823_0341.py
new file mode 100644
index 00000000000..63d413bf447
--- /dev/null
+++ b/geonode/qgis_server/migrations/0005_auto_20170823_0341.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('qgis_server', '0004_auto_20170805_0223'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='qgisserverlayer',
+ name='default_style',
+ field=models.ForeignKey(related_name='layer_default_style', on_delete=django.db.models.deletion.SET_NULL, default=None, to='qgis_server.QGISServerStyle', null=True),
+ ),
+ ]
diff --git a/geonode/qgis_server/models.py b/geonode/qgis_server/models.py
index 53133921de6..8b1692a7539 100644
--- a/geonode/qgis_server/models.py
+++ b/geonode/qgis_server/models.py
@@ -27,6 +27,7 @@
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
+from geonode.security.models import PermissionLevelMixin
from lxml import etree
from geonode import qgis_server
@@ -44,7 +45,7 @@
os.mkdir(QGIS_LAYER_DIRECTORY)
-class QGISServerLayer(models.Model):
+class QGISServerLayer(models.Model, PermissionLevelMixin):
"""Model for Layer in QGIS Server Backend.
"""
@@ -71,7 +72,8 @@ class QGISServerLayer(models.Model):
'QGISServerStyle',
related_name='layer_default_style',
default=None,
- null=True)
+ null=True,
+ on_delete=models.SET_NULL)
styles = models.ManyToManyField(
'QGISServerStyle',
related_name='layer_styles')
@@ -175,8 +177,16 @@ def delete_qgis_layer(self):
for style in QGISServerStyle.objects.filter(layer_styles=None):
style.delete()
+ def get_self_resource(self):
+ """Get associated resource base."""
+ # Associate this model with resource
+ try:
+ return self.layer.get_self_resource()
+ except:
+ return None
+
-class QGISServerStyle(models.Model):
+class QGISServerStyle(models.Model, PermissionLevelMixin):
"""Model wrapper for QGIS Server styles."""
name = models.CharField(_('style name'), max_length=255)
@@ -296,8 +306,18 @@ def style_tile_cache_path(self):
return os.path.join(
QGIS_TILES_DIRECTORY, self.layer_styles.first().layer.name, self.name)
+ def get_self_resource(self):
+ """Get associated resource base."""
+ # Associate this model with resource
+ try:
+ qgis_layer = self.layer_styles.first()
+ """:type: QGISServerLayer"""
+ return qgis_layer.get_self_resource()
+ except:
+ return None
+
-class QGISServerMap(models.Model):
+class QGISServerMap(models.Model, PermissionLevelMixin):
"""Model wrapper for QGIS Server Map."""
map = models.OneToOneField(
@@ -347,6 +367,14 @@ def cache_path(self):
"""
return os.path.join(QGIS_TILES_DIRECTORY, self.qgis_map_name)
+ def get_self_resource(self):
+ """Get associated resource base."""
+ # Associate this model with resource
+ try:
+ return self.layer.get_self_resource()
+ except:
+ return None
+
from geonode.qgis_server.signals import \
register_qgis_server_signals # noqa: F402,F401
diff --git a/geonode/qgis_server/signals.py b/geonode/qgis_server/signals.py
index 76a2ec56632..8f4f4e4a226 100644
--- a/geonode/qgis_server/signals.py
+++ b/geonode/qgis_server/signals.py
@@ -215,6 +215,26 @@ def qgis_server_post_save(instance, sender, **kwargs):
)
)
+ # QGS link layer workspace
+ ogc_qgs_url = urljoin(
+ base_url,
+ reverse(
+ 'qgis_server:download-qgs',
+ kwargs={'layername': instance.name}))
+ logger.debug('qgs_download_url: %s' % ogc_qgs_url)
+ link_name = 'QGS Layer file'
+ link_mime = 'application/xml'
+ Link.objects.update_or_create(
+ resource=instance.resourcebase_ptr,
+ name=link_name,
+ defaults=dict(
+ extension='qgs',
+ mime=link_mime,
+ url=ogc_qgs_url,
+ link_type='data'
+ )
+ )
+
if instance.is_vector():
# WFS link layer workspace
ogc_wfs_url = urljoin(
@@ -236,6 +256,26 @@ def qgis_server_post_save(instance, sender, **kwargs):
)
)
+ # QLR link layer workspace
+ ogc_qlr_url = urljoin(
+ base_url,
+ reverse(
+ 'qgis_server:download-qlr',
+ kwargs={'layername': instance.name}))
+ logger.debug('qlr_download_url: %s' % ogc_qlr_url)
+ link_name = 'QGIS Layer file'
+ link_mime = 'application/xml'
+ Link.objects.update_or_create(
+ resource=instance.resourcebase_ptr,
+ name=link_name,
+ defaults=dict(
+ extension='qlr',
+ mime=link_mime,
+ url=ogc_qlr_url,
+ link_type='data'
+ )
+ )
+
# if layer has overwrite attribute, then it probably comes from
# importlayers management command and needs to be overwritten
overwrite = getattr(instance, 'overwrite', False)
diff --git a/geonode/qgis_server/tests/data/test_grid.qml b/geonode/qgis_server/tests/data/test_grid.qml
new file mode 100644
index 00000000000..217a5921214
--- /dev/null
+++ b/geonode/qgis_server/tests/data/test_grid.qml
@@ -0,0 +1,16 @@
+
+
+
+
+
+-9999
+-236.1545301164897
+StretchToMinimumMaximum
+
+
+
+
+
+
+0
+
diff --git a/geonode/qgis_server/tests/test_views.py b/geonode/qgis_server/tests/test_views.py
index e5be43d4758..592df6b7b43 100644
--- a/geonode/qgis_server/tests/test_views.py
+++ b/geonode/qgis_server/tests/test_views.py
@@ -171,7 +171,7 @@ def test_ogc_specific_layer(self):
reverse('qgis_server:set-thumbnail', kwargs=params),
data=data)
# User dont have permission
- self.assertEqual(response.status_code, 401)
+ self.assertEqual(response.status_code, 403)
# Should log in
self.client.login(username='admin', password='admin')
response = self.client.post(
@@ -291,6 +291,11 @@ class QGISServerStyleManagerTest(LiveServerTestCase):
def setUp(self):
call_command('loaddata', 'people_data', verbosity=0)
+ def data_path(self, path):
+ project_root = os.path.abspath(settings.PROJECT_ROOT)
+ return os.path.join(
+ project_root, 'qgis_server/tests/data', path)
+
@on_ogc_backend(qgis_server.BACKEND_PACKAGE)
def test_list_style(self):
"""Test querying list of styles from QGIS Server."""
@@ -304,8 +309,95 @@ def test_list_style(self):
# There will be a default style
self.assertEqual(
set(expected_list_style),
- set([style.name for style in actual_list_style])
- )
+ set([style.name for style in actual_list_style]))
+
+ style_list_url = reverse(
+ 'qgis_server:download-qml',
+ kwargs={
+ 'layername': layer.name
+ })
+ response = self.client.get(style_list_url)
+ self.assertEqual(response.status_code, 200)
+ actual_list_style = json.loads(response.content)
+
+ # There will be a default style
+ self.assertEqual(
+ set(expected_list_style),
+ set([style['name'] for style in actual_list_style]))
+
+ layer.delete()
+
+ @on_ogc_backend(qgis_server.BACKEND_PACKAGE)
+ def test_add_delete_style(self):
+ """Test add new style using qgis_server views."""
+ filename = os.path.join(gisdata.GOOD_DATA, 'raster/test_grid.tif')
+ layer = file_upload(filename)
+ """:type: geonode.layers.models.Layer"""
+
+ self.client.login(username='admin', password='admin')
+
+ qml_path = self.data_path('test_grid.qml')
+ add_style_url = reverse(
+ 'qgis_server:upload-qml',
+ kwargs={
+ 'layername': layer.name})
+
+ with open(qml_path) as file_handle:
+ form_data = {
+ 'name': 'new_style',
+ 'title': 'New Style',
+ 'qml': file_handle
+ }
+ response = self.client.post(
+ add_style_url,
+ data=form_data)
+
+ self.assertEqual(response.status_code, 201)
+
+ actual_list_style = style_list(layer, internal=False)
+
+ expected_list_style = ['default', 'new_style']
+
+ self.assertEqual(
+ set(expected_list_style),
+ set([style.name for style in actual_list_style]))
+
+ # Test delete request
+ delete_style_url = reverse(
+ 'qgis_server:remove-qml',
+ kwargs={
+ 'layername': layer.name,
+ 'style_name': 'default'})
+
+ response = self.client.delete(delete_style_url)
+ self.assertEqual(response.status_code, 200)
+
+ actual_list_style = style_list(layer, internal=False)
+
+ expected_list_style = ['new_style']
+
+ self.assertEqual(
+ set(expected_list_style),
+ set([style.name for style in actual_list_style]))
+
+ # Check new default
+ default_style_url = reverse(
+ 'qgis_server:default-qml',
+ kwargs={
+ 'layername': layer.name})
+
+ response = self.client.get(default_style_url)
+
+ self.assertEqual(response.status_code, 200)
+ expected_default_style_retval = {
+ 'name': 'new_style',
+ }
+ actual_default_style_retval = json.loads(response.content)
+
+ for key, value in expected_default_style_retval.iteritems():
+ self.assertEqual(actual_default_style_retval[key], value)
+
+ layer.delete()
class ThumbnailGenerationTest(LiveServerTestCase):
diff --git a/geonode/qgis_server/urls.py b/geonode/qgis_server/urls.py
index 0297511d78a..6988ce2ad20 100644
--- a/geonode/qgis_server/urls.py
+++ b/geonode/qgis_server/urls.py
@@ -22,6 +22,7 @@
from geonode.qgis_server.views import (
download_zip,
+ download_qgs,
tile,
tile_404,
legend,
@@ -30,6 +31,7 @@
qgis_server_pdf,
qgis_server_map_print,
geotiff,
+ download_qlr,
qml_style, set_thumbnail, default_qml_style)
@@ -42,6 +44,11 @@
download_zip,
name='download-zip'
),
+ url(
+ r'^download-qgs/(?P[\w]*)$',
+ download_qgs,
+ name='download-qgs'
+ ),
url(
r'^tiles/'
r'(?P[^/]*)/'
@@ -123,28 +130,33 @@
name='map-print'
),
url(
- r'^style/(?P[^/]*)(?:/(?P[^/]*))?/edit$',
- qml_style,
- name='upload-qml'
+ r'^style/default/(?P[^/]*)(?:/(?P[^/]*))?$',
+ default_qml_style,
+ name='default-qml'
),
url(
- r'^style/(?P[^/]*)(?:/(?P[^/]*))?$',
+ r'^style/upload/(?P[^/]*)(?:/(?P[^/]*))?$',
qml_style,
- name='download-qml'
+ name='upload-qml'
),
url(
- r'^style/(?P[^/]*)(?:/(?P[^/]*))?$',
+ r'^style/remove/(?P[^/]*)/(?P[^/]*)$',
qml_style,
name='remove-qml'
),
url(
- r'^style/(?P[^/]*)/(?P[^/]*)/default$',
- default_qml_style,
- name='default-qml'
+ r'^style/download/(?P[^/]*)(?:/(?P[^/]*))?$',
+ qml_style,
+ name='download-qml'
),
url(
r'^thumbnail/set/(?P[^/]*)$',
set_thumbnail,
name='set-thumbnail'
),
+ url(
+ r'^download-qlr/(?P[\w]*)$',
+ download_qlr,
+ name='download-qlr'
+ ),
)
diff --git a/geonode/qgis_server/views.py b/geonode/qgis_server/views.py
index 02603f09bf5..8a22586d07a 100644
--- a/geonode/qgis_server/views.py
+++ b/geonode/qgis_server/views.py
@@ -47,6 +47,8 @@
tile_url_format,
legend_url,
tile_url,
+ qgs_url,
+ qlr_url,
qgis_server_endpoint, style_get_url, style_list, style_add_url,
style_remove_url, style_set_default_url)
from geonode.qgis_server.models import QGISServerLayer
@@ -106,6 +108,29 @@ def download_zip(request, layername):
return resp
+def download_qgs(request, layername):
+ """Download QGS file for a layer.
+
+ :param layername: The request from frontend.
+ :type layername: HttpRequest
+
+ :param layername: The layer name in Geonode.
+ :type layername: basestring
+
+ :return: QGS file.
+ """
+ layer = get_object_or_404(Layer, name=layername)
+ url = qgs_url(layer, internal=True)
+ request = requests.get(url)
+ response = HttpResponse(
+ request.content, content_type="application/xml",
+ status=request.status_code)
+ response['Content-Disposition'] = \
+ 'attachment; filename=%s' % layer.name + '.qgs'
+
+ return response
+
+
def legend(request, layername, layertitle=False, style=None):
"""Get the legend from a layer.
@@ -498,7 +523,7 @@ def qml_style(request, layername, style_name=None):
'change_resourcebase', layer.get_self_resource()):
return HttpResponse(
'User does not have permission to change QML style.',
- status=401)
+ status=403)
# Request about adding new QML style
@@ -512,7 +537,7 @@ def qml_style(request, layername, style_name=None):
'resource': layer,
'style_upload_form': form
},
- status=200).render()
+ status=400).render()
try:
uploaded_qml = request.FILES['qml']
@@ -561,7 +586,7 @@ def qml_style(request, layername, style_name=None):
'alert_message': response.content,
'alert_class': 'alert-danger'
},
- status=200).render()
+ status=response.status_code).render()
# We succeeded on adding new style
@@ -583,7 +608,7 @@ def qml_style(request, layername, style_name=None):
'alert_class': 'alert-success',
'alert_message': alert_message
},
- status=200).render()
+ status=201).render()
except Exception as e:
logger.exception(e)
@@ -621,7 +646,7 @@ def qml_style(request, layername, style_name=None):
'alert_message': alert_message,
'alert_class': 'alert-danger'
},
- status=200).render()
+ status=response.status_code).render()
# Successfully removed styles
# Handle when default style is deleted.
@@ -645,7 +670,7 @@ def qml_style(request, layername, style_name=None):
return HttpResponseBadRequest()
-def default_qml_style(request, layername, style_name):
+def default_qml_style(request, layername, style_name=None):
"""Set default style used by layer.
:param layername: The layer name in Geonode.
@@ -667,6 +692,16 @@ def default_qml_style(request, layername, style_name):
return HttpResponse(
json.dumps(retval), content_type='application/json')
elif request.method == 'POST':
+ # For people who uses API request
+ if not request.user.has_perm(
+ 'change_resourcebase', layer.get_self_resource()):
+ return HttpResponse(
+ 'User does not have permission to change QML style.',
+ status=403)
+
+ if not style_name:
+ return HttpResponseBadRequest()
+
style_url = style_set_default_url(layer, style_name)
response = requests.get(style_url)
@@ -715,7 +750,7 @@ def set_thumbnail(request, layername):
'change_resourcebase', layer.get_self_resource()):
return HttpResponse(
'User does not have permission to change thumbnail.',
- status=401)
+ status=403)
# extract bbox
bbox_string = request.POST['bbox']
@@ -730,3 +765,25 @@ def set_thumbnail(request, layername):
}
return HttpResponse(
json.dumps(retval), content_type="application/json")
+
+
+def download_qlr(request, layername):
+ """Download QLR file for a layer.
+
+ :param layername: The layer name in Geonode.
+ :type layername: basestring
+
+ :return: QLR file.
+ """
+ layer = get_object_or_404(Layer, name=layername)
+ url = qlr_url(layer, internal=True)
+
+ request = requests.get(url)
+ response = HttpResponse(
+ request.content,
+ content_type="application/xml",
+ status=request.status_code)
+ response['Content-Disposition'] = \
+ 'attachment; filename=%s' % layer.name + '.qlr'
+
+ return response
diff --git a/geonode/qgis_server/xml_utilities.py b/geonode/qgis_server/xml_utilities.py
index 331c77f8c76..dd039cede47 100644
--- a/geonode/qgis_server/xml_utilities.py
+++ b/geonode/qgis_server/xml_utilities.py
@@ -103,7 +103,12 @@ def insert_xml_element(root, element_path):
element = root.find(path, XML_NS)
if element is None:
# if a parent is missing insert it at the right place
- element = ElementTree.SubElement(parent, tag)
+ try:
+ element = ElementTree.SubElement(parent, tag)
+ except:
+ # In some cases we can't add parent because the tag name is
+ # not specific
+ pass
parent = element
return element
diff --git a/geonode/settings.py b/geonode/settings.py
index 5a185ce6112..beb022f77f5 100644
--- a/geonode/settings.py
+++ b/geonode/settings.py
@@ -867,6 +867,8 @@
DOWNLOAD_FORMATS_VECTOR = [
'JPEG', 'PDF', 'PNG', 'Zipped Shapefile', 'GML 2.0', 'GML 3.1.1', 'CSV',
'Excel', 'GeoJSON', 'KML', 'View in Google Earth', 'Tiles',
+ 'QGIS Layer file',
+ 'QGS Layer file',
]
DOWNLOAD_FORMATS_RASTER = [
'JPEG',
@@ -881,6 +883,8 @@
'Tiles',
'GML',
'GZIP',
+ 'QGIS Layer file',
+ 'QGS Layer file',
'Zipped All Files'
]
diff --git a/geonode/static/geonode/js/upload/LayerInfo.js b/geonode/static/geonode/js/upload/LayerInfo.js
index b2f00325283..112b2d20252 100644
--- a/geonode/static/geonode/js/upload/LayerInfo.js
+++ b/geonode/static/geonode/js/upload/LayerInfo.js
@@ -339,6 +339,10 @@ define(function (require, exports) {
var b = '' + gettext('Edit Metadata') + '';
var c = '' + gettext('Upload Metadata') + '';
var d = '' + gettext('Manage Styles') + '';
+ if(resp.ogc_backend == 'geonode.qgis_server'){
+ // QGIS Server has no manage style interaction.
+ d = '';
+ }
var msg_col = "";
if (resp.info){
var msg_template = gettext('The column %1 was renamed to %2
');
diff --git a/geonode/upload/forms.py b/geonode/upload/forms.py
index 38fda01cdd4..0e5351ab7f9 100644
--- a/geonode/upload/forms.py
+++ b/geonode/upload/forms.py
@@ -24,9 +24,12 @@
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
+
+from geonode import geoserver, qgis_server
from geonode.layers.forms import JSONField
from geonode.upload.models import UploadFile
from geonode.geoserver.helpers import ogc_server_settings
+from geonode.utils import check_ogc_backend
class UploadFileForm(forms.ModelForm):
@@ -43,9 +46,9 @@ class LayerUploadForm(forms.Form):
prj_file = forms.FileField(required=False)
xml_file = forms.FileField(required=False)
- if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
sld_file = forms.FileField(required=False)
- if 'geonode.qgis_server' in settings.INSTALLED_APPS:
+ if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
qml_file = forms.FileField(required=False)
geogig = forms.BooleanField(required=False)
@@ -77,9 +80,9 @@ class LayerUploadForm(forms.Form):
"xml_file",
]
# Adding style file based on the backend
- if 'geonode.geoserver' in settings.INSTALLED_APPS:
+ if check_ogc_backend(geoserver.BACKEND_PACKAGE):
spatial_files.append('sld_file')
- if 'geonode.qgis_server' in settings.INSTALLED_APPS:
+ if check_ogc_backend(qgis_server.BACKEND_PACKAGE):
spatial_files.append('qml_file')
spatial_files = tuple(spatial_files)
diff --git a/pavement.py b/pavement.py
index ce1032837db..9c8f2b8dcbc 100644
--- a/pavement.py
+++ b/pavement.py
@@ -436,9 +436,31 @@ def test(options):
"""
Run GeoNode's Unit Test Suite
"""
- sh("%s manage.py test %s.tests --noinput --liveserver=0.0.0.0:8000" % (
- options.get('prefix'), '.tests '.join(GEONODE_APPS)))
+ from geonode.settings import INSTALLED_APPS
+ success = False
+
+ if 'geonode.geoserver' in INSTALLED_APPS:
+ _reset()
+ # Start GeoServer
+ call_task('start_geoserver')
+ info("GeoNode is now available, running the tests now.")
+
+ try:
+ sh("%s manage.py test %s.tests --noinput --liveserver=0.0.0.0:8000" % (
+ options.get('prefix'), '.tests '.join(GEONODE_APPS)))
+ except BuildFailure as e:
+ info('Tests failed! %s' % str(e))
+ else:
+ success = True
+ finally:
+ if 'geonode.geoserver' in INSTALLED_APPS:
+ # don't use call task here - it won't run since it already has
+ stop()
+
+ _reset()
+ if not success:
+ sys.exit(1)
@task
def test_javascript(options):