diff --git a/.github/github_settings.py b/.github/github_settings.py index 89791d64..7ce64b70 100644 --- a/.github/github_settings.py +++ b/.github/github_settings.py @@ -617,9 +617,22 @@ def get_env_variable(var_name, default=None): # This is the default if source isn't set as a parameter in the request TILESERVER_URL = "https://openmaps.gov.bc.ca/" BC_TILESERVER_URLS = { - "maps": "https://maps.gov.bc.ca/", - "openmaps": TILESERVER_URL, - "local": "http://localhost:7800/", + "maps": { + "url": "https://maps.gov.bc.ca/", + "use_outbound_proxy": True, # Use outbound proxy for this source + }, + "openmaps": { + "url": TILESERVER_URL, + "use_outbound_proxy": True, # Use outbound proxy for this source + }, + "local": { + "url": get_env_variable("TILESERVER_LOCAL_URL"), + "use_outbound_proxy": False, # Local doesn't need outbound proxy + }, + "local-feature": { + "url": get_env_variable("FEATURESERVER_LOCAL_URL"), + "use_outbound_proxy": False, # Local doesn't need outbound proxy + }, } AUTH_BYPASS_HOSTS = get_env_variable("AUTH_BYPASS_HOSTS", default="localhost") diff --git a/README.QGIS.md b/README.QGIS.md new file mode 100644 index 00000000..eb14df3d --- /dev/null +++ b/README.QGIS.md @@ -0,0 +1,94 @@ +## Using Arches w/ QGIS +### Strategy +1. Use `pg_tileserv` for general site / site visit visibility. This can't be used to push geometries back to server +2. Use `pg_featureserv` to get actual GeoJSON geometry for editing / pushing back to the server. +3. Modify app proxy to host `pg_featuresrf` + +### 1. Installing +1. Download qgis_testing_package.zip file +2. Uncompress qgis_testing_package.zip file this will have several files in it: + 1. arches_project.zip - Arches plugin + 1. basemap_config.xml - WMS config for the BC Roads basemap and Borden Grid + 2. bcap_wfs_config.xml - WFS config for BCAP features - both DLVR & TEST + 3. oauth_config.xml - OAuth config for DLVR & TEST +2. Install the Arches Plugin: + 1. Start QGIS + 2. Select Plugins -> Manage and Install Plugins... + 3. Select Install from ZIP from LHS menu + 4. Select the arches_project.zip file in the ZIP file box + 5. Press Install Plugin button + 1. Select "Yes" when the security warning comes up + 6. Click "Installed" from the LHS menu and ensure "Arches Project" checkbox is checked + 7. Close Window +3. Install OAuth configuration (password required) + 1. Select Settings -> Options... from top menu + 2. Select Authentication on the LHS menu + 3. Click Utilities button in the bottom right + 4. Select Import Authentication Configurations from File... + 5. Navigate to `qgis_testing_package/oauth_config.xml` and select the file + 6. Click the Open button + 7. Enter password provided + 8. Click OK + 9. NB - nothing will show up in the window. You can re-open the Autentication settings to confirm they are there. There should be 2 - One for TEST and one for DLVR +4. Import the BCAP WFS configurations + 1. Open the Layer -> Data Source Manager menu item + 2. Select the WFS / OGC API - Features in the LHS menu + 3. Press the Load button in the top right + 4. Navigate to the `qgis_testing_package/bcap_wfs_config.xml` file and select it + 5. Click the Open button + 6. Press the Select All button in the popup window + 7. Press the Import button + 8. You will see the Server connections dropdown list populated with those two connections + 9. Select the BCAP Features - DLVR option + 10. Press the Edit button + 11. In the Authentication -> Configurations tab select the BCAP - Django OAuth Toolkit - DLVR (OAuth2) option + 12. Press OK + 13. Repeat steps ix->xii, substituting TEST for DLVR in all steps +5. Import the WMS configurations + 1. Select WMS/WMTS in the LHS menu + 2. Press the Load button in the top right of the window + 3. Navigate to the `qgis_testing_package/basemap_config.xml` file and select it + 4. Click the Open button + 5. Press the Select All button in the popup window + 6. Press the Import button + 7. You will see the Server connections dropdown list populated with those two connections +6. You're done! + +### 2. Logging into QGIS plugin using OAuth +1. Login to Arches in your _default_ web browser (QGIS will ) +2. Click Arches from plugin +3. Select Authentication method from list +4. Browser window should appear for OAuth process +5. You should see auth window with your information + +### 2. Map layers +- pg_tileserv layer (Vector Tile): `http:///bcap/bctileserver/public.geojson_geometries/{z}/{x}/{y}.pbf?source=local` +- pg_featureserv layer (OGC API): `http:///bcap/bctileserver/?source=local-feature` +- Borden Grid (Vector Tile): `https://openmaps.gov.bc.ca/geo/pub/WHSE_ARCHAEOLOGY.RAAD_BORDENGRID/ows?service=WMS&request=GetCapabilities` +- BC Roads Basemap (WMS): `https://maps.gov.bc.ca/arcserver/services/Province/roads_wm/MapServer/WMSServer` + +### 3. How to copy feature to push back to server + 1. Select feature from pg_featureserv layer + 2. Copy feature + 3. Paste feature as -> Temporary Scratch layer + 4. Toggle "Make layer editable" + 4. Edit feature + 5. Select feature + 6. In Arches Project tab, confirm the feature selected matches the one you want to update + 7. Click "Replace geometry" + +### 4. TO DO +1. confirm tileserv and featuresrv authentiation/authorization +2. Can we (they) create an Action on the featuresrv to automate the copy? +8. Confirm edits are logged w/ user that made the changes + +### 4. Issues +- Can't download full Site / Site Visit set of geometries - way too big +- More than one feature is being copied back to the server? Need to look at copy geometry process. +- featuresrv and tileserv are displaying project boundary geometry. +- OAuth access token is currently valid for a week. Can this be configured to be e24h? +- Can't currently create resources because geometries aren't top-level objects. Maybe we can do a different endpoint for this? +- When pushing a geometry up to BCAP, if the tile isn't in a state that it can be saved, a 500 error is returned. Maybe we can put a card-level trigger to set the edit type? + +### 6. Other notes / findings / Gotchas +- Filters can't be used directly on the Layer, it must be done on the Connection otherwise the OAuth config is lost \ No newline at end of file diff --git a/bcap/management/commands/ensure_bcrhp_oauth.py b/bcap/management/commands/ensure_bcrhp_oauth.py deleted file mode 100644 index 1e270cf4..00000000 --- a/bcap/management/commands/ensure_bcrhp_oauth.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -ARCHES - a program developed to inventory and manage immovable cultural heritage. -Copyright (C) 2013 J. Paul Getty Trust and World Monuments Fund - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero 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 Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -""" - -import os -from django.core.management.base import BaseCommand -from oauth2_provider.models import Application - - -class Command(BaseCommand): - """ - Commands for adding the BCAP API OAuth2 provider configuration. - This requires the following environment variables to be set: - 1. BCRHP_API_CLIENT_ID - 2. BCRHP_API_CLIENT_SECRET (only if the config doesn't exist yet) - - """ - - def handle(self, *args, **options): - apps = Application.objects.filter(name="BCRHP API").all() - if len(apps) == 0: - add_bcrhp_oauth_config() - elif len(apps) > 1: - print( - "More than one BCRHP API application found. Please delete the extra applications." - ) - else: - update_bcrhp_oauth_secret(apps[0]) - - -def add_bcrhp_oauth_config(): - client_id = os.environ.get("BCRHP_API_CLIENT_ID") - client_secret = os.environ.get("BCRHP_API_CLIENT_SECRET") - if not client_id: - print( - "BCRHP_API_CLIENT_ID environment variable must be set to create new OAuth2 provider configuration." - ) - return - if not client_secret: - print( - "BCRHP_API_CLIENT_SECRET environment variable must be set to create new OAuth2 provider configuration." - ) - - app = Application() - app.name = "BCRHP API" - app.client_id = client_id - app.client_type = "confidential" - app.authorization_grant_type = "client-credentials" - app.client_secret = client_secret - app.skip_authorization = False - app.hash_client_secret = True - app.save() - - -def update_bcrhp_oauth_secret(app): - client_secret = os.environ.get("BCRHP_API_CLIENT_SECRET") - if not client_secret: - print( - "BCRHP_API_CLIENT_SECRET environment variable must be set to update new OAuth2 provider configuration." - ) - app.client_secret = client_secret - app.save() diff --git a/bcap/management/commands/oauth_provider_config.py b/bcap/management/commands/oauth_provider_config.py new file mode 100644 index 00000000..63a858f5 --- /dev/null +++ b/bcap/management/commands/oauth_provider_config.py @@ -0,0 +1,126 @@ +""" +ARCHES - a program developed to inventory and manage immovable cultural heritage. +Copyright (C) 2013 J. Paul Getty Trust and World Monuments Fund + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero 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 Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" + +import os +from django.core.management import CommandError +from django.core.management.base import BaseCommand +from oauth2_provider.models import Application + + +class Command(BaseCommand): + """ + Commands for adding the BCAP API OAuth2 provider configuration. + This requires the following environment variables to be set: + 1. BCRHP_API_CLIENT_ID + 2. BCRHP_API_CLIENT_SECRET (only if the config doesn't exist yet) + + """ + + def add_arguments(self, parser): + parser.add_argument( + "-cid", + "--client-id", + action="store", + dest="client_id", + help="Well known client id for the OAUTH2 provider.", + ) + parser.add_argument( + "-cn", + "--config-name", + action="store", + dest="config_name", + help="Name of the OAuth2 provider configuration. Must be unique in the configurations.", + ) + parser.add_argument( + "-ct", + "--client-type", + action="store", + dest="client_type", + default="confidential", + help="Client type. One of 'confidential' or 'public'.", + ) + parser.add_argument( + "-gt", + "--grant-type", + action="store", + dest="grant_type", + default="confidential", + help="Authorization grant type. One of 'authorization-code', 'client-credentials', ...", + ) + parser.add_argument( + "-ru", + "--redirect-uris", + action="store", + dest="redirect_uris", + default="", + help="Valid redirect URIs for the OAuth2 provider. Comma separated.", + ) + parser.add_argument( + "-ao", + "--allowed_origins", + action="store", + dest="allowed_origins", + default="", + help="Allowed origins for CORS requests. Comma separated.", + ) + parser.add_argument( + "-ha", + "--hash", + action="store_true", + dest="hash_secret", + default=True, + help="Whether to hash the client secret.", + ) + + def handle(self, *args, **options): + client_secret = os.environ.get("CLIENT_SECRET") + if not client_secret: + print( + "CLIENT_SECRET environment variable must be set to create new OAuth2 provider configuration." + ) + raise CommandError( + "CLIENT_SECRET environment variable must be set to create new OAuth2 provider configuration." + ) + apps = Application.objects.filter(name=options["config_name"]).all() + if len(apps) == 0: + add_oauth_config(client_secret, options) + elif len(apps) > 1: + print( + "More than one BCRHP API application found. Please delete the extra applications." + ) + else: + update_oauth_secret(apps[0], client_secret) + + +def add_oauth_config(client_secret, options): + app = Application() + app.name = options["config_name"] + app.client_id = options["client_id"] + app.client_type = options["client_type"] + app.authorization_grant_type = options["grant_type"] + app.redirect_uris = options["redirect_uris"] + app.allowed_origins = options["allowed_origins"] + app.client_secret = client_secret + app.skip_authorization = False + app.hash_client_secret = True + app.save() + + +def update_oauth_secret(app, client_secret): + app.client_secret = client_secret + app.save() diff --git a/bcap/migrations/855_add_qgis_views.py b/bcap/migrations/855_add_qgis_views.py new file mode 100644 index 00000000..2acb17bd --- /dev/null +++ b/bcap/migrations/855_add_qgis_views.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.5 on 2026-01-21 00:29 + +import django_migrate_sql.operations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bcap", "1182c_add_cross_model_advanced_search"), + ] + + operations = [ + django_migrate_sql.operations.CreateSQL( + name="bc_labelled_site_visit_geometries", + sql="create or replace view public.bc_labelled_site_visit_geometries as\n(\nselect re.name ->> 'en' resource_name, g.*\nfrom geojson_geometries g\n join (select re2.*\n from resource_instances re2\n join graphs g on re2.graphid = g.graphid and\n g.slug = 'site_visit') re\n on g.resourceinstanceid = re.resourceinstanceid);\n", + reverse_sql="drop view bc_labelled_site_visit_geometries;", + ), + django_migrate_sql.operations.CreateSQL( + name="bc_labelled_geojson_geometries", + sql="create or replace view public.bc_labelled_geojson_geometries as\n(\nselect re.name ->> 'en' resource_name, g.*\nfrom geojson_geometries g\n join (select re2.*\n from resource_instances re2\n join graphs g on re2.graphid = g.graphid and\n g.slug in ('archaeological_site', 'site_visit', 'sandcastle')) re\n on g.resourceinstanceid = re.resourceinstanceid);\n", + reverse_sql="drop view bc_labelled_geojson_geometries;", + ), + django_migrate_sql.operations.CreateSQL( + name="bc_labelled_sandcastle_geometries", + sql="create or replace view public.bc_labelled_sandcastle_geometries as\n(\nselect re.name ->> 'en' resource_name, g.*\nfrom geojson_geometries g\n join (select re2.*\n from resource_instances re2\n join graphs g on re2.graphid = g.graphid and\n g.slug = 'sandcastle') re\n on g.resourceinstanceid = re.resourceinstanceid);\n", + reverse_sql="drop view bc_labelled_sandcastle_geometries;", + ), + django_migrate_sql.operations.CreateSQL( + name="bc_labelled_site_geometries", + sql="create or replace view public.bc_labelled_site_geometries as\n(\nselect re.name ->> 'en' resource_name, g.*\nfrom geojson_geometries g\n join (select re2.*\n from resource_instances re2\n join graphs g on re2.graphid = g.graphid and\n g.slug = 'archaeological_site') re\n on g.resourceinstanceid = re.resourceinstanceid);\n", + reverse_sql="drop view bc_labelled_site_geometries;", + ), + ] diff --git a/bcap/migrations/sql/views/bc_labelled_geojson_geometries.sql b/bcap/migrations/sql/views/bc_labelled_geojson_geometries.sql new file mode 100644 index 00000000..9f421433 --- /dev/null +++ b/bcap/migrations/sql/views/bc_labelled_geojson_geometries.sql @@ -0,0 +1,9 @@ +create or replace view public.bc_labelled_geojson_geometries as +( +select re.name ->> 'en' resource_name, g.* +from geojson_geometries g + join (select re2.* + from resource_instances re2 + join graphs g on re2.graphid = g.graphid and + g.slug in ('archaeological_site', 'site_visit', 'sandcastle')) re + on g.resourceinstanceid = re.resourceinstanceid); diff --git a/bcap/migrations/sql/views/bc_labelled_sandcastle_geometries.sql b/bcap/migrations/sql/views/bc_labelled_sandcastle_geometries.sql new file mode 100644 index 00000000..f53a185f --- /dev/null +++ b/bcap/migrations/sql/views/bc_labelled_sandcastle_geometries.sql @@ -0,0 +1,9 @@ +create or replace view public.bc_labelled_sandcastle_geometries as +( +select re.name ->> 'en' resource_name, g.* +from geojson_geometries g + join (select re2.* + from resource_instances re2 + join graphs g on re2.graphid = g.graphid and + g.slug = 'sandcastle') re + on g.resourceinstanceid = re.resourceinstanceid); diff --git a/bcap/migrations/sql/views/bc_labelled_site_geometries.sql b/bcap/migrations/sql/views/bc_labelled_site_geometries.sql new file mode 100644 index 00000000..8b5be87e --- /dev/null +++ b/bcap/migrations/sql/views/bc_labelled_site_geometries.sql @@ -0,0 +1,9 @@ +create or replace view public.bc_labelled_site_geometries as +( +select re.name ->> 'en' resource_name, g.* +from geojson_geometries g + join (select re2.* + from resource_instances re2 + join graphs g on re2.graphid = g.graphid and + g.slug = 'archaeological_site') re + on g.resourceinstanceid = re.resourceinstanceid); diff --git a/bcap/migrations/sql/views/bc_labelled_site_visit_geometries.sql b/bcap/migrations/sql/views/bc_labelled_site_visit_geometries.sql new file mode 100644 index 00000000..d39cc7f6 --- /dev/null +++ b/bcap/migrations/sql/views/bc_labelled_site_visit_geometries.sql @@ -0,0 +1,9 @@ +create or replace view public.bc_labelled_site_visit_geometries as +( +select re.name ->> 'en' resource_name, g.* +from geojson_geometries g + join (select re2.* + from resource_instances re2 + join graphs g on re2.graphid = g.graphid and + g.slug = 'site_visit') re + on g.resourceinstanceid = re.resourceinstanceid); diff --git a/bcap/settings.py b/bcap/settings.py index edf4aa8c..2edd4eb9 100644 --- a/bcap/settings.py +++ b/bcap/settings.py @@ -485,7 +485,9 @@ def get_env_variable(var_name, is_optional=False): "/bcap/auth/eoauth_start", "/bcap/auth/eoauth_cb", "/bcap/o/token", - "/bcap/api/borden-number" + # "/bcap/api/borden-number", + "/bcap/auth/user_profile" + # "/bcap/geojson" ], }, } @@ -697,9 +699,22 @@ def get_env_variable(var_name, is_optional=False): # This is the default if source isn't set as a parameter in the request TILESERVER_URL = "https://openmaps.gov.bc.ca/" BC_TILESERVER_URLS = { - "maps": "https://maps.gov.bc.ca/", - "openmaps": TILESERVER_URL, - "local": "http://localhost:7800/", + "maps": { + "url": "https://maps.gov.bc.ca/", + "use_outbound_proxy": True # Use outbound proxy for this source + }, + "openmaps": { + "url": TILESERVER_URL, + "use_outbound_proxy": True # Don't use outbound proxy for this source + }, + "local": { + "url": get_env_variable("TILESERVER_LOCAL_URL"), + "use_outbound_proxy": False # Local doesn't need outbound proxy + }, + "local-feature": { + "url": get_env_variable("FEATURESERVER_LOCAL_URL"), + "use_outbound_proxy": False # Local doesn't need outbound proxy + }, } AUTH_BYPASS_HOSTS = get_env_variable("AUTH_BYPASS_HOSTS") diff --git a/bcap/sql_config.py b/bcap/sql_config.py new file mode 100644 index 00000000..350cef39 --- /dev/null +++ b/bcap/sql_config.py @@ -0,0 +1,39 @@ +from django.db import migrations +from django_migrate_sql.config import SQLItem +from bcap.migrations.util.migration_util import format_sql + + +sql_items = [ + SQLItem( + "bc_labelled_geojson_geometries", + format_sql( + "sql/views/bc_labelled_geojson_geometries.sql", + ), + reverse_sql="drop view bc_labelled_geojson_geometries;", + replace=True, + ), + SQLItem( + "bc_labelled_site_geometries", + format_sql( + "sql/views/bc_labelled_site_geometries.sql", + ), + reverse_sql="drop view bc_labelled_site_geometries;", + replace=True, + ), + SQLItem( + "bc_labelled_site_visit_geometries", + format_sql( + "sql/views/bc_labelled_site_visit_geometries.sql", + ), + reverse_sql="drop view bc_labelled_site_visit_geometries;", + replace=True, + ), + SQLItem( + "bc_labelled_sandcastle_geometries", + format_sql( + "sql/views/bc_labelled_sandcastle_geometries.sql", + ), + reverse_sql="drop view bc_labelled_sandcastle_geometries;", + replace=True, + ), +] diff --git a/bcap/urls.py b/bcap/urls.py index e91ab3db..4800799e 100644 --- a/bcap/urls.py +++ b/bcap/urls.py @@ -29,6 +29,11 @@ FileView.as_view(), name="files", ), + path( + f"{PREFIX}bctileserver/", + BCTileserverProxyView.as_view(), + name="bcap_tile_server_root", + ), path( f"{PREFIX}bctileserver/", BCTileserverProxyView.as_view(), diff --git a/cd/config/default.yml b/cd/config/default.yml index 58e03057..7255058d 100644 --- a/cd/config/default.yml +++ b/cd/config/default.yml @@ -31,7 +31,8 @@ additional_pip_modules: - "oracledb" - "Authlib" -configure_tileserv: False +configure_tileserv: True +configure_featureserv: True django_key: "{{ lookup('ansible.builtin.env', 'DJANGO_KEY') }}" db_password: "{{ lookup('ansible.builtin.env', 'DB_PASSWORD') }}" @@ -46,3 +47,16 @@ s3_secret_access_key: "{{ lookup('ansible.builtin.env', 'S3_SECRET_ACCESS_KEY') planet_api_key: "{{ lookup('ansible.builtin.env', 'PLANET_API_KEY') }}" mapbox_key: "{{ lookup('ansible.builtin.env', 'MAPBOX_KEY') }}" oauth_client_secret: "{{ lookup('ansible.builtin.env', 'OAUTH_CLIENT_SECRET') }}" + +oauth2_provider_configs: + - config_name: "BCRHP API" + client_id: "{{ lookup('ansible.builtin.env', 'BCRHP_API_CLIENT_ID') }}" + client_secret: "{{ lookup('ansible.builtin.env', 'BCRHP_API_CLIENT_SECRET') }}" + client_type: confidential + grant_type: client-credentials + - config_name: "QGIS Plugin" + client_id: "{{ lookup('ansible.builtin.env', 'QGIS_PLUGIN_CLIENT_ID') }}" + client_secret: "{{ lookup('ansible.builtin.env', 'QGIS_PLUGIN_CLIENT_SECRET') }}" + client_type: public + grant_type: authorization-code + redirect_uris: "http://127.0.0.1:7070/" diff --git a/cd/config/dlvr.yml b/cd/config/dlvr.yml index 01cbf48f..43524ea6 100644 --- a/cd/config/dlvr.yml +++ b/cd/config/dlvr.yml @@ -10,3 +10,6 @@ oauth_auth_endpoint: "https://dev.loginproxy.gov.bc.ca/auth/realms/bcregistry/pr oauth_token_endpoint: "https://dev.loginproxy.gov.bc.ca/auth/realms/bcregistry/protocol/openid-connect/token" oauth_jwks_uri: "https://dev.loginproxy.gov.bc.ca/auth/realms/bcregistry/protocol/openid-connect/certs" oauth_server_metadata_url: "https://dev.loginproxy.gov.bc.ca/auth/realms/bcregistry/.well-known/openid-configuration" + +tileserv_http_listen_port: "7812" +featureserv_http_listen_port: "9012" diff --git a/cd/config/test.yml b/cd/config/test.yml index 391a1bb7..d131cdc3 100644 --- a/cd/config/test.yml +++ b/cd/config/test.yml @@ -6,3 +6,6 @@ oauth_auth_endpoint: "https://test.loginproxy.gov.bc.ca/auth/realms/bcregistry/p oauth_token_endpoint: "https://test.loginproxy.gov.bc.ca/auth/realms/bcregistry/protocol/openid-connect/token" oauth_jwks_uri: "https://test.loginproxy.gov.bc.ca/auth/realms/bcregistry/protocol/openid-connect/certs" oauth_server_metadata_url: "https://test.loginproxy.gov.bc.ca/auth/realms/bcregistry/.well-known/openid-configuration" + +tileserv_http_listen_port: "7802" +featureserv_http_listen_port: "9002" diff --git a/docker-compose.yml b/docker-compose.yml index f2982ff5..aed38e4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,17 @@ services: stdin_open: true tty: true + bcap-pg_featureserv: + container_name: bcap-pg_featureserv7-6 + hostname: bcap-pg_featureserv7-6 + image: pramsey/pg_featureserv:latest + env_file: + - ./docker/pg_featureserv/pg_featureserv.env + ports: + - "9002:9000" + stdin_open: true + tty: true + networks: default: external: true diff --git a/docker/pg_featureserv/pg_featureserv.env b/docker/pg_featureserv/pg_featureserv.env new file mode 100644 index 00000000..6eaeb8c4 --- /dev/null +++ b/docker/pg_featureserv/pg_featureserv.env @@ -0,0 +1,3 @@ +DATABASE_URL=postgres://$PGUSERNAME:$PGPASSWORD@$PGHOST:5432/$PGDBNAME +PGFS_SERVER_URLBASE=http://localhost:82/bcap/bctileserver/ +#PGFS_SERVER_BASEPATH=/bcap/bctileserver \ No newline at end of file diff --git a/dot.env.j2 b/dot.env.j2 index 42611989..1d77fb6c 100644 --- a/dot.env.j2 +++ b/dot.env.j2 @@ -87,4 +87,6 @@ OAUTH_SERVER_METADATA_URL={{ oauth_server_metadata_url }} #OAUTHLIB_INSECURE_TRANSPORT=True #Local Tileserver config -TILESERVER_OUTBOUND_PROXY={{ proxy_env.https_proxy }} \ No newline at end of file +TILESERVER_OUTBOUND_PROXY={{ proxy_env.https_proxy }} +TILESERVER_LOCAL_URL=http://localhost:{{ tileserv_http_listen_port }}/ +FEATURESERVER_LOCAL_URL=http://localhost:{{ featureserv_http_listen_port }}/ diff --git a/package.json b/package.json index ae53ea38..f6b68fd5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@types/underscore": "^1.13.0", "arches-dev-dependencies": "archesproject/arches-dev-dependencies#dev/8.0.x", "eslint-config-prettier": "^10.1.5", - "prettier": "^3.6.2", + "prettier": "^3.7.1", "prettier-plugin-jinja-template": "^2.1.0", "unplugin-vue-components": "^29.0.0", "vite": "^6.2.1", diff --git a/tests/management/commands/test_ensure_bcrhp_oauth.py b/tests/management/commands/test_ensure_bcrhp_oauth.py deleted file mode 100644 index fd4d7126..00000000 --- a/tests/management/commands/test_ensure_bcrhp_oauth.py +++ /dev/null @@ -1,142 +0,0 @@ -import os -from unittest.mock import patch - -from django.core.management import call_command -from django.test import TestCase - -from oauth2_provider.models import Application - - -class EnsureBcrhpOauthCommandTests(TestCase): - def _get_bcrhp_apps(self): - return Application.objects.filter(name="BCRHP API").order_by("id") - - @patch("builtins.print") - def test_creates_app_when_missing_and_env_present(self, print_mock): - self.assertEqual(self._get_bcrhp_apps().count(), 0) - - with patch.dict( - os.environ, - { - "BCRHP_API_CLIENT_ID": "client-id-123", - "BCRHP_API_CLIENT_SECRET": "secret-abc", - }, - clear=False, - ): - call_command("ensure_bcrhp_oauth") - - apps = self._get_bcrhp_apps() - self.assertEqual(apps.count(), 1) - - app = apps.first() - self.assertEqual(app.client_id, "client-id-123") - self.assertEqual(app.client_type, "confidential") - self.assertEqual(app.authorization_grant_type, "client-credentials") - - # Secret is often hashed by DOT; don't assert exact equality. - self.assertTrue(app.client_secret) - - # No error prints expected in this happy-path case - printed = " ".join( - " ".join(map(str, c.args)) for c in print_mock.call_args_list - ) - self.assertNotIn("must be set", printed) - - @patch("builtins.print") - def test_does_not_create_when_client_id_missing(self, print_mock): - self.assertEqual(self._get_bcrhp_apps().count(), 0) - - with patch.dict( - os.environ, - { - # Missing BCRHP_API_CLIENT_ID on purpose - "BCRHP_API_CLIENT_SECRET": "secret-abc", - }, - clear=False, - ): - call_command("ensure_bcrhp_oauth") - - self.assertEqual(self._get_bcrhp_apps().count(), 0) - - # Should print message about missing client id - print_mock.assert_called() - printed = " ".join( - " ".join(map(str, c.args)) for c in print_mock.call_args_list - ) - self.assertIn("BCRHP_API_CLIENT_ID environment variable must be set", printed) - - @patch("builtins.print") - def test_updates_secret_when_single_app_exists(self, print_mock): - app = Application.objects.create( - name="BCRHP API", - client_id="client-id-123", - client_type="confidential", - authorization_grant_type="client-credentials", - client_secret="old-secret", - ) - - with patch.dict( - os.environ, - {"BCRHP_API_CLIENT_SECRET": "new-secret"}, - clear=False, - ): - call_command("ensure_bcrhp_oauth") - - app.refresh_from_db() - - # If django-oauth-toolkit hashes secrets, check_secret may exist. - if hasattr(app, "check_secret"): - self.assertTrue(app.check_secret("new-secret")) - else: - # Fallback: at least confirm it changed - self.assertNotEqual(app.client_secret, "old-secret") - self.assertTrue(app.client_secret) - - # No "must be set" print expected when secret provided - printed = " ".join( - " ".join(map(str, c.args)) for c in print_mock.call_args_list - ) - self.assertNotIn("must be set", printed) - - @patch("builtins.print") - def test_multiple_apps_prints_warning_and_does_not_update(self, print_mock): - a1 = Application.objects.create( - name="BCRHP API", - client_id="id1", - client_type="confidential", - authorization_grant_type="client-credentials", - client_secret="secret1", - ) - a2 = Application.objects.create( - name="BCRHP API", - client_id="id2", - client_type="confidential", - authorization_grant_type="client-credentials", - client_secret="secret2", - ) - - # Secrets are typically hashed on save; capture the stored values so we can - # assert they were NOT modified by the command. - a1.refresh_from_db() - a2.refresh_from_db() - a1_secret_before = a1.client_secret - a2_secret_before = a2.client_secret - - with patch.dict( - os.environ, - {"BCRHP_API_CLIENT_SECRET": "new-secret"}, - clear=False, - ): - call_command("ensure_bcrhp_oauth") - - a1.refresh_from_db() - a2.refresh_from_db() - - # Should not have updated either secret in the >1 apps branch - self.assertEqual(a1.client_secret, a1_secret_before) - self.assertEqual(a2.client_secret, a2_secret_before) - - printed = " ".join( - " ".join(map(str, c.args)) for c in print_mock.call_args_list - ) - self.assertIn("More than one BCRHP API application found", printed) diff --git a/tests/management/commands/test_oauth_provider_config.py b/tests/management/commands/test_oauth_provider_config.py new file mode 100644 index 00000000..2a86a0a5 --- /dev/null +++ b/tests/management/commands/test_oauth_provider_config.py @@ -0,0 +1,79 @@ +from django.test import TestCase +from django.core.management import call_command +from django.core.management.base import CommandError +from unittest.mock import patch, MagicMock +from oauth2_provider.models import Application +from django.contrib.auth.hashers import make_password, check_password + + +class OAuthProviderConfigCommandTests(TestCase): + def setUp(self): + """Set up test data""" + self.name = "Test OAuth App" + self.client_id = "test_client_id" + self.client_secret = "test_client_secret" + self.hashed_client_secret = make_password(self.client_secret) + self.redirect_uris = "http://example.com/callback" + self.client_type = "confidential" + self.authorization_grant_type = "authorization-code" + + @patch.dict("os.environ", {"CLIENT_SECRET": "test_client_secret"}) + def test_add_oauth_config_creates_new_app(self): + """Test that a new OAuth application is created with the provided parameters""" + with patch("builtins.input", return_value="y"): + call_command( + "oauth_provider_config", + "--config-name", + self.name, + "--client-id", + self.client_id, + "--redirect-uris", + self.redirect_uris, + "--client-type", + self.client_type, + "--grant-type", + self.authorization_grant_type, + ) + + app = Application.objects.get(client_id=self.client_id) + self.assertEqual(app.name, self.name) + self.assertTrue(check_password(self.client_secret, app.client_secret)) + self.assertEqual(app.redirect_uris, self.redirect_uris) + self.assertEqual(app.client_type, self.client_type) + self.assertEqual(app.authorization_grant_type, self.authorization_grant_type) + + @patch.dict("os.environ", {"CLIENT_SECRET": "new_secret"}) + def test_update_existing_app_secret(self): + hashed_secret = make_password("new_secret") + """Test updating the client secret of an existing application""" + # Create initial application + Application.objects.create( + name=self.name, + client_id=self.client_id, + client_secret="old_secret", + redirect_uris=self.redirect_uris, + client_type=self.client_type, + authorization_grant_type=self.authorization_grant_type, + ) + + with patch("builtins.input", return_value="y"): + call_command( + "oauth_provider_config", + "--config-name", + self.name, + ) + + app = Application.objects.get(client_id=self.client_id) + self.assertTrue(check_password("new_secret", app.client_secret)) + + def test_missing_client_secret_env_var(self): + """Test that command fails when CLIENT_SECRET environment variable is missing""" + with patch.dict("os.environ", {}, clear=True): + with self.assertRaises(CommandError): + call_command( + "oauth_provider_config", + "--config-name", + self.name, + "--client-id", + self.client_id, + )