Skip to content

Commit cc2b73d

Browse files
committed
Add more HTML templates, minor fixes, add joinId param to single feature item endpoint, improve caching of source refs
1 parent eacce01 commit cc2b73d

File tree

12 files changed

+440
-120
lines changed

12 files changed

+440
-120
lines changed

pygeoapi/api/itemtypes.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ def get_collection_items(
594594
'NoApplicableCode', msg
595595
)
596596

597+
# Add links to join source
597598
joins_uri = f'{api.get_collections_url()}/{dataset}/joins'
598599
content['links'].extend([{
599600
'type': FORMAT_TYPES[F_JSON],
@@ -684,6 +685,8 @@ def get_collection_items(
684685
l10n.set_response_language(headers, prv_locale, request.locale)
685686

686687
if request.format == F_HTML: # render
688+
html_fields = frozenset(key for f in content['features']
689+
for key in f['properties'])
687690
tpl_config = api.get_dataset_templates(dataset)
688691
# For constructing proper URIs to items
689692

@@ -694,6 +697,8 @@ def get_collection_items(
694697

695698
content['offset'] = offset
696699

700+
content['join_id'] = join_id
701+
content['columns'] = html_fields
697702
content['id_field'] = p.id_field
698703
if p.uri_field is not None:
699704
content['uri_field'] = p.uri_field
@@ -909,6 +914,7 @@ def get_collection_item(api: API, request: APIRequest,
909914
err.http_status_code, headers, request.format,
910915
err.ogc_exception_code, err.message)
911916

917+
join_id = None
912918
crs_transform_spec = None
913919
if provider_type == 'feature':
914920
# crs query parameter is only available for OGC API - Features
@@ -926,6 +932,10 @@ def get_collection_item(api: API, request: APIRequest,
926932
'InvalidParameterValue', msg)
927933
set_content_crs_header(headers, provider_def, query_crs_uri)
928934

935+
# joinId query parameter also works for OGC API - Features only
936+
LOGGER.debug('processing joinId parameter')
937+
join_id = request.params.get('joinId')
938+
929939
# Get provider language (if any)
930940
prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
931941

@@ -985,6 +995,52 @@ def get_collection_item(api: API, request: APIRequest,
985995
'href': f'{api.get_collections_url()}/{dataset}'
986996
}])
987997

998+
joins_uri = None
999+
if join_id:
1000+
# Post-process Feature and join with CSV data
1001+
try:
1002+
join_util.perform_join(content, dataset, join_id)
1003+
except ValueError as e:
1004+
LOGGER.error(f'Invalid request parameter: {e}',
1005+
exc_info=True)
1006+
return api.get_exception(
1007+
HTTPStatus.BAD_REQUEST, headers, request.format,
1008+
'InvalidParameterValue', str(e))
1009+
except KeyError as e:
1010+
msg = 'Join source not found'
1011+
LOGGER.error(f'Unknown joinId: {e}',
1012+
exc_info=True)
1013+
return api.get_exception(
1014+
HTTPStatus.NOT_FOUND, headers, request.format,
1015+
'NotFound', msg)
1016+
except Exception as e:
1017+
LOGGER.error(f'Failed to perform join: {e}',
1018+
exc_info=True)
1019+
msg = f'Failed to perform join: {str(e)}'
1020+
return api.get_exception(
1021+
HTTPStatus.INTERNAL_SERVER_ERROR, headers, F_JSON,
1022+
'NoApplicableCode', msg
1023+
)
1024+
1025+
# Add links to join source
1026+
joins_uri = f'{api.get_collections_url()}/{dataset}/joins'
1027+
content['links'].extend([{
1028+
'type': FORMAT_TYPES[F_JSON],
1029+
'rel': request.get_linkrel(F_JSON),
1030+
'title': l10n.translate('Join source details as JSON', request.locale), # noqa
1031+
'href': f'{joins_uri}/{join_id}?f={F_JSON}'
1032+
}, {
1033+
'type': FORMAT_TYPES[F_JSONLD],
1034+
'rel': request.get_linkrel(F_JSONLD),
1035+
'title': l10n.translate('Join source details as RDF (JSON-LD)', request.locale), # noqa
1036+
'href': f'{joins_uri}/{join_id}?f={F_JSONLD}'
1037+
}, {
1038+
'type': FORMAT_TYPES[F_HTML],
1039+
'rel': request.get_linkrel(F_HTML),
1040+
'title': l10n.translate('Join source details as HTML', request.locale), # noqa
1041+
'href': f'{joins_uri}/{join_id}?f={F_HTML}'
1042+
}])
1043+
9881044
link_request_format = (
9891045
request.format if request.format is not None else F_JSON
9901046
)
@@ -1010,6 +1066,8 @@ def get_collection_item(api: API, request: APIRequest,
10101066
tpl_config = api.get_dataset_templates(dataset)
10111067
content['title'] = l10n.translate(collections[dataset]['title'],
10121068
request.locale)
1069+
content['joins_path'] = joins_uri
1070+
content['join_id'] = join_id
10131071
content['id_field'] = p.id_field
10141072
if p.uri_field is not None:
10151073
content['uri_field'] = p.uri_field

pygeoapi/api/joins.py

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from pygeoapi.provider.base import ProviderTypeError, ProviderGenericError
4040
from pygeoapi.util import (
4141
get_provider_by_type, to_json, filter_providers_by_type,
42-
filter_dict_by_key_value, get_current_datetime
42+
filter_dict_by_key_value, get_current_datetime, render_j2_template
4343
)
4444

4545
LOGGER = logging.getLogger(__name__)
@@ -491,6 +491,32 @@ def _server_error(api: API, request: APIRequest, headers: dict,
491491
)
492492

493493

494+
def _render_html(api: API, request: APIRequest, dataset: str, template: str,
495+
title: str, data: dict, **data_kwargs) -> str:
496+
"""
497+
Render HTML content for the API response.
498+
Adds base_url, dataset_path, and collections_path URLs to the data dict
499+
before the page is rendered.
500+
501+
:param api: API instance
502+
:param request: A request object
503+
:param dataset: Dataset name (collection key)
504+
:param template: Template file path (relative)
505+
:param title: Title of the HTML page
506+
:param data: Data dictionary to be passed to the template
507+
:param data_kwargs: Additional keyword arguments to update the data dict
508+
"""
509+
collections_url = api.get_collections_url()
510+
data['title'] = title
511+
data['base_url'] = api.base_url
512+
data['dataset_path'] = f'{collections_url}/{dataset}'
513+
data['collections_path'] = collections_url
514+
data.update(data_kwargs)
515+
tpl_config = api.get_dataset_templates(dataset)
516+
return render_j2_template(api.tpl_config, tpl_config,
517+
template, data, request.locale)
518+
519+
494520
def list_joins(api: API, request: APIRequest,
495521
collection: str) -> tuple[dict, int, str]:
496522
"""
@@ -517,24 +543,24 @@ def list_joins(api: API, request: APIRequest,
517543
# Build the joins list with proper structure
518544
joins_list = []
519545
uri = f'{api.get_collections_url()}/{dataset}'
520-
for source_id, source_obj in sources:
546+
for source_id, source_obj in sources.items():
521547
join_item = {
522548
'id': source_id,
523549
'timeStamp': source_obj['timeStamp'],
524550
'links': [
525551
{
526552
'type': FORMAT_TYPES[F_JSON],
527-
'rel': request.get_linkrel(F_JSON),
553+
'rel': "join-source",
528554
'title': l10n.translate('Join source details as JSON', request.locale), # noqa
529555
'href': f'{uri}/joins/{source_id}?f={F_JSON}'
530556
}, {
531557
'type': FORMAT_TYPES[F_JSONLD],
532-
'rel': request.get_linkrel(F_JSONLD),
558+
'rel': "join-source",
533559
'title': l10n.translate('Join source details as RDF (JSON-LD)', request.locale), # noqa
534560
'href': f'{uri}/joins/{source_id}?f={F_JSONLD}'
535561
}, {
536562
'type': FORMAT_TYPES[F_HTML],
537-
'rel': request.get_linkrel(F_HTML),
563+
'rel': "join-source",
538564
'title': l10n.translate('Join source details as HTML', request.locale), # noqa
539565
'href': f'{uri}/joins/{source_id}?f={F_HTML}'
540566
}
@@ -544,7 +570,7 @@ def list_joins(api: API, request: APIRequest,
544570

545571
# Build the response with proper structure
546572
# TODO: support pagination
547-
response = {
573+
output = {
548574
'links': [
549575
{
550576
'type': FORMAT_TYPES[F_JSON],
@@ -574,7 +600,25 @@ def list_joins(api: API, request: APIRequest,
574600
# locale (or fallback default locale)
575601
l10n.set_response_language(headers, request.locale)
576602

577-
return headers, HTTPStatus.OK, to_json(response, api.pretty_print)
603+
if request.format == F_HTML: # render
604+
# HTML only: use provider to fetch key fields for dropdown
605+
try:
606+
provider_def = get_provider_by_type(
607+
collections[dataset]['providers'], 'feature'
608+
)
609+
provider = load_plugin('provider', provider_def)
610+
keys = provider.get_key_fields()
611+
except ProviderTypeError:
612+
LOGGER.warning(f'Feature provider not found for collection: '
613+
f'{dataset}', exc_info=True)
614+
keys = {}
615+
616+
title = f'{collections[dataset]['title']} - Join Sources'
617+
content = _render_html(api, request, dataset, 'collections/joins.html',
618+
title, output, key_fields=keys)
619+
return headers, HTTPStatus.OK, content
620+
621+
return headers, HTTPStatus.OK, to_json(output, api.pretty_print)
578622

579623

580624
def join_details(api: API, request: APIRequest,
@@ -599,7 +643,7 @@ def join_details(api: API, request: APIRequest,
599643
details = join_util.read_join_source(dataset, join_id)
600644

601645
uri = f'{api.get_collections_url()}/{dataset}'
602-
response = {
646+
output = {
603647
'id': join_id,
604648
'timeStamp': get_current_datetime(),
605649
'details': {
@@ -628,17 +672,17 @@ def join_details(api: API, request: APIRequest,
628672
'href': f'{uri}/joins/{join_id}?f={F_HTML}'
629673
}, {
630674
'type': 'application/geo+json',
631-
'rel': 'results',
675+
'rel': 'items',
632676
'title': 'Items with joined data as GeoJSON',
633677
'href': f"{uri}/items?f={F_JSON}&joinId={join_id}",
634678
}, {
635679
'type': FORMAT_TYPES[F_JSONLD],
636-
'rel': 'results',
680+
'rel': 'items',
637681
'title': 'Items with joined data as RDF (JSON-LD)',
638682
'href': f"{uri}/items?f={F_JSONLD}&joinId={join_id}", # noqa
639683
}, {
640684
'type': FORMAT_TYPES[F_HTML],
641-
'rel': 'results',
685+
'rel': 'items',
642686
'title': 'Items with joined data items as HTML',
643687
'href': f"{uri}/items?f={F_HTML}&joinId={join_id}",
644688
}
@@ -662,7 +706,13 @@ def join_details(api: API, request: APIRequest,
662706
# locale (or fallback default locale)
663707
l10n.set_response_language(headers, request.locale)
664708

665-
return headers, HTTPStatus.OK, to_json(response, api.pretty_print)
709+
if request.format == F_HTML: # render
710+
title = f'{collections[dataset]['title']} - Join Source'
711+
content = _render_html(api, request, dataset,
712+
'collections/joinsource.html', title, output)
713+
return headers, HTTPStatus.OK, content
714+
715+
return headers, HTTPStatus.OK, to_json(output, api.pretty_print)
666716

667717

668718
def create_join(api: API, request: APIRequest,
@@ -711,7 +761,7 @@ def create_join(api: API, request: APIRequest,
711761

712762
uri = f'{api.get_collections_url()}/{dataset}'
713763
join_id = details['id']
714-
response = {
764+
output = {
715765
'id': join_id,
716766
'timeStamp': get_current_datetime(),
717767
'details': {
@@ -740,17 +790,17 @@ def create_join(api: API, request: APIRequest,
740790
'href': f'{uri}/joins/{join_id}?f={F_HTML}'
741791
}, {
742792
'type': 'application/geo+json',
743-
'rel': 'results',
793+
'rel': 'items',
744794
'title': 'Items with joined data as GeoJSON',
745795
'href': f"{uri}/items?f={F_JSON}&joinId={details['id']}", # noqa
746796
}, {
747797
'type': FORMAT_TYPES[F_JSONLD],
748-
'rel': 'results',
798+
'rel': 'items',
749799
'title': 'Items with joined data as RDF (JSON-LD)',
750800
'href': f"{uri}/items?f={F_JSONLD}&joinId={details['id']}", # noqa
751801
}, {
752802
'type': FORMAT_TYPES[F_HTML],
753-
'rel': 'results',
803+
'rel': 'items',
754804
'title': 'Items with joined data as HTML',
755805
'href': f"{uri}/items?f={F_HTML}&joinId={details['id']}", # noqa
756806
}
@@ -774,7 +824,15 @@ def create_join(api: API, request: APIRequest,
774824
# locale (or fallback default locale)
775825
l10n.set_response_language(headers, prv_locale, request.locale)
776826

777-
return headers, HTTPStatus.OK, to_json(response, api.pretty_print)
827+
if request.format == F_HTML:
828+
# Render same page as join details to show result of POST
829+
title = f'{collections[dataset]['title']} - Join Source'
830+
content = _render_html(api, request, dataset,
831+
'collections/joinsource.html', title, output,
832+
description="Join source created successfully.")
833+
return headers, HTTPStatus.OK, content
834+
835+
return headers, HTTPStatus.OK, to_json(output, api.pretty_print)
778836

779837

780838
def delete_join(api: API, request: APIRequest,
@@ -855,7 +913,7 @@ def key_fields(api: API, request: APIRequest,
855913
prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
856914

857915
uri = f'{api.get_collections_url()}/{dataset}'
858-
content = {
916+
output = {
859917
'links': [
860918
{
861919
'type': FORMAT_TYPES[F_JSON],
@@ -877,17 +935,23 @@ def key_fields(api: API, request: APIRequest,
877935
'keys': []
878936
}
879937

880-
for name, info in fields:
881-
content['keys'].append({
938+
for name, info in fields.items():
939+
output['keys'].append({
882940
'id': name,
883941
'type': info.get('type'), # not always set (e.g. for ID)
884942
'isDefault': info['default'],
885-
'language': prv_locale.language # TODO: is this really useful?
943+
'language': prv_locale.language if prv_locale else request.locale.language # noqa
886944
})
887945

888946
# Set response language to requested provider locale
889947
# (if it supports language) and/or otherwise the requested pygeoapi
890948
# locale (or fallback default locale)
891-
l10n.set_response_language(headers, prv_locale, request.locale)
949+
l10n.set_response_language(headers, request.locale)
950+
951+
if request.format == F_HTML: # render
952+
title = f'{collections[dataset]['title']} - Key Fields'
953+
output = _render_html(api, request, dataset, 'collections/keys.html',
954+
title, output)
955+
return headers, HTTPStatus.OK, output
892956

893-
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
957+
return headers, HTTPStatus.OK, to_json(output, api.pretty_print)

0 commit comments

Comments
 (0)