14 Commits

Author SHA1 Message Date
xbgmsharp
8197a26c49 Release 0.4.1 2023-10-31 10:18:02 +01:00
xbgmsharp
ba3b213423 Update SQL Uppercase type 2023-10-30 21:17:21 +01:00
xbgmsharp
0be57a4e70 Update role, add iew select for user_role 2023-10-30 21:16:44 +01:00
xbgmsharp
80163d3fe2 Update schemalint, disable tests, most require large review for future reference 2023-10-30 11:59:24 +01:00
xbgmsharp
c8795b15f3 Update test versions, latest PostgREST 2023-10-29 23:08:21 +01:00
xbgmsharp
e8c0ea5c94 revert logbook naming 2023-10-29 22:54:53 +01:00
xbgmsharp
38ad6084bb Updte openAPI 2023-10-29 22:54:12 +01:00
xbgmsharp
c726187b4d Update explore view 2023-10-29 22:38:45 +01:00
xbgmsharp
3eafa2e13f Update export functiond to order by date rether than id.
Update delete logbook to return a boolean
2023-10-29 21:31:15 +01:00
xbgmsharp
d13f096d4f Update process_logbook_queue_fn, remove the gpx handler 2023-10-29 18:44:43 +01:00
xbgmsharp
e2e37e1f01 Add explore view 2023-10-29 18:44:03 +01:00
xbgmsharp
3bbe309de3 Add delete logbook and dependency stays 2023-10-29 18:43:32 +01:00
xbgmsharp
2be7c787dd Remove duplicated 2023-10-28 22:28:14 +02:00
xbgmsharp
9aecda4752 Update reverse_geocode_py_fn, improve location detection, ignore tag road or highway. Recursive over lower zoom level. 2023-10-28 21:55:29 +02:00
9 changed files with 262 additions and 82 deletions

View File

@@ -6,17 +6,17 @@ module.exports = {
database: process.env.PGDATABASE,
charset: "utf8",
},
rules: {
"name-casing": ["error", "snake"],
//"name-casing": ["error", "snake"],
"prefer-jsonb-to-json": ["error"],
"prefer-text-to-varchar": ["error"],
"prefer-timestamptz-to-timestamp": ["error"],
"prefer-identity-to-serial": ["error"],
"name-inflection": ["error", "singular"],
//"prefer-timestamptz-to-timestamp": ["error"],
//"prefer-identity-to-serial": ["error"],
//"name-inflection": ["error", "singular"],
},
schemas: [{ name: "public" }, { name: "api" }],
ignores: [],
};

View File

@@ -29,8 +29,7 @@ CREATE OR REPLACE FUNCTION api.timelapse_fn(
WHERE id >= start_log
AND id <= end_log
AND track_geom IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
)
SELECT ST_AsGeoJSON(geo.*) INTO _geojson FROM (
SELECT ST_Collect(
@@ -45,8 +44,7 @@ CREATE OR REPLACE FUNCTION api.timelapse_fn(
WHERE _from_time >= start_log::TIMESTAMP WITHOUT TIME ZONE
AND _to_time <= end_date::TIMESTAMP WITHOUT TIME ZONE + interval '23 hours 59 minutes'
AND track_geom IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
)
SELECT ST_AsGeoJSON(geo.*) INTO _geojson FROM (
SELECT ST_Collect(
@@ -59,8 +57,7 @@ CREATE OR REPLACE FUNCTION api.timelapse_fn(
SELECT track_geom
FROM api.logbook
WHERE track_geom IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
)
SELECT ST_AsGeoJSON(geo.*) INTO _geojson FROM (
SELECT ST_Collect(
@@ -232,8 +229,7 @@ AS $export_logbooks_gpx$
WHERE id >= start_log
AND id <= end_log
AND track_geojson IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
) AS sub
WHERE (f->'geometry'->>'type') = 'Point';
ELSE
@@ -244,8 +240,7 @@ AS $export_logbooks_gpx$
SELECT jsonb_array_elements(track_geojson->'features') AS f
FROM api.logbook
WHERE track_geojson IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
) AS sub
WHERE (f->'geometry'->>'type') = 'Point';
END IF;
@@ -295,8 +290,7 @@ BEGIN
WHERE id >= start_log
AND id <= end_log
AND track_geom IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
)
SELECT ST_Collect(
ARRAY(
@@ -307,8 +301,7 @@ BEGIN
SELECT track_geom
FROM api.logbook
WHERE track_geom IS NOT NULL
GROUP BY id
ORDER BY id ASC
ORDER BY _from_time ASC
)
SELECT ST_Collect(
ARRAY(
@@ -728,4 +721,60 @@ $stats_stays$ LANGUAGE plpgsql;
-- Description
COMMENT ON FUNCTION
api.stats_stays_fn
IS 'Stays/Moorages stats by date';
IS 'Stays/Moorages stats by date';
DROP FUNCTION IF EXISTS api.delete_logbook_fn;
CREATE OR REPLACE FUNCTION api.delete_logbook_fn(IN _id integer) RETURNS BOOLEAN AS $delete_logbook$
DECLARE
logbook_rec record;
previous_stays_id numeric;
current_stays_departed text;
current_stays_id numeric;
current_stays_active boolean;
BEGIN
-- If _id is not NULL
IF _id IS NULL OR _id < 1 THEN
RAISE WARNING '-> delete_logbook_fn invalid input %', _id;
RETURN FALSE;
END IF;
-- Update logbook
UPDATE api.logbook l
SET notes = 'mark for deletion'
WHERE l.vessel_id = current_setting('vessel.id', false)
AND id = logbook_rec.id;
-- Get related stays
SELECT id,departed,active INTO current_stays_id,current_stays_departed,current_stays_active
FROM api.stays s
WHERE s.vessel_id = current_setting('vessel.id', false)
AND s.arrived = logbook_rec._to_time;
-- Update related stays
UPDATE api.stays s
SET notes = 'mark for deletion'
WHERE s.vessel_id = current_setting('vessel.id', false)
AND s.arrived = logbook_rec._to_time;
-- Find previous stays
SELECT id INTO previous_stays_id
FROM api.stays s
WHERE s.vessel_id = current_setting('vessel.id', false)
AND s.arrived < logbook_rec._to_time
ORDER BY s.arrived DESC LIMIT 1;
-- Update previous stays with the departed time from current stays
-- and set the active state from current stays
UPDATE api.stays
SET departed = current_stays_departed::timestamp without time zone,
active = current_stays_active
WHERE vessel_id = current_setting('vessel.id', false)
AND id = previous_stays_id;
-- Clean up, remove invalid logbook and stay entry
DELETE FROM api.logbook WHERE id = logbook_rec.id;
RAISE WARNING '-> delete_logbook_fn delete logbook [%]', logbook_rec.id;
DELETE FROM api.stays WHERE id = current_stays_id;
RAISE WARNING '-> delete_logbook_fn delete stays [%]', current_stays_id;
-- TODO should we subtract (-1) moorages ref count or reprocess it?!?
RETURN TRUE;
END;
$delete_logbook$ LANGUAGE plpgsql;
-- Description
COMMENT ON FUNCTION
api.delete_logbook_fn
IS 'Delete a logbook and dependency stay';

View File

@@ -11,6 +11,8 @@
-- Views
-- Views are invoked with the privileges of the view owner,
-- make the user_role the views owner.
-- to bypass this limit you need pg15+ with specific settings
-- security_invoker=true,security_barrier=true
---------------------------------------------------------------------------
CREATE VIEW first_metric AS
@@ -23,12 +25,14 @@ CREATE VIEW last_metric AS
FROM api.metrics
ORDER BY time DESC LIMIT 1;
CREATE VIEW trip_in_progress AS
DROP VIEW IF EXISTS public.trip_in_progress;
CREATE VIEW public.trip_in_progress AS
SELECT *
FROM api.logbook
WHERE active IS true;
CREATE VIEW stay_in_progress AS
DROP VIEW IF EXISTS public.stay_in_progress;
CREATE VIEW public.stay_in_progress AS
SELECT *
FROM api.stays
WHERE active IS true;
@@ -454,3 +458,19 @@ CREATE VIEW api.total_info_view WITH (security_invoker=true,security_barrier=tru
COMMENT ON VIEW
api.total_info_view
IS 'total_info_view web view';
DROP VIEW IF EXISTS api.explore_view;
CREATE VIEW api.explore_view WITH (security_invoker=true,security_barrier=true) AS
-- Expose last metrics
WITH raw_metrics AS (
SELECT m.time, m.metrics
FROM api.metrics m
ORDER BY m.time desc limit 1
)
SELECT raw_metrics.time, key, value
FROM raw_metrics,
jsonb_each_text(raw_metrics.metrics)
ORDER BY key ASC;
COMMENT ON VIEW
api.explore_view
IS 'explore_view web view';

View File

@@ -33,7 +33,7 @@ CREATE OR REPLACE FUNCTION public.logbook_metrics_dwithin_fn(
AND ST_DWithin(
Geography(ST_MakePoint(m.longitude, m.latitude)),
Geography(ST_MakePoint(lgn, lat)),
15
50
);
END;
$logbook_metrics_dwithin$ LANGUAGE plpgsql;
@@ -238,7 +238,7 @@ $logbook_update_gpx$ LANGUAGE plpgsql;
-- Description
COMMENT ON FUNCTION
public.logbook_update_gpx_fn
IS 'Update log details with gpx xml';
IS 'Update log details with gpx xml, deprecated';
CREATE FUNCTION logbook_get_extra_json_fn(IN search TEXT, OUT output_json JSON)
AS $logbook_get_extra_json$
@@ -375,7 +375,6 @@ CREATE OR REPLACE FUNCTION process_logbook_queue_fn(IN _id integer) RETURNS void
log_settings jsonb;
user_settings jsonb;
geojson jsonb;
gpx xml;
_invalid_time boolean;
_invalid_interval boolean;
_invalid_distance boolean;
@@ -410,7 +409,7 @@ CREATE OR REPLACE FUNCTION process_logbook_queue_fn(IN _id integer) RETURNS void
PERFORM set_config('vessel.id', logbook_rec.vessel_id, false);
--RAISE WARNING 'public.process_logbook_queue_fn() scheduler vessel.id %, user.id', current_setting('vessel.id', false), current_setting('user.id', false);
-- Check if all metrics are within 10meters base on geo loc
-- Check if all metrics are within 50meters base on geo loc
count_metric := logbook_metrics_dwithin_fn(logbook_rec._from_time::TEXT, logbook_rec._to_time::TEXT, logbook_rec._from_lng::NUMERIC, logbook_rec._from_lat::NUMERIC);
RAISE NOTICE '-> process_logbook_queue_fn logbook_metrics_dwithin_fn count:[%]', count_metric;
@@ -508,13 +507,6 @@ CREATE OR REPLACE FUNCTION process_logbook_queue_fn(IN _id integer) RETURNS void
track_geojson = geojson
WHERE id = logbook_rec.id;
-- GPX field
--gpx := logbook_update_gpx_fn(logbook_rec.id, logbook_rec._from_time::TEXT, logbook_rec._to_time::TEXT);
--UPDATE api.logbook
-- SET
-- track_gpx = gpx
-- WHERE id = logbook_rec.id;
-- Prepare notification, gather user settings
SELECT json_build_object('logbook_name', log_name, 'logbook_link', logbook_rec.id) into log_settings;
user_settings := get_user_settings_from_vesselid_fn(logbook_rec.vessel_id::TEXT);
@@ -1302,6 +1294,115 @@ COMMENT ON FUNCTION
public.badges_geom_fn
IS 'check geometry logbook for new badges, eg: Tropic, Alaska, Geographic zone';
DROP FUNCTION IF EXISTS public.process_logbook_valid_fn;
CREATE OR REPLACE FUNCTION public.process_logbook_valid_fn(IN _id integer) RETURNS void AS $process_logbook_valid$
DECLARE
logbook_rec record;
avg_rec record;
geo_rec record;
_invalid_time boolean;
_invalid_interval boolean;
_invalid_distance boolean;
count_metric numeric;
previous_stays_id numeric;
current_stays_departed text;
current_stays_id numeric;
current_stays_active boolean;
BEGIN
-- If _id is not NULL
IF _id IS NULL OR _id < 1 THEN
RAISE WARNING '-> process_logbook_valid_fn invalid input %', _id;
RETURN;
END IF;
-- Get the logbook record with all necessary fields exist
SELECT * INTO logbook_rec
FROM api.logbook
WHERE active IS false
AND id = _id
AND _from_lng IS NOT NULL
AND _from_lat IS NOT NULL
AND _to_lng IS NOT NULL
AND _to_lat IS NOT NULL;
-- Ensure the query is successful
IF logbook_rec.vessel_id IS NULL THEN
RAISE WARNING '-> process_logbook_valid_fn invalid logbook %', _id;
RETURN;
END IF;
PERFORM set_config('vessel.id', logbook_rec.vessel_id, false);
--RAISE WARNING 'public.process_logbook_queue_fn() scheduler vessel.id %, user.id', current_setting('vessel.id', false), current_setting('user.id', false);
-- Check if all metrics are within 10meters base on geo loc
count_metric := logbook_metrics_dwithin_fn(logbook_rec._from_time::TEXT, logbook_rec._to_time::TEXT, logbook_rec._from_lng::NUMERIC, logbook_rec._from_lat::NUMERIC);
RAISE NOTICE '-> process_logbook_valid_fn logbook_metrics_dwithin_fn count:[%]', count_metric;
-- Calculate logbook data average and geo
-- Update logbook entry with the latest metric data and calculate data
avg_rec := logbook_update_avg_fn(logbook_rec.id, logbook_rec._from_time::TEXT, logbook_rec._to_time::TEXT);
geo_rec := logbook_update_geom_distance_fn(logbook_rec.id, logbook_rec._from_time::TEXT, logbook_rec._to_time::TEXT);
-- Avoid/ignore/delete logbook stationary movement or time sync issue
-- Check time start vs end
SELECT logbook_rec._to_time::timestamp without time zone < logbook_rec._from_time::timestamp without time zone INTO _invalid_time;
-- Is distance is less than 0.010
SELECT geo_rec._track_distance < 0.010 INTO _invalid_distance;
-- Is duration is less than 100sec
SELECT (logbook_rec._to_time::timestamp without time zone - logbook_rec._from_time::timestamp without time zone) < (100::text||' secs')::interval INTO _invalid_interval;
-- if stationary fix data metrics,logbook,stays,moorage
IF _invalid_time IS True OR _invalid_distance IS True
OR _invalid_interval IS True OR count_metric = avg_rec.count_metric THEN
RAISE NOTICE '-> process_logbook_queue_fn invalid logbook data id [%], _invalid_time [%], _invalid_distance [%], _invalid_interval [%], within count_metric == total count_metric [%]',
logbook_rec.id, _invalid_time, _invalid_distance, _invalid_interval, count_metric;
-- Update metrics status to moored
UPDATE api.metrics
SET status = 'moored'
WHERE time >= logbook_rec._from_time::TIMESTAMP WITHOUT TIME ZONE
AND time <= logbook_rec._to_time::TIMESTAMP WITHOUT TIME ZONE
AND vessel_id = current_setting('vessel.id', false);
-- Update logbook
UPDATE api.logbook
SET notes = 'invalid logbook data, stationary need to fix metrics?'
WHERE vessel_id = current_setting('vessel.id', false)
AND id = logbook_rec.id;
-- Get related stays
SELECT id,departed,active INTO current_stays_id,current_stays_departed,current_stays_active
FROM api.stays s
WHERE s.vessel_id = current_setting('vessel.id', false)
AND s.arrived = logbook_rec._to_time;
-- Update related stays
UPDATE api.stays s
SET notes = 'invalid stays data, stationary need to fix metrics?'
WHERE vessel_id = current_setting('vessel.id', false)
AND arrived = logbook_rec._to_time;
-- Find previous stays
SELECT id INTO previous_stays_id
FROM api.stays s
WHERE s.vessel_id = current_setting('vessel.id', false)
AND s.arrived < logbook_rec._to_time
ORDER BY s.arrived DESC LIMIT 1;
-- Update previous stays with the departed time from current stays
-- and set the active state from current stays
UPDATE api.stays
SET departed = current_stays_departed::timestamp without time zone,
active = current_stays_active
WHERE vessel_id = current_setting('vessel.id', false)
AND id = previous_stays_id;
-- Clean up, remove invalid logbook and stay entry
DELETE FROM api.logbook WHERE id = logbook_rec.id;
RAISE WARNING '-> process_logbook_queue_fn delete invalid logbook [%]', logbook_rec.id;
DELETE FROM api.stays WHERE id = current_stays_id;
RAISE WARNING '-> process_logbook_queue_fn delete invalid stays [%]', current_stays_id;
-- TODO should we subtract (-1) moorages ref count or reprocess it?!?
RETURN;
END IF;
END;
$process_logbook_valid$ LANGUAGE plpgsql;
-- Description
COMMENT ON FUNCTION
public.process_logbook_queue_fn
IS 'Avoid/ignore/delete logbook stationary movement or time sync issue';
---------------------------------------------------------------------------
-- TODO add alert monitoring for Battery

View File

@@ -15,9 +15,9 @@ CREATE SCHEMA IF NOT EXISTS public;
--
-- https://github.com/CartoDB/labs-postgresql/blob/master/workshop/plpython.md
--
DROP FUNCTION IF EXISTS reverse_geocode_py_fn;
DROP FUNCTION IF EXISTS reverse_geocode_py_fn;
CREATE OR REPLACE FUNCTION reverse_geocode_py_fn(IN geocoder TEXT, IN lon NUMERIC, IN lat NUMERIC,
OUT geo jsonb)
OUT geo JSONB)
AS $reverse_geocode_py$
import requests
@@ -39,47 +39,56 @@ AS $reverse_geocode_py$
plpy.error('Error missing parameters')
return None
# Make the request to the geocoder API
# https://operations.osmfoundation.org/policies/nominatim/
payload = {"lon": lon, "lat": lat, "format": "jsonv2", "zoom": 18}
# https://nominatim.org/release-docs/latest/api/Reverse/
r = requests.get(url, headers = {"Accept-Language": "en-US,en;q=0.5"}, params=payload)
def georeverse(geocoder, lon, lat, zoom="18"):
# Make the request to the geocoder API
# https://operations.osmfoundation.org/policies/nominatim/
payload = {"lon": lon, "lat": lat, "format": "jsonv2", "zoom": zoom, "accept-language": "en"}
# https://nominatim.org/release-docs/latest/api/Reverse/
r = requests.get(url, headers = {"Accept-Language": "en-US,en;q=0.5"}, params=payload)
# Parse response
# Option1: If name is null fallback to address field road,neighbourhood,suburb
# Option2: Return the json for future reference like country
if r.status_code == 200 and "name" in r.json():
r_dict = r.json()
#plpy.notice('reverse_geocode_py_fn Parameters [{}] [{}] Response'.format(lon, lat, r_dict))
output = None
country_code = None
if "country_code" in r_dict["address"] and r_dict["address"]["country_code"]:
country_code = r_dict["address"]["country_code"]
if r_dict["name"]:
return { "name": r_dict["name"], "country_code": country_code }
elif "address" in r_dict and r_dict["address"]:
if "neighbourhood" in r_dict["address"] and r_dict["address"]["neighbourhood"]:
return { "name": r_dict["address"]["neighbourhood"], "country_code": country_code }
elif "road" in r_dict["address"] and r_dict["address"]["road"]:
return { "name": r_dict["address"]["road"], "country_code": country_code }
elif "suburb" in r_dict["address"] and r_dict["address"]["suburb"]:
return { "name": r_dict["address"]["suburb"], "country_code": country_code }
elif "residential" in r_dict["address"] and r_dict["address"]["residential"]:
return { "name": r_dict["address"]["residential"], "country_code": country_code }
elif "village" in r_dict["address"] and r_dict["address"]["village"]:
return { "name": r_dict["address"]["village"], "country_code": country_code }
elif "town" in r_dict["address"] and r_dict["address"]["town"]:
return { "name": r_dict["address"]["town"], "country_code": country_code }
else:
return { "name": "n/a", "country_code": country_code }
else:
return { "name": "n/a", "country_code": country_code }
else:
plpy.warning('Failed to received a geo full address %s', r.json())
#plpy.error('Failed to received a geo full address %s', r.json())
return { "name": "unknown", "country_code": "unknown" }
# Parse response
# If name is null fallback to address field tags: neighbourhood,suburb
# if none repeat with lower zoom level
if r.status_code == 200 and "name" in r.json():
r_dict = r.json()
#plpy.notice('reverse_geocode_py_fn Parameters [{}] [{}] Response'.format(lon, lat, r_dict))
output = None
country_code = None
if "country_code" in r_dict["address"] and r_dict["address"]["country_code"]:
country_code = r_dict["address"]["country_code"]
if r_dict["name"]:
return { "name": r_dict["name"], "country_code": country_code }
elif "address" in r_dict and r_dict["address"]:
if "neighbourhood" in r_dict["address"] and r_dict["address"]["neighbourhood"]:
return { "name": r_dict["address"]["neighbourhood"], "country_code": country_code }
elif "hamlet" in r_dict["address"] and r_dict["address"]["hamlet"]:
return { "name": r_dict["address"]["hamlet"], "country_code": country_code }
elif "suburb" in r_dict["address"] and r_dict["address"]["suburb"]:
return { "name": r_dict["address"]["suburb"], "country_code": country_code }
elif "residential" in r_dict["address"] and r_dict["address"]["residential"]:
return { "name": r_dict["address"]["residential"], "country_code": country_code }
elif "village" in r_dict["address"] and r_dict["address"]["village"]:
return { "name": r_dict["address"]["village"], "country_code": country_code }
elif "town" in r_dict["address"] and r_dict["address"]["town"]:
return { "name": r_dict["address"]["town"], "country_code": country_code }
elif "amenity" in r_dict["address"] and r_dict["address"]["amenity"]:
return { "name": r_dict["address"]["amenity"], "country_code": country_code }
else:
if (zoom == 15):
plpy.notice('georeverse recursive retry with lower zoom than:[{}], Response [{}]'.format(zoom , r.json()))
return georeverse(geocoder, lon, lat, 14)
else:
plpy.notice('georeverse recursive retry with lower zoom than:[{}], Response [{}]'.format(zoom , r.json()))
return georeverse(geocoder, lon, lat, 15)
else:
return { "name": "n/a", "country_code": country_code }
else:
plpy.warning('Failed to received a geo full address %s', r.json())
#plpy.error('Failed to received a geo full address %s', r.json())
return { "name": "unknown", "country_code": "unknown" }
return georeverse(geocoder, lon, lat)
$reverse_geocode_py$ TRANSFORM FOR TYPE jsonb LANGUAGE plpython3u;
-- Description
COMMENT ON FUNCTION
public.reverse_geocode_py_fn

View File

@@ -38,6 +38,7 @@ grant execute on function api.pushover_fn(text,text) to api_anonymous;
grant execute on function api.telegram_fn(text,text) to api_anonymous;
grant execute on function api.telegram_otp_fn(text) to api_anonymous;
--grant execute on function api.generate_otp_fn(text) to api_anonymous;
grant execute on function api.ispublic_fn(integer,public_type) to api_anonymous;
-- authenticator
-- login role
@@ -106,7 +107,7 @@ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO user_role;
-- pg15 feature security_invoker=true,security_barrier=true
GRANT SELECT ON TABLE api.logs_view,api.moorages_view,api.stays_view TO user_role;
GRANT SELECT ON TABLE api.log_view,api.moorage_view,api.stay_view,api.vessels_view TO user_role;
GRANT SELECT ON TABLE api.monitoring_view,api.monitoring_view2,api.monitoring_view3 TO user_role;
GRANT SELECT ON TABLE api.monitoring_view,api.monitoring_view2,api.monitoring_view3,api.explore_view TO user_role;
GRANT SELECT ON TABLE api.monitoring_humidity,api.monitoring_voltage,api.monitoring_temperatures TO user_role;
GRANT SELECT ON TABLE api.stats_moorages_away_view,api.versions_view TO user_role;
GRANT SELECT ON TABLE api.total_info_view TO user_role;

View File

@@ -1 +1 @@
0.4.0
0.4.1

File diff suppressed because one or more lines are too long

View File

@@ -597,12 +597,12 @@ reverse_geocode_py_fn | {"name": "Spain", "country_code": "es"}
Test geoip reverse_geoip_py_fn
-[ RECORD 1 ]----------------------------------------------------------------------------------------------------------------------------------------------
versions_fn | {"api_version" : "0.4.0", "sys_version" : "PostgreSQL 15.4", "timescaledb" : "2.12.2", "postgis" : "3.4.0", "postgrest" : "PostgREST 11.2.1"}
versions_fn | {"api_version" : "0.4.1", "sys_version" : "PostgreSQL 15.4", "timescaledb" : "2.12.2", "postgis" : "3.4.0", "postgrest" : "PostgREST 11.2.2"}
-[ RECORD 1 ]-----------------
api_version | 0.4.0
api_version | 0.4.1
sys_version | PostgreSQL 15.4
timescaledb | 2.12.2
postgis | 3.4.0
postgrest | PostgREST 11.2.1
postgrest | PostgREST 11.2.2