diff --git a/CHANGELOG.md b/CHANGELOG.md index 366b0fe0d..87d65e40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,13 @@ Development (master) - Fix checkboxgroup value filtering in the admin to work with arrays of selected values * New Features: - - ... + - Allow placing the map at a path other than the host root. * Upgrade Steps: - - ... + - If you want to take advantage of the new relative paths, you will need to update your templates and CSS files: + - Go through your HTML templates, ensure you're using the `{% static %}` tag to include static files, and update any paths that are not relative to use the `{{route_prefix}}` variable (i.e. `"/places/"` would become `"{{route_prefix}}/places/"}}`). + - Go through your jstemplates and search for href. Update any internal paths to use the new `{{prefix "..."}}` helper. + - Go through your CSS and search for `url(/static/...)`. Ensure that these use relative paths instead (in CSS, `url(...)` paths are relative to the CSS file location). 4.1.0 ----------------------------- diff --git a/doc/CONFIG.md b/doc/CONFIG.md index 6b92d7bd9..ec21e63c9 100644 --- a/doc/CONFIG.md +++ b/doc/CONFIG.md @@ -500,3 +500,14 @@ To change the subject or body of the email that is sent to users, create templat ### Styling See [Customizing the Theme](CUSTOM_THEME.md) + +## Step 4: Deploying your map + +There are a few important environment variables that you can set: + +`SHAREABOUTS_FLAVOR` - The name of the flavor you created in Step 2. This is required. +`SHAREABOUTS_DATASET_ROOT` - The URL to your dataset root. This is required. +`SHAREABOUTS_DATASET_KEY` - The API key for your dataset. This is optional. +`BASE_URL` - If you want to run Shareabouts under a subpath, set this to the path you want to use. For example, if you want to run Shareabouts under `http://example.com/subpath/`, set this to `/subpath/`. **NOTE: unless this is a full URL, it should probably start with a slash, and if it's a path it should probably end in a slash as well.** This is optional. + +For more information see [Deploying Your Map](DEPLOY.md) \ No newline at end of file diff --git a/src/flavors/defaultflavor/config.yml b/src/flavors/defaultflavor/config.yml index f33e0bb45..75b4baeca 100644 --- a/src/flavors/defaultflavor/config.yml +++ b/src/flavors/defaultflavor/config.yml @@ -55,7 +55,7 @@ map: # GeoJSON Layers # ============== - - url: /static/data/philadelphia.geojson + - url: "{{static_url}}/data/philadelphia.geojson" type: json rules: - condition: 'true' @@ -85,7 +85,7 @@ place_types: # Display landmarks as icons when zoomed in icon: - iconUrl: /static/css/images/markers/dot-0d85e9.png + iconUrl: "{{static_url}}/css/images/markers/dot-0d85e9.png" iconSize: [17, 18] iconAnchor: [9, 9] @@ -93,8 +93,8 @@ place_types: # Display landmarks as icons when focused/selected icon: - iconUrl: /static/css/images/markers/marker-0d85e9.png - shadowUrl: /static/css/images/marker-shadow.png + iconUrl: "{{static_url}}/css/images/markers/marker-0d85e9.png" + shadowUrl: "{{static_url}}/css/images/marker-shadow.png" iconSize: [25, 41] shadowSize: [41, 41] iconAnchor: [12, 41] @@ -106,8 +106,8 @@ place_types: # Show parks that are points as icons... icon: - iconUrl: /static/css/images/markers/marker-4bbd45.png - shadowUrl: /static/css/images/marker-shadow.png + iconUrl: "{{static_url}}/css/images/markers/marker-4bbd45.png" + shadowUrl: "{{static_url}}/css/images/marker-shadow.png" iconSize: [25, 41] shadowSize: [41, 41] iconAnchor: [12, 41] @@ -124,7 +124,7 @@ place_types: # Show parks that are points as icons... icon: - iconUrl: /static/css/images/markers/dot-4bbd45.png + iconUrl: "{{static_url}}/css/images/markers/dot-4bbd45.png" iconSize: [17, 18] iconAnchor: [9, 9] @@ -362,11 +362,11 @@ pages: pages: - title: _(Why Shareabouts?) slug: why - url: /static/pages/why.html + url: "{{static_url}}/pages/why.html" - title: _(Features) slug: features - url: /static/pages/features.html + url: "{{static_url}}/pages/features.html" - title: _(Links) pages: diff --git a/src/project/settings.py b/src/project/settings.py index 3b5dc48d4..fe51c236f 100644 --- a/src/project/settings.py +++ b/src/project/settings.py @@ -60,6 +60,13 @@ # If you set this to False, Django will not use timezone-aware datetimes. USE_TZ = True +# Path or URL prefix for all app paths and static files. This is useful if you +# want to run Shareabouts under a subpath, such as `/subpath/`. Note that if the +# `BASE_URL` is set, the site will not work directly through runserver, so you +# should use a reverse proxy in front of it. Thus by default, this is an empty +# string. +BASE_URL = os.environ.get('BASE_URL', '') + # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/home/media/media.lawrence.com/media/" MEDIA_ROOT = '' @@ -79,7 +86,7 @@ # URL prefix for static files. # Example: "http://media.lawrence.com/static/" -STATIC_URL = '/static/' +STATIC_URL = BASE_URL + '/static/' COMPRESS_URL = STATIC_URL # Additional locations of static files diff --git a/src/project/urls.py b/src/project/urls.py index ff18360b1..1744a2779 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -3,17 +3,24 @@ from django.contrib import admin from django.views.i18n import set_language +from urllib.parse import urlparse +base_url = urlparse(settings.BASE_URL) +if base_url.path: + base_path = base_url.path.strip('/') + '/' +else: + base_path = '' + admin.autodiscover() urlpatterns = [ - path('choose-language', set_language, name='set_language'), - path('login/', include('sa_login.urls')), - path('admin/', include('sa_admin.urls')), - path('', include('sa_web.urls')), + path(base_path + 'choose-language', set_language, name='set_language'), + path(base_path + 'login/', include('sa_login.urls')), + path(base_path + 'admin/', include('sa_admin.urls')), + path(base_path + '', include('sa_web.urls')), ] if settings.SHAREABOUTS['DATASET_ROOT'].startswith('/'): urlpatterns = [ - path('full-api/', include('sa_api_v2.urls')), + path(base_path + 'full-api/', include('sa_api_v2.urls')), ] + urlpatterns diff --git a/src/sa_admin/templates/sa_admin/base.html b/src/sa_admin/templates/sa_admin/base.html index 69795a380..979af5483 100644 --- a/src/sa_admin/templates/sa_admin/base.html +++ b/src/sa_admin/templates/sa_admin/base.html @@ -67,8 +67,6 @@

{% block heading %}Shareabouts Admin{% endblock %}

{% endif %} - - + + + {% endblock %} diff --git a/src/sa_admin/views.py b/src/sa_admin/views.py index c7aae0c4d..74d494d7b 100644 --- a/src/sa_admin/views.py +++ b/src/sa_admin/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.shortcuts import render, redirect from django.urls import reverse from sa_util.config import get_shareabouts_config @@ -20,7 +21,11 @@ def wrapper(request, *args, **kwargs): @shareabouts_loggedin def admin_home(request, config, api): + path_prefix = settings.BASE_URL + return render(request, 'sa_admin/dashboard.html', { + 'route_prefix': path_prefix, + 'api_prefix': path_prefix + '/api', 'api': api, 'config': config, }) @@ -28,7 +33,11 @@ def admin_home(request, config, api): @shareabouts_loggedin def place_detail(request, config, api, place_id): + path_prefix = settings.BASE_URL + return render(request, 'sa_admin/place_detail.html', { + 'route_prefix': path_prefix, + 'api_prefix': path_prefix + '/api', 'place_id': place_id, 'api': api, 'config': config, diff --git a/src/sa_util/api.py b/src/sa_util/api.py index 439a2a168..16885d1b6 100644 --- a/src/sa_util/api.py +++ b/src/sa_util/api.py @@ -8,7 +8,19 @@ def make_api_root(dataset_root): + """ + Construct the API root URL based on the dataset root. + + If we're hitting an API server (as opposed to using a file backend), then + the dataset_root should be a URL of the form: + //datasets// + + Thus the API root should be everything before the dataset owner. + """ components = dataset_root.split('/') + if dataset_root.startswith('http') and (components[-2] != 'datasets' and components[-3] != 'datasets'): + raise ValueError(f'dataset_root expected to be a URL of the form http[s]:////datasets//; got {dataset_root!r}') + if dataset_root.endswith('/'): return '/'.join(components[:-4]) + '/' else: diff --git a/src/sa_util/config.py b/src/sa_util/config.py index e45c79b1f..856d99b3b 100644 --- a/src/sa_util/config.py +++ b/src/sa_util/config.py @@ -78,6 +78,21 @@ def parse_msg(s): return s[2:-1] +def interpolate(data): + """ + Interpolate strings in the data structure that contain placeholders. + Placeholders are of the form {key} where key is a key in the data structure. + """ + if isinstance(data, dict): + return {k: interpolate(v) for k, v in data.items()} + elif isinstance(data, list): + return [interpolate(item) for item in data] + elif isinstance(data, str): + return data.replace('{{static_url}}', settings.STATIC_URL) + else: + return data + + class _ShareaboutsConfig: """ Base class representing Shareabouts configuration options @@ -85,9 +100,10 @@ class _ShareaboutsConfig: raw = False apply_env = True - def __init__(self, translate=True, apply_env=True): + def __init__(self, translate=True, apply_env=True, interpolate=True): self.translate = translate self.apply_env = apply_env + self.interpolate = interpolate @property def data(self): @@ -100,6 +116,9 @@ def data(self): if self.translate: self._data = translate(self._data) + + if self.interpolate: + self._data = interpolate(self._data) return self._data diff --git a/src/sa_web/jstemplates/activity-list-item.html b/src/sa_web/jstemplates/activity-list-item.html index c9c59a1ed..90ad3022c 100644 --- a/src/sa_web/jstemplates/activity-list-item.html +++ b/src/sa_web/jstemplates/activity-list-item.html @@ -1,6 +1,6 @@
  • {{!-- data attributes are not ideal, see comment in activity view --}} - {{#_}} + {{#_}} {{#if target.submitter}} {{ target.submitter.name }} diff --git a/src/sa_web/jstemplates/auth-nav.html b/src/sa_web/jstemplates/auth-nav.html index 10f8b5a5a..61b1e2299 100644 --- a/src/sa_web/jstemplates/auth-nav.html +++ b/src/sa_web/jstemplates/auth-nav.html @@ -5,7 +5,7 @@ {{#with (current_user "name") }}
  • {{#_}}Signed in as {{ this }}{{/_}}
  • {{/with}} -
  • {{#_}}Log Out{{/_}} +
  • {{#_}}Log Out{{/_}}
  • @@ -13,8 +13,8 @@ {{/ is_authenticated }} diff --git a/src/sa_web/jstemplates/form-field-input.html b/src/sa_web/jstemplates/form-field-input.html index d8275798e..f7b48a5ff 100644 --- a/src/sa_web/jstemplates/form-field-input.html +++ b/src/sa_web/jstemplates/form-field-input.html @@ -1,7 +1,7 @@ {{#is_submitter_name}} {{^is_authenticated}} - + {{/is_authenticated}} {{^}} diff --git a/src/sa_web/jstemplates/page-nav-item.html b/src/sa_web/jstemplates/page-nav-item.html index b2d2d2b80..bf39ed717 100644 --- a/src/sa_web/jstemplates/page-nav-item.html +++ b/src/sa_web/jstemplates/page-nav-item.html @@ -6,7 +6,7 @@ {{^ external }} {{# slug }} - {{ title }} + {{ title }} {{/ slug }} {{^ slug }} {{ title }} diff --git a/src/sa_web/jstemplates/pages-nav-item.html b/src/sa_web/jstemplates/pages-nav-item.html index 64a70369a..81929d887 100644 --- a/src/sa_web/jstemplates/pages-nav-item.html +++ b/src/sa_web/jstemplates/pages-nav-item.html @@ -22,7 +22,7 @@ {{ title }} {{else }} {{#if slug }} - {{ title }} + {{ title }} {{else }} {{ title }} {{/if }} diff --git a/src/sa_web/jstemplates/place-detail.html b/src/sa_web/jstemplates/place-detail.html index cb0618fed..2c0af8b23 100644 --- a/src/sa_web/jstemplates/place-detail.html +++ b/src/sa_web/jstemplates/place-detail.html @@ -32,12 +32,12 @@

    {{#if name }}{{ name }}{{^}}{{>location-string .}}{{/if}}

    in {{ region }} {{/if}}{{/_}} - + {{ survey_count }} {{ survey_label_by_count }} {{^if survey_config}} - {{#_}}View On Map{{/_}} + {{#_}}View On Map{{/_}} {{/if}} diff --git a/src/sa_web/static/js/handlebars-helpers.js b/src/sa_web/static/js/handlebars-helpers.js index 202da2027..064ce9e16 100644 --- a/src/sa_web/static/js/handlebars-helpers.js +++ b/src/sa_web/static/js/handlebars-helpers.js @@ -7,6 +7,10 @@ var Shareabouts = Shareabouts || {}; return NS.bootstrapped.staticUrl; }); + Handlebars.registerHelper('prefix', function(route) { + return NS.Util.prefixRoute(route); + }); + Handlebars.registerHelper('debug', function(value) { if (typeof(value) === typeof({})) { return JSON.stringify(value, null, 4); diff --git a/src/sa_web/static/js/models.js b/src/sa_web/static/js/models.js index 306793b1e..5922e9396 100644 --- a/src/sa_web/static/js/models.js +++ b/src/sa_web/static/js/models.js @@ -126,7 +126,7 @@ var Shareabouts = Shareabouts || {}; 'must save the place before saving ' + 'its ' + submissionType + '.'); } - return '/api/places/' + placeId + '/' + submissionType; + return S.Util.prefixApiEndpoint('/places/' + placeId + '/' + submissionType); }, comparator: 'created_datetime' @@ -250,7 +250,7 @@ var Shareabouts = Shareabouts || {}; }); S.PlaceCollection = S.PaginatedCollection.extend({ - url: '/api/places', + url: S.Util.prefixApiEndpoint('/places'), model: S.PlaceModel, resultsAttr: 'features', @@ -375,7 +375,7 @@ var Shareabouts = Shareabouts || {}; }); S.ActionCollection = S.PaginatedCollection.extend({ - url: '/api/actions', + url: S.Util.prefixApiEndpoint('/actions'), comparator: function(a, b) { if (a.get('created_datetime') > b.get('created_datetime')) { return -1; diff --git a/src/sa_web/static/js/routes.js b/src/sa_web/static/js/routes.js index a13378af3..5de221680 100644 --- a/src/sa_web/static/js/routes.js +++ b/src/sa_web/static/js/routes.js @@ -80,6 +80,9 @@ var Shareabouts = Shareabouts || {}; if (options.defaultPlaceTypeName) { historyOptions.root = '/' + options.defaultPlaceTypeName + '/'; } + if (options.routePrefix) { + historyOptions.root = options.routePrefix + '/'; + } Backbone.history.start(historyOptions); diff --git a/src/sa_web/static/js/utils.js b/src/sa_web/static/js/utils.js index 993c8cec0..3e338da2d 100644 --- a/src/sa_web/static/js/utils.js +++ b/src/sa_web/static/js/utils.js @@ -350,6 +350,34 @@ var Shareabouts = Shareabouts || {}; func.apply(context, args); }, + prefixRoute: function(route) { + return (S.bootstrapped.routePrefix || '') + route; + }, + + unprefixRoute: function(route) { + var prefix = S.bootstrapped.routePrefix || ''; + if (route.indexOf(prefix) === 0) { + return route.substr(prefix.length); + } + return route; + }, + + prefixStaticUrl: function(url) { + return (S.bootstrapped.staticUrl || '') + url; + }, + + prefixApiEndpoint: function(route) { + return (S.bootstrapped.apiPrefix || '') + route; + }, + + unprefixApiEndpoint: function(route) { + var prefix = S.bootstrapped.apiPrefix || ''; + if (route.indexOf(prefix) === 0) { + return route.substr(prefix.length); + } + return route; + }, + // Cookies! Om nom nom // Thanks ppk! http://www.quirksmode.org/js/cookies.html cookies: { diff --git a/src/sa_web/static/js/views/activity-view.js b/src/sa_web/static/js/views/activity-view.js index 7b4ed436b..3a7b3df3b 100644 --- a/src/sa_web/static/js/views/activity-view.js +++ b/src/sa_web/static/js/views/activity-view.js @@ -94,7 +94,8 @@ var Shareabouts = Shareabouts || {}; placeId = actionLink.getAttribute('data-place-id'); S.Util.log('USER', 'action', 'click', actionType+' -- '+placeId); - this.options.router.navigate(actionLink.getAttribute('href'), {trigger: true}); + const route = S.Util.unprefixRoute(actionLink.getAttribute('href')); + this.options.router.navigate(route, {trigger: true}); }, onAddAction: function(model, collection) { diff --git a/src/sa_web/templates/base.html b/src/sa_web/templates/base.html index 99e54b6b8..cb37b25fe 100644 --- a/src/sa_web/templates/base.html +++ b/src/sa_web/templates/base.html @@ -173,7 +173,9 @@

    staticUrl: '{{ STATIC_URL }}', languageCode: '{{ LANGUAGE_CODE }}', mapQuestKey: '{{ settings.MAPQUEST_KEY }}', - mapboxToken: '{{ settings.MAPBOX_TOKEN }}' + mapboxToken: '{{ settings.MAPBOX_TOKEN }}', + routePrefix: {{ route_prefix | as_json }}, + apiPrefix: {{ api_prefix | as_json }}, }; function bootstrapCurrentUser(data) { @@ -216,6 +218,9 @@

    S.bootstrapped.currentUser ? 'user:' + S.bootstrapped.currentUser.id : {{ user_token_json|safe }}), + + routePrefix: S.bootstrapped.routePrefix, + apiPrefix: S.bootstrapped.apiPrefix, flavor: {{ config.data|as_json }}, place: {{ config.place|as_json }}, @@ -235,6 +240,7 @@

    defaultPlaceTypeName: S.Config.defaultPlaceTypeName, userToken: S.Config.userToken, + routePrefix: S.Config.routePrefix, config: S.Config.flavor, placeConfig: S.Config.place, placeTypes: S.Config.placeTypes, diff --git a/src/sa_web/test_fixtures/places b/src/sa_web/test_fixtures/dataowner/datasets/test_dataset/places similarity index 100% rename from src/sa_web/test_fixtures/places rename to src/sa_web/test_fixtures/dataowner/datasets/test_dataset/places diff --git a/src/sa_web/tests.py b/src/sa_web/tests.py index 75d0fbea9..fc60a1ffa 100644 --- a/src/sa_web/tests.py +++ b/src/sa_web/tests.py @@ -7,12 +7,12 @@ from contextlib import contextmanager from django.conf import settings -from django.test import Client, override_settings, SimpleTestCase +from django.test import Client, override_settings, SimpleTestCase, RequestFactory from os.path import abspath, dirname, join as path_join from pathlib import Path from threading import Thread from unittest import mock -from sa_util import config +from sa_util import api, config class SimpleTest(SimpleTestCase): def test_basic_addition(self): @@ -159,20 +159,36 @@ def start_stub_api_server(directory): 'DATASET_ROOT': 'http://localhost:8001/', 'CONFIG': abspath(path_join(APP_DIR, '..', 'flavors', 'defaultflavor')) }) +class InvalidAPIServerBackend (SimpleTestCase): + def test_raise_error(self): + with start_stub_api_server(DATA_FIXTURES_DIR / 'test_fixtures') as server: + request = RequestFactory().get('http://testserver/') + with self.assertRaises(ValueError) as context: + api.ShareaboutsApi(config=None, request=request) + + self.assertIn('dataset_root expected to be a URL of the form', str(context.exception)) + + +@override_settings( + DEBUG=True, + SHAREABOUTS={ + 'DATASET_ROOT': 'http://localhost:8001/dataowner/datasets/test_dataset/', + 'CONFIG': abspath(path_join(APP_DIR, '..', 'flavors', 'defaultflavor')) + }) class APIServerBackend (SimpleTestCase): def test_index(self): with start_stub_api_server(DATA_FIXTURES_DIR / 'test_fixtures') as server: client = Client() - response = client.get('/') + response = client.get('http://testserver/') self.assertEqual(response.status_code, 200) def test_api_proxy(self): - with (DATA_FIXTURES_DIR / 'test_fixtures' / 'places').open('rb') as datafile: + with (DATA_FIXTURES_DIR / 'test_fixtures' / 'dataowner' / 'datasets' / 'test_dataset' / 'places').open('rb') as datafile: places_data = datafile.read() with start_stub_api_server(DATA_FIXTURES_DIR / 'test_fixtures') as server: client = Client() - response = client.get('/api/places') + response = client.get('http://testserver/api/places') self.assertEqual(response.status_code, 200) self.assertEqual(response.content, places_data) @@ -202,7 +218,7 @@ def test_send_notification(self): { "id": 123, "properties": { - "location_type": "test place", + "location_type": "test place" } } ''' diff --git a/src/sa_web/views.py b/src/sa_web/views.py index afddeee8e..b217c931e 100644 --- a/src/sa_web/views.py +++ b/src/sa_web/views.py @@ -156,8 +156,13 @@ def index(request, place_id=None): except KeyError: uses_mapbox_layers = False + path_prefix = settings.BASE_URL + context = {'config': config, + 'route_prefix': path_prefix, + 'api_prefix': path_prefix + '/api', + 'user_token_json': user_token_json, 'pages_config': pages_config, 'pages_config_json': pages_config_json, @@ -242,7 +247,7 @@ def send_place_created_notifications(request, response): 'place': place, 'email': recipient_email, 'config': config, - 'site_root': request.build_absolute_uri('/'), + 'site_root': request.build_absolute_uri(settings.BASE_URL), } subject = render_to_string('new_place_email_subject.txt', context_data, request) body = render_to_string('new_place_email_body.txt', context_data, request)