From a542fa3f354c3ea77fd58e465b9b67ae4835f757 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Wed, 11 Sep 2024 15:43:51 -0600 Subject: [PATCH 01/12] Adding intersection tables to create sql --- .../sql_scripts/CVManager_CreateTables.sql | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/resources/sql_scripts/CVManager_CreateTables.sql b/resources/sql_scripts/CVManager_CreateTables.sql index 57a3c3f3..cce621c4 100644 --- a/resources/sql_scripts/CVManager_CreateTables.sql +++ b/resources/sql_scripts/CVManager_CreateTables.sql @@ -459,4 +459,70 @@ CREATE TABLE IF NOT EXISTS public.obu_ota_requests ( CONSTRAINT fk_manufacturer FOREIGN KEY (manufacturer) REFERENCES public.manufacturers(manufacturer_id) ); -CREATE SCHEMA IF NOT EXISTS keycloak; \ No newline at end of file +CREATE SCHEMA IF NOT EXISTS keycloak; + +-- Intersections +CREATE SEQUENCE public.intersections_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersections +( + intersection_id integer NOT NULL DEFAULT nextval('intersections_intersection_id_seq'::regclass), + ref_pt GEOGRAPHY(POINT, 4326) NOT NULL, + intersection_number character varying(128) NOT NULL, + bbox GEOGRAPHY(POLYGON, 4326), + intersection_name character varying(128), + origin_ip inet, + CONSTRAINT intersection_pkey PRIMARY KEY (intersection_id), + CONSTRAINT intersection_intersection_number UNIQUE (intersection_number), +); + +CREATE SEQUENCE public.intersection_organization_intersection_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersection_organization +( + intersection_organization_id integer NOT NULL DEFAULT nextval('intersection_organization_intersection_organization_id_seq'::regclass), + intersection_id integer NOT NULL, + organization_id integer NOT NULL, + CONSTRAINT intersection_organization_pkey PRIMARY KEY (intersection_organization_id), + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.rsu_intersection_rsu_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_intersection +( + rsu_intersection_id integer NOT NULL DEFAULT nextval('rsu_intersection_rsu_intersection_id_seq'::regclass), + rsu_id integer NOT NULL, + intersection_id integer NOT NULL, + CONSTRAINT rsu_intersection_pkey PRIMARY KEY (rsu_intersection_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); \ No newline at end of file From da54572c2c17becb4e8d078a86eabf8c73f7a027 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Tue, 17 Sep 2024 20:37:43 -0600 Subject: [PATCH 02/12] Adding intersection admin pages --- .vscode/settings.json | 1 + .../sql_scripts/CVManager_CreateTables.sql | 5 +- services/api/src/admin_intersection.py | 277 ++++++++++ services/api/src/admin_new_intersection.py | 198 +++++++ services/api/src/main.py | 4 + services/api/src/middleware.py | 2 + webapp/package-lock.json | 5 + webapp/package.json | 1 + webapp/src/EnvironmentVars.tsx | 2 + webapp/src/apis/rsu-api.test.ts | 2 + .../AdminAddIntersection.test.tsx | 21 + .../AdminAddIntersection.tsx | 219 ++++++++ .../adminAddIntersectionSlice.test.ts | 437 +++++++++++++++ .../adminAddIntersectionSlice.tsx | 246 +++++++++ .../AdminEditIntersection.test.tsx | 19 + .../AdminEditIntersection.tsx | 323 +++++++++++ .../adminEditIntersectionSlice.test.ts | 516 ++++++++++++++++++ .../adminEditIntersectionSlice.tsx | 219 ++++++++ .../features/adminIntersectionTab/Admin.css | 294 ++++++++++ .../AdminIntersectionTab.test.tsx | 39 ++ .../AdminIntersectionTab.tsx | 180 ++++++ .../adminIntersectionTabSlice.test.ts | 261 +++++++++ .../adminIntersectionTabSlice.tsx | 143 +++++ .../AdminOrganizationTab.tsx | 10 + .../adminOrganizationTabSlice.test.ts | 2 + .../adminOrganizationTabSlice.tsx | 20 +- .../AdminOrganizationTabIntersection.test.tsx | 21 + .../AdminOrganizationTabIntersection.tsx | 200 +++++++ ...AdminOrganizationTabIntersectionTypes.d.ts | 26 + ...nOrganizationTabIntersection.test.tsx.snap | 72 +++ ...inOrganizationTabIntersectionSlice.test.ts | 347 ++++++++++++ .../adminOrganizationTabIntersectionSlice.tsx | 221 ++++++++ webapp/src/models/Intersection.d.ts | 17 + webapp/src/pages/Admin.tsx | 8 + webapp/src/store.tsx | 9 + webapp/yarn.lock | 70 +++ 36 files changed, 4433 insertions(+), 4 deletions(-) create mode 100644 services/api/src/admin_intersection.py create mode 100644 services/api/src/admin_new_intersection.py create mode 100644 webapp/src/features/adminAddIntersection/AdminAddIntersection.test.tsx create mode 100644 webapp/src/features/adminAddIntersection/AdminAddIntersection.tsx create mode 100644 webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.test.ts create mode 100644 webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx create mode 100644 webapp/src/features/adminEditIntersection/AdminEditIntersection.test.tsx create mode 100644 webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx create mode 100644 webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts create mode 100644 webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx create mode 100644 webapp/src/features/adminIntersectionTab/Admin.css create mode 100644 webapp/src/features/adminIntersectionTab/AdminIntersectionTab.test.tsx create mode 100644 webapp/src/features/adminIntersectionTab/AdminIntersectionTab.tsx create mode 100644 webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.test.ts create mode 100644 webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx create mode 100644 webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.test.tsx create mode 100644 webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.tsx create mode 100644 webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersectionTypes.d.ts create mode 100644 webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap create mode 100644 webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.test.ts create mode 100644 webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.tsx create mode 100644 webapp/src/models/Intersection.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 5920a681..1f610703 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "editor.formatOnSave": true }, "cSpell.words": [ + "bbox", "BOOTSTRAPSERVERS", "cdot", "cimms", diff --git a/resources/sql_scripts/CVManager_CreateTables.sql b/resources/sql_scripts/CVManager_CreateTables.sql index cce621c4..f8a24965 100644 --- a/resources/sql_scripts/CVManager_CreateTables.sql +++ b/resources/sql_scripts/CVManager_CreateTables.sql @@ -308,6 +308,7 @@ CREATE TABLE IF NOT EXISTS public.rsu_organization ON DELETE NO ACTION ); +-- TODO: should combine with intersection table? CREATE TABLE IF NOT EXISTS public.map_info ( ipv4_address inet NOT NULL, @@ -472,13 +473,13 @@ CREATE SEQUENCE public.intersections_intersection_id_seq CREATE TABLE IF NOT EXISTS public.intersections ( intersection_id integer NOT NULL DEFAULT nextval('intersections_intersection_id_seq'::regclass), - ref_pt GEOGRAPHY(POINT, 4326) NOT NULL, intersection_number character varying(128) NOT NULL, + ref_pt GEOGRAPHY(POINT, 4326) NOT NULL, bbox GEOGRAPHY(POLYGON, 4326), intersection_name character varying(128), origin_ip inet, CONSTRAINT intersection_pkey PRIMARY KEY (intersection_id), - CONSTRAINT intersection_intersection_number UNIQUE (intersection_number), + CONSTRAINT intersection_intersection_number UNIQUE (intersection_number) ); CREATE SEQUENCE public.intersection_organization_intersection_organization_id_seq diff --git a/services/api/src/admin_intersection.py b/services/api/src/admin_intersection.py new file mode 100644 index 00000000..f2a9ade6 --- /dev/null +++ b/services/api/src/admin_intersection.py @@ -0,0 +1,277 @@ +import logging +import common.pgquery as pgquery +import sqlalchemy +import admin_new_intersection +import os + + +def get_intersection_data(intersection_id): + query = ( + "SELECT to_jsonb(row) " + "FROM (" + "SELECT intersection_number, ST_X(geography::geometry) AS ref_pt_longitude, ST_Y(geography::geometry) AS ref_pt_latitude, " + "ST_XMin(geography::geometry) AS bbox_longitude_1, ST_YMin(geography::geometry) AS bbox_latitude_1, " + "ST_XMax(geography::geometry) AS bbox_longitude_2, ST_YMax(geography::geometry) AS bbox_latitude_2, " + "intersection_name, origin_ip, " + "org.name AS org_name, rsu.ipv4_address AS rsu_ip " + "FROM public.intersections " + "JOIN public.intersection_organization AS ro ON ro.intersection_id = intersections.intersection_id " + "JOIN public.organizations AS org ON org.organization_id = ro.organization_id " + "JOIN public.rsu_intersection AS rsu_intersection ON ro.intersection_id = intersections.intersection_id " + "JOIN public.rsus AS rsu ON rsu.rsu_id = rsu_intersection.rsu_id" + ) + if intersection_id != "all": + query += f" WHERE intersection_number = '{intersection_id}'" + query += ") as row" + + data = pgquery.query_db(query) + + intersection_dict = {} + for row in data: + row = dict(row[0]) + if str(row["intersection_number"]) not in intersection_dict: + intersection_dict[str(row["intersection_number"])] = { + "intersection_id": str(row["intersection_number"]), + "ref_pt": { + "latitude": row["ref_pt_latitude"], + "longitude": row["ref_pt_longitude"], + }, + "bbox": { + "latitude1": row["bbox_latitude_1"], + "longitude1": row["bbox_longitude_1"], + "latitude2": row["bbox_latitude_2"], + "longitude2": row["bbox_longitude_2"], + }, + "intersection_name": row["intersection_name"], + "origin_ip": row["origin_ip"], + "organizations": [], + "rsus": [], + } + intersection_dict[str(row["intersection_number"])]["organizations"].append( + row["org_name"] + ) + intersection_dict[str(row["intersection_number"])]["rsus"].append(row["rsu_ip"]) + + intersection_list = list(intersection_dict.values()) + # If list is empty and a single Intersection was requested, return empty object + if len(intersection_list) == 0 and intersection_id != "all": + return {} + # If list is not empty and a single Intersection was requested, return the first index of the list + elif len(intersection_list) == 1 and intersection_id != "all": + return intersection_list[0] + else: + return intersection_list + + +def get_modify_intersection_data(intersection_id): + modify_intersection_obj = {} + modify_intersection_obj["intersection_data"] = get_intersection_data( + intersection_id + ) + if intersection_id != "all": + modify_intersection_obj["allowed_selections"] = ( + admin_new_intersection.get_allowed_selections() + ) + return modify_intersection_obj + + +def modify_intersection(intersection_spec): + # Check for special characters for potential SQL injection + if not admin_new_intersection.check_safe_input(intersection_spec): + return { + "message": "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" + }, 500 + + try: + # Modify the existing Intersection data + query = ( + "UPDATE public.intersections SET " + f"ref_pt=ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})'), " + f"bbox=ST_MakeEnvelope({str(intersection_spec['bbox']['longitude1'])},{str(intersection_spec['bbox']['latitude1'])},{str(intersection_spec['bbox']['longitude2'])},{str(intersection_spec['bbox']['latitude2'])}), " + f"intersection_name='{intersection_spec['intersection_name']}', " + f"origin_ip='{intersection_spec['origin_ip']}'" + f"WHERE intersection_number='{intersection_spec['intersection_id']}'" + ) + pgquery.write_db(query) + + # Add the intersection-to-organization relationships for the organizations to add + if len(intersection_spec["organizations_to_add"]) > 0: + org_add_query = "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES" + for organization in intersection_spec["organizations_to_add"]: + org_add_query += ( + " (" + f"(SELECT intersection_id FROM public.intersections WHERE ipv4_address = '{intersection_spec['intersection_id']}'), " + f"(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" + ")," + ) + org_add_query = org_add_query[:-1] + pgquery.write_db(org_add_query) + + # Remove the intersection-to-organization relationships for the organizations to remove + for organization in intersection_spec["organizations_to_remove"]: + org_remove_query = ( + "DELETE FROM public.intersection_organization WHERE " + f"intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}') " + f"AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" + ) + pgquery.write_db(org_remove_query) + + # Add the rsu-to-intersection relationships for the rsus to add + if len(intersection_spec["rsus_to_add"]) > 0: + rsu_add_query = ( + "INSERT INTO public.rsu_intersection(rsu_id, intersection_id) VALUES" + ) + for ip in intersection_spec["rsus_to_add"]: + rsu_add_query += ( + " (" + f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{ip}'), " + f"(SELECT intersection_id FROM public.intersections WHERE ipv4_address = '{intersection_spec['intersection_id']}')" + ")," + ) + rsu_add_query = rsu_add_query[:-1] + pgquery.write_db(rsu_add_query) + + # Remove the rsu-to-intersection relationships for the rsus to remove + for ip in intersection_spec["rsus_to_remove"]: + rsu_remove_query = ( + "DELETE FROM public.rsu_intersection WHERE " + f"intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}') " + f"AND rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{ip}')" + ) + pgquery.write_db(rsu_remove_query) + except sqlalchemy.exc.IntegrityError as e: + failed_value = e.orig.args[0]["D"] + failed_value = failed_value.replace("(", '"') + failed_value = failed_value.replace(")", '"') + failed_value = failed_value.replace("=", " = ") + logging.error(f"Exception encountered: {failed_value}") + return {"message": failed_value}, 500 + except Exception as e: + logging.error(f"Exception encountered: {e}") + return {"message": "Encountered unknown issue"}, 500 + + return {"message": "Intersection successfully modified"}, 200 + + +def delete_intersection(intersection_id): + # Delete Intersection to Organization relationships + org_remove_query = ( + "DELETE FROM public.intersection_organization WHERE " + f"intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_id}')" + ) + pgquery.write_db(org_remove_query) + + rsu_intersection_remove_query = ( + "DELETE FROM public.rsu_intersection WHERE " + f"intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_id}')" + ) + pgquery.write_db(rsu_intersection_remove_query) + + # Delete Intersection data + intersection_remove_query = ( + "DELETE FROM public.intersections WHERE " + f"intersection_number = '{intersection_id}'" + ) + pgquery.write_db(intersection_remove_query) + + return {"message": "Intersection successfully deleted"} + + +# REST endpoint resource class +from flask import request, abort +from flask_restful import Resource +from marshmallow import Schema, fields + + +class AdminIntersectionGetAllSchema(Schema): + intersection_id = fields.Str(required=True) + + +class AdminIntersectionGetDeleteSchema(Schema): + intersection_id = fields.Str(required=True) + + +class GeoPositionSchema(Schema): + latitude = fields.Decimal(required=True) + longitude = fields.Decimal(required=True) + + +class GeoPolygonSchema(Schema): + latitude1 = fields.Decimal(required=True) + longitude1 = fields.Decimal(required=True) + latitude2 = fields.Decimal(required=True) + longitude2 = fields.Decimal(required=True) + + +class AdminIntersectionPatchSchema(Schema): + intersection_id = fields.Integer(required=True) + ref_pt = fields.Nested(GeoPositionSchema, required=True) + bbox = fields.Nested(GeoPolygonSchema, required=False) + intersection_name = fields.String(required=False) + origin_ip = fields.IPv4(required=False) + organizations_to_add = fields.List(fields.String(), required=True) + organizations_to_remove = fields.List(fields.String(), required=True) + rsus_to_add = fields.List(fields.String(), required=True) + rsus_to_remove = fields.List(fields.String(), required=True) + + +class AdminIntersection(Resource): + options_headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "GET,PATCH,DELETE", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + def get(self): + logging.debug("AdminIntersection GET requested") + schema = AdminIntersectionGetAllSchema() + errors = schema.validate(request.args) + if errors: + logging.error(errors) + abort(400, errors) + + # If intersection_id is "all", allow without checking for an IPv4 address + if request.args["intersection_id"] != "all": + schema = AdminIntersectionGetDeleteSchema() + errors = schema.validate(request.args) + if errors: + logging.error(errors) + abort(400, errors) + + return ( + get_modify_intersection_data(request.args["intersection_id"]), + 200, + self.headers, + ) + + def patch(self): + logging.debug("AdminIntersection PATCH requested") + # Check for main body values + schema = AdminIntersectionPatchSchema() + errors = schema.validate(request.json) + if errors: + logging.error(str(errors)) + abort(400, str(errors)) + + data, code = modify_intersection(request.json) + return (data, code, self.headers) + + def delete(self): + logging.debug("AdminIntersection DELETE requested") + schema = AdminIntersectionGetDeleteSchema() + errors = schema.validate(request.args) + if errors: + logging.error(errors) + abort(400, errors) + + return (delete_intersection(request.args["intersection_id"]), 200, self.headers) diff --git a/services/api/src/admin_new_intersection.py b/services/api/src/admin_new_intersection.py new file mode 100644 index 00000000..c82616e3 --- /dev/null +++ b/services/api/src/admin_new_intersection.py @@ -0,0 +1,198 @@ +import logging +import common.pgquery as pgquery +import sqlalchemy +import os + + +def query_and_return_list(query): + data = pgquery.query_db(query) + return_list = [] + for row in data: + return_list.append(" ".join(row)) + return return_list + + +def get_allowed_selections(): + allowed = {} + + organizations_query = "SELECT name FROM public.organizations ORDER BY name ASC" + allowed["organizations"] = query_and_return_list(organizations_query) + + rsus_query = "SELECT ipv4_address::text AS ipv4_address FROM public.rsus ORDER BY ipv4_address ASC" + allowed["rsus"] = query_and_return_list(rsus_query) + + return allowed + + +def check_safe_input(intersection_spec): + special_characters = "!\"#$%&'()*+,./:;<=>?@[\\]^`{|}~" + unchecked_fields = [ + "origin_ip", + "rsus", + "rsus_to_add", + "rsus_to_remove", + "latitude", + "longitude", + "latitude1", + "longitude1", + "latitude2", + "longitude2", + ] + for k, value in intersection_spec.items(): + if isinstance(value, dict): + if not check_safe_input(value): + return False + elif isinstance(value, list): + if not all(check_safe_input({k: v}) for v in value): + return False + else: + if (k in unchecked_fields) or (value is None): + return True + if any(c in special_characters for c in str(value)) or "--" in str(value): + return False + return True + + +def add_intersection(intersection_spec): + # Check for special characters for potential SQL injection + if not check_safe_input(intersection_spec): + return { + "message": "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" + }, 500 + + try: + query = "INSERT INTO public.intersections(intersection_number, ref_pt" + + # Add optional fields if they are present + if "bbox" in intersection_spec: + query += ", bbox" + if "intersection_name" in intersection_spec: + query += ", intersection_name" + if "origin_ip" in intersection_spec: + query += ", origin_ip" + + # Close the column list and start the VALUES clause + query += ") VALUES (" + + # Add the mandatory fields + query += ( + f"'{intersection_spec['intersection_id']}', " + f"ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})')" + ) + + # Add optional values if they are present + if "bbox" in intersection_spec: + query += ( + f", ST_MakeEnvelope({str(intersection_spec['bbox']['longitude1'])}," + f"{str(intersection_spec['bbox']['latitude1'])}," + f"{str(intersection_spec['bbox']['longitude2'])}," + f"{str(intersection_spec['bbox']['latitude2'])})" + ) + if "intersection_name" in intersection_spec: + query += f", '{intersection_spec['intersection_name']}'" + if "origin_ip" in intersection_spec: + query += f", '{intersection_spec['origin_ip']}'" + + # Close the VALUES clause + query += ")" + pgquery.write_db(query) + + org_query = "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES" + for organization in intersection_spec["organizations"]: + org_query += ( + " (" + f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}'), " + f"(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" + ")," + ) + org_query = org_query[:-1] + pgquery.write_db(org_query) + if intersection_spec["rsus"]: + rsu_intersection_query = ( + "INSERT INTO public.rsu_intersection(rsu_id, intersection_id) VALUES" + ) + for rsu_ip in intersection_spec["rsus"]: + rsu_intersection_query += ( + " (" + f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{rsu_ip}'), " + f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}')" + ")," + ) + rsu_intersection_query = rsu_intersection_query[:-1] + pgquery.write_db(rsu_intersection_query) + except sqlalchemy.exc.IntegrityError as e: + failed_value = e.orig.args[0]["D"] + failed_value = failed_value.replace("(", '"') + failed_value = failed_value.replace(")", '"') + failed_value = failed_value.replace("=", " = ") + logging.error(f"Exception encountered: {failed_value}") + return {"message": failed_value}, 500 + except Exception as e: + logging.error(f"Exception encountered: {e}") + return {"message": "Encountered unknown issue"}, 500 + + return {"message": "New Intersection successfully added"}, 200 + + +# REST endpoint resource class +from flask import request, abort +from flask_restful import Resource +from marshmallow import Schema, fields, validate + + +class GeoPositionSchema(Schema): + latitude = fields.Decimal(required=True) + longitude = fields.Decimal(required=True) + + +class GeoPolygonSchema(Schema): + latitude1 = fields.Decimal(required=True) + longitude1 = fields.Decimal(required=True) + latitude2 = fields.Decimal(required=True) + longitude2 = fields.Decimal(required=True) + + +class AdminNewIntersectionSchema(Schema): + intersection_id = fields.Integer(required=True) + ref_pt = fields.Nested(GeoPositionSchema, required=True) + organizations = fields.List( + fields.String(), required=True, validate=validate.Length(min=1) + ) + bbox = fields.Nested(GeoPolygonSchema, required=False) + intersection_name = fields.String(required=False) + origin_ip = fields.IPv4(required=False) + rsus = fields.List(fields.IPv4(), required=True) + + +class AdminNewIntersection(Resource): + options_headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "GET,POST", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + def get(self): + logging.debug("AdminNewIntersection GET requested") + return (get_allowed_selections(), 200, self.headers) + + def post(self): + logging.debug("AdminNewIntersection POST requested") + # Check for main body values + schema = AdminNewIntersectionSchema() + errors = schema.validate(request.json) + if errors: + logging.error(str(errors)) + abort(400, str(errors)) + + data, code = add_intersection(request.json) + return (data, code, self.headers) diff --git a/services/api/src/main.py b/services/api/src/main.py index b15f6d5d..38e4cd29 100644 --- a/services/api/src/main.py +++ b/services/api/src/main.py @@ -22,6 +22,8 @@ from rsu_ssm_srm import RsuSsmSrmData from admin_new_rsu import AdminNewRsu from admin_rsu import AdminRsu +from admin_new_intersection import AdminNewIntersection +from admin_intersection import AdminIntersection from admin_new_user import AdminNewUser from admin_user import AdminUser from admin_new_org import AdminNewOrg @@ -54,6 +56,8 @@ api.add_resource(RsuSsmSrmData, "/rsu-ssm-srm-data") api.add_resource(AdminNewRsu, "/admin-new-rsu") api.add_resource(AdminRsu, "/admin-rsu") +api.add_resource(AdminNewIntersection, "/admin-new-intersection") +api.add_resource(AdminIntersection, "/admin-intersection") api.add_resource(AdminNewUser, "/admin-new-user") api.add_resource(AdminUser, "/admin-user") api.add_resource(AdminNewOrg, "/admin-new-org") diff --git a/services/api/src/middleware.py b/services/api/src/middleware.py index 97858c4c..818f60b3 100644 --- a/services/api/src/middleware.py +++ b/services/api/src/middleware.py @@ -54,6 +54,8 @@ def get_user_role(token): "/rsu-ssm-srm-data": False, "/admin-new-rsu": False, "/admin-rsu": False, + "/admin-new-intersection": False, + "/admin-intersection": False, "/admin-new-user": False, "/admin-user": False, "/admin-new-org": False, diff --git a/webapp/package-lock.json b/webapp/package-lock.json index b2481013..d95fa94d 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -31,6 +31,7 @@ "abort-controller": "^3.0.0", "axios": "^1.6.5", "color": "^4.2.3", + "cv-manager": "file:", "dayjs": "^1.11.7", "env-cmd": "^10.1.0", "fbjs": "^3.0.4", @@ -11331,6 +11332,10 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" }, + "node_modules/cv-manager": { + "resolved": "", + "link": true + }, "node_modules/d3-array": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", diff --git a/webapp/package.json b/webapp/package.json index d744e430..f617515a 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -26,6 +26,7 @@ "abort-controller": "^3.0.0", "axios": "^1.6.5", "color": "^4.2.3", + "cv-manager": "file:", "dayjs": "^1.11.7", "env-cmd": "^10.1.0", "fbjs": "^3.0.4", diff --git a/webapp/src/EnvironmentVars.tsx b/webapp/src/EnvironmentVars.tsx index ba6f7b2e..a46090e5 100644 --- a/webapp/src/EnvironmentVars.tsx +++ b/webapp/src/EnvironmentVars.tsx @@ -56,6 +56,8 @@ class EnvironmentVars { static authEndpoint = `${this.getBaseApiUrl()}/user-auth` static adminAddRsu = `${this.getBaseApiUrl()}/admin-new-rsu` static adminRsu = `${this.getBaseApiUrl()}/admin-rsu` + static adminAddIntersection = `${this.getBaseApiUrl()}/admin-new-intersection` + static adminIntersection = `${this.getBaseApiUrl()}/admin-intersection` static adminAddUser = `${this.getBaseApiUrl()}/admin-new-user` static adminUser = `${this.getBaseApiUrl()}/admin-user` static adminNotification = `${this.getBaseApiUrl()}/admin-notification` diff --git a/webapp/src/apis/rsu-api.test.ts b/webapp/src/apis/rsu-api.test.ts index 7583f4f8..986a26e2 100644 --- a/webapp/src/apis/rsu-api.test.ts +++ b/webapp/src/apis/rsu-api.test.ts @@ -16,6 +16,8 @@ beforeEach(() => { EnvironmentVars.authEndpoint = 'REACT_APP_ENV/user-auth' EnvironmentVars.adminAddRsu = 'REACT_APP_ENV/admin-new-rsu' EnvironmentVars.adminRsu = 'REACT_APP_ENV/admin-rsu' + EnvironmentVars.adminAddRsu = 'REACT_APP_ENV/admin-new-intersection' + EnvironmentVars.adminRsu = 'REACT_APP_ENV/admin-intersection' EnvironmentVars.adminAddUser = 'REACT_APP_ENV/admin-new-user' EnvironmentVars.adminUser = 'REACT_APP_ENV/admin-user' EnvironmentVars.adminAddOrg = 'REACT_APP_ENV/admin-new-org' diff --git a/webapp/src/features/adminAddIntersection/AdminAddIntersection.test.tsx b/webapp/src/features/adminAddIntersection/AdminAddIntersection.test.tsx new file mode 100644 index 00000000..f8e45785 --- /dev/null +++ b/webapp/src/features/adminAddIntersection/AdminAddIntersection.test.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { render } from '@testing-library/react' +import AdminAddIntersection from './AdminAddIntersection' +import { Provider } from 'react-redux' +import { setupStore } from '../../store' +import { replaceChaoticIds } from '../../utils/test-utils' +import { BrowserRouter, Routes, Route } from 'react-router-dom' + +it('should take a snapshot', () => { + const { container } = render( + + + + } /> + + + + ) + + expect(replaceChaoticIds(container)).toMatchSnapshot() +}) diff --git a/webapp/src/features/adminAddIntersection/AdminAddIntersection.tsx b/webapp/src/features/adminAddIntersection/AdminAddIntersection.tsx new file mode 100644 index 00000000..80ae6ea9 --- /dev/null +++ b/webapp/src/features/adminAddIntersection/AdminAddIntersection.tsx @@ -0,0 +1,219 @@ +import React, { useEffect, useState } from 'react' +import { Form } from 'react-bootstrap' +import { useForm } from 'react-hook-form' +import { Multiselect } from 'react-widgets' +import { + selectOrganizations, + selectSelectedOrganizations, + selectRsus, + selectSelectedRsus, + selectSubmitAttempt, + + // actions + getIntersectionCreationData, + submitForm, + updateSelectedOrganizations, + updateSelectedRsus, +} from './adminAddIntersectionSlice' +import { useSelector, useDispatch } from 'react-redux' + +import '../adminIntersectionTab/Admin.css' +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' +import { RootState } from '../../store' +import toast from 'react-hot-toast' +import { useNavigate } from 'react-router-dom' +import Dialog from '@mui/material/Dialog' +import { DialogActions, DialogContent, DialogTitle } from '@mui/material' + +export type AdminAddIntersectionForm = { + intersection_id: string + ref_pt: { + latitude: string + longitude: string + } + bbox?: { + latitude1: string + longitude1: string + latitude2: string + longitude2: string + } + intersection_name?: string + origin_ip?: string + organizations: string[] + rsus: string[] +} + +const AdminAddIntersection = () => { + const dispatch: ThunkDispatch = useDispatch() + + const organizations = useSelector(selectOrganizations) + const selectedOrganizations = useSelector(selectSelectedOrganizations) + const rsus = useSelector(selectRsus) + const selectedRsus = useSelector(selectSelectedRsus) + const submitAttempt = useSelector(selectSubmitAttempt) + + const [open, setOpen] = useState(true) + const navigate = useNavigate() + + const notifySuccess = (message: string) => toast.success(message) + const notifyError = (message: string) => toast.error(message) + + const handleFormSubmit = (data: AdminAddIntersectionForm) => { + dispatch(submitForm({ data, reset })).then((data: any) => { + data.payload.success + ? notifySuccess(data.payload.message) + : notifyError('Failed to add Intersection due to error: ' + data.payload.message) + }) + setOpen(false) + navigate('/dashboard/admin/intersections') + } + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm() + + useEffect(() => { + dispatch(getIntersectionCreationData()) + }, [dispatch]) + + return ( + + Add Intersection + +
handleFormSubmit(data))} + style={{ fontFamily: 'Arial, Helvetica, sans-serif' }} + > + + Intersection ID + + {errors.intersection_id &&

{errors.intersection_id.message}

} +
+ + + Reference Point Latitude + + {errors.ref_pt?.latitude &&

{errors.ref_pt.latitude.message}

} +
+ + + Reference Point Longitude + + {errors.ref_pt?.longitude &&

{errors.ref_pt.longitude.message}

} +
+ + + Intersection Name + + {errors.intersection_name &&

{errors.intersection_name.message}

} +
+ + + Origin IP + + {errors.origin_ip &&

{errors.origin_ip.message}

} +
+ + + Organization + { + dispatch(updateSelectedOrganizations(value)) + }} + /> + {selectedOrganizations.length === 0 && submitAttempt && ( +

+ Must select an organization +

+ )} +
+ + + RSUs + { + dispatch(updateSelectedRsus(value)) + }} + /> + +
+
+ + + + +
+ ) +} + +export default AdminAddIntersection diff --git a/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.test.ts b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.test.ts new file mode 100644 index 00000000..28a86105 --- /dev/null +++ b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.test.ts @@ -0,0 +1,437 @@ +import reducer from './adminAddIntersectionSlice' +import { + // async thunks + getIntersectionCreationData, + createIntersection, + submitForm, + + // functions + updateApiJson, + checkForm, + updateJson, + + // reducers + updateSelectedOrganizations, + updateSelectedRsus, + resetForm, + + // selectors + selectApiData, + selectOrganizations, + selectRsus, + selectSelectedOrganizations, + selectSelectedRsus, + selectSubmitAttempt, + selectLoading, +} from './adminAddIntersectionSlice' +import apiHelper from '../../apis/api-helper' +import EnvironmentVars from '../../EnvironmentVars' +import { RootState } from '../../store' + +describe('admin add Intersection reducer', () => { + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + loading: false, + value: { + apiData: {}, + selectedOrganizations: [], + selectedRsus: [], + submitAttempt: false, + }, + }) + }) +}) + +describe('async thunks', () => { + const initialState: RootState['adminAddIntersection'] = { + loading: null, + value: { + apiData: null, + selectedOrganizations: null, + selectedRsus: null, + submitAttempt: null, + }, + } + + beforeAll(() => { + jest.mock('../../apis/api-helper') + }) + + afterAll(() => { + jest.unmock('../../apis/api-helper') + }) + + describe('getIntersectionCreationData', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const action = getIntersectionCreationData() + + const apiJson = { data: 'data' } + apiHelper._getData = jest.fn().mockReturnValue('_getData_response') + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual(undefined) + expect(apiHelper._getData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminAddIntersection, + token: 'token', + additional_headers: { 'Content-Type': 'application/json' }, + }) + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminAddIntersection/getIntersectionCreationData/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + const apiData = 'apiData' + const state = reducer(initialState, { + type: 'adminAddIntersection/getIntersectionCreationData/fulfilled', + payload: apiData, + }) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, apiData }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminAddIntersection/getIntersectionCreationData/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('createIntersection', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const json = { data: 'data' } as any + + let reset = jest.fn() + let action = createIntersection({ json, reset }) + global.setTimeout = jest.fn((cb) => cb()) as any + try { + apiHelper._postData = jest.fn().mockReturnValue({ status: 200, message: 'message' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: true, message: '' }) + expect(apiHelper._postData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminAddIntersection, + token: 'token', + body: JSON.stringify(json), + }) + expect(dispatch).toHaveBeenCalledTimes(2 + 2) + expect(reset).toHaveBeenCalledTimes(1) + } catch (e) { + ;(global.setTimeout as any).mockClear() + throw e + } + + // Error Code Other + dispatch = jest.fn() + reset = jest.fn() + action = createIntersection({ json, reset }) + global.setTimeout = jest.fn((cb) => cb()) as any + try { + apiHelper._postData = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: false, message: 'message' }) + expect(apiHelper._postData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminAddIntersection, + token: 'token', + body: JSON.stringify(json), + }) + expect(setTimeout).not.toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + expect(reset).not.toHaveBeenCalled() + } catch (e) { + ;(global.setTimeout as any).mockClear() + throw e + } + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminAddIntersection/createIntersection/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + + let state = reducer(initialState, { + type: 'adminAddIntersection/createIntersection/fulfilled', + payload: { message: 'message', success: true }, + }) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + + // Error Case + + state = reducer(initialState, { + type: 'adminAddIntersection/createIntersection/fulfilled', + payload: { message: 'message', success: false }, + }) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminAddIntersection/createIntersection/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('submitForm', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + let getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + adminAddIntersection: { + value: { + selectedOrganizations: ['org1'], + selectedRsus: ['rsu1'], + }, + }, + }) + const data = { data: 'data' } as any + + let reset = jest.fn() + let action = submitForm({ data, reset }) + let resp = await action(dispatch, getState, undefined) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + + // invalid checkForm + + dispatch = jest.fn() + getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + adminAddIntersection: { + value: { + selectedOrganizations: [], + selectedRsus: [], + }, + }, + }) + action = submitForm({ data, reset }) + resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ + message: 'Please fill out all required fields', + submitAttempt: true, + success: false, + }) + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + }) + + it('Updates the state correctly fulfilled', async () => { + const submitAttempt = 'submitAttempt' + + const state = reducer(initialState, { + type: 'adminAddIntersection/submitForm/fulfilled', + payload: { submitAttempt: 'submitAttempt' }, + }) + + expect(state).toEqual({ + ...initialState, + value: { ...initialState.value, submitAttempt }, + }) + }) + }) +}) + +describe('functions', () => { + it('updateApiJson', async () => { + // write test for updateApiJson + const apiJson = { + organizations: ['org1', 'org2'], + rsus: ['rsu1', 'rsu2'], + } + + const expected = { + organizations: [ + { id: 0, name: 'org1' }, + { id: 1, name: 'org2' }, + ], + rsus: [ + { id: 0, name: 'rsu1' }, + { id: 1, name: 'rsu2' }, + ], + } + expect(updateApiJson(apiJson)).toEqual(expected) + }) + + it('checkForm all invalid', async () => { + expect( + checkForm({ + value: { + selectedOrganizations: [], + selectedRsus: [], + }, + } as any) + ).toEqual(false) + }) + + it('checkForm all valid', async () => { + expect( + checkForm({ + value: { + selectedOrganizations: ['org1'], + selectedRsus: ['rsu1'], + }, + } as any) + ).toEqual(true) + }) + + it('updateJson', async () => { + const data = { + ref_pt: { + latitude: '39.7392', + longitude: '-104.9903', + }, + intersection_name: 'a', + } as any + + const state = { + value: { + selectedOrganizations: [{ name: 'org1' }], + selectedRsus: [{ name: 'rsu1' }], + }, + } as any + + const expected = { + ref_pt: { + latitude: 39.7392, + longitude: -104.9903, + }, + intersection_name: 'a', + organizations: ['org1'], + rsus: ['rsu1'], + } + + expect(updateJson(data, state)).toEqual(expected) + }) +}) + +describe('reducers', () => { + const initialState: RootState['adminAddIntersection'] = { + loading: null, + value: { + apiData: null, + selectedOrganizations: null, + selectedRsus: null, + submitAttempt: null, + }, + } + + it('updateSelectedOrganizations reducer updates state correctly', async () => { + const selectedOrganizations = [{ id: 1, name: 'selectedOrganizations' }] + expect(reducer(initialState, updateSelectedOrganizations(selectedOrganizations))).toEqual({ + ...initialState, + value: { ...initialState.value, selectedOrganizations }, + }) + }) + + it('updateSelectedRsus reducer updates state correctly', async () => { + const selectedRsus = [{ id: 1, name: 'selectedRsus' }] + expect(reducer(initialState, updateSelectedRsus(selectedRsus))).toEqual({ + ...initialState, + value: { ...initialState.value, selectedRsus }, + }) + }) + + it('resetForm reducer updates state correctly', async () => { + const selectedOrganizations = [] as any + const selectedRsus = [] as any + expect(reducer(initialState, resetForm(selectedOrganizations))).toEqual({ + ...initialState, + value: { + ...initialState.value, + selectedOrganizations, + selectedRsus, + }, + }) + }) +}) + +describe('selectors', () => { + const initialState = { + loading: 'loading', + value: { + apiData: { + organizations: ['org1', 'org2'], + rsus: ['rsu1', 'rsu2'], + }, + selectedOrganizations: 'selectedOrganizations', + selectedRsus: 'selectedRsus', + submitAttempt: 'submitAttempt', + }, + } + const state = { adminAddIntersection: initialState } as any + + it('selectors return the correct value', async () => { + expect(selectLoading(state)).toEqual('loading') + + expect(selectApiData(state)).toEqual(initialState.value.apiData) + expect(selectOrganizations(state)).toEqual(initialState.value.apiData.organizations) + expect(selectRsus(state)).toEqual(initialState.value.apiData.rsus) + expect(selectSelectedOrganizations(state)).toEqual('selectedOrganizations') + expect(selectSelectedRsus(state)).toEqual('selectedRsus') + expect(selectSubmitAttempt(state)).toEqual('submitAttempt') + }) + + it('selectors return the correct value defaults', async () => { + initialState.value.apiData = undefined + expect(selectApiData(state)).toEqual(undefined) + expect(selectOrganizations(state)).toEqual([]) + expect(selectRsus(state)).toEqual([]) + }) +}) diff --git a/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx new file mode 100644 index 00000000..ae9560a9 --- /dev/null +++ b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx @@ -0,0 +1,246 @@ +import { PayloadAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit' +import { selectToken } from '../../generalSlices/userSlice' +import EnvironmentVars from '../../EnvironmentVars' +import apiHelper from '../../apis/api-helper' +import { updateTableData as updateIntersectionTableData } from '../adminIntersectionTab/adminIntersectionTabSlice' +import { RootState } from '../../store' +import { AdminAddIntersectionForm } from './AdminAddIntersection' + +export type AdminIntersectionCreationInfo = { + organizations: string[] + rsus: string[] +} + +export type AdminIntersectionKeyedCreationInfo = { + organizations: { id: number; name: string }[] + rsus: { id: number; name: string }[] +} + +export type AdminIntersectionCreationBody = { + intersection_id: string + ref_pt: { + latitude: string + longitude: string + } + bbox?: { + latitude1: string + longitude1: string + latitude2: string + longitude2: string + } + intersection_name?: string + origin_ip?: string + organizations: string[] + rsus: string[] +} + +const initialState = { + apiData: {} as AdminIntersectionKeyedCreationInfo, + selectedOrganizations: [] as AdminIntersectionKeyedCreationInfo['organizations'], + selectedRsus: [] as AdminIntersectionKeyedCreationInfo['rsus'], + submitAttempt: false, +} + +export const updateApiJson = (apiJson: AdminIntersectionCreationInfo): AdminIntersectionKeyedCreationInfo => { + if (Object.keys(apiJson).length !== 0) { + let keyedApiJson = {} as AdminIntersectionKeyedCreationInfo + + let data = [] + for (let i = 0; i < apiJson['organizations'].length; i++) { + let value = apiJson['organizations'][i] + let temp = { id: i, name: value } + data.push(temp) + } + keyedApiJson.organizations = data + + data = [] + for (let i = 0; i < apiJson['rsus'].length; i++) { + let value = apiJson['rsus'][i] + let temp = { id: i, name: value.replace('/32', '') } + data.push(temp) + } + keyedApiJson.rsus = data + + return keyedApiJson + } +} + +export const checkForm = (state: RootState['adminAddIntersection']) => { + if (state.value.selectedOrganizations.length === 0) { + return false + } else { + return true + } +} + +export const updateJson = ( + data: AdminAddIntersectionForm, + state: RootState['adminAddIntersection'] +): AdminIntersectionCreationBody => { + const json: any = data + // creating geo_position object from latitudes and longitude + json.intersection_id = Number(data.intersection_id) + json.ref_pt = { + latitude: Number(data.ref_pt.latitude), + longitude: Number(data.ref_pt.longitude), + } + if (data.bbox?.latitude1 && data.bbox?.longitude1 && data.bbox?.latitude2 && data.bbox?.longitude2) { + json.bbox = { + latitude1: Number(data.bbox.latitude1), + longitude1: Number(data.bbox.longitude1), + latitude2: Number(data.bbox.latitude2), + longitude2: Number(data.bbox.longitude2), + } + } + + let tempOrganizations = [] + for (var i = 0; i < state.value.selectedOrganizations.length; i++) { + tempOrganizations.push(state.value.selectedOrganizations[i].name) + } + + json.organizations = tempOrganizations + + let tempRsus = [] + for (var i = 0; i < state.value.selectedRsus.length; i++) { + tempRsus.push(state.value.selectedRsus[i].name) + } + + json.rsus = tempRsus + + return json +} + +export const getIntersectionCreationData = createAsyncThunk( + 'adminAddIntersection/getIntersectionCreationData', + async (_, { getState }) => { + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = (await apiHelper._getData({ + url: EnvironmentVars.adminAddIntersection, + token, + additional_headers: { 'Content-Type': 'application/json' }, + })) as AdminIntersectionCreationInfo + return updateApiJson(data) + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const createIntersection = createAsyncThunk( + 'adminAddIntersection/createIntersection', + async (payload: { json: AdminIntersectionCreationBody; reset: () => void }, { getState, dispatch }) => { + const { json, reset } = payload + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await apiHelper._postData({ + url: EnvironmentVars.adminAddIntersection, + body: JSON.stringify(json), + token, + }) + switch (data.status) { + case 200: + dispatch(adminAddIntersectionSlice.actions.resetForm()) + dispatch(updateIntersectionTableData()) + reset() + return { success: true, message: '' } + default: + return { success: false, message: data.message } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const submitForm = createAsyncThunk( + 'adminAddIntersection/submitForm', + async (payload: { data: AdminAddIntersectionForm; reset: () => void }, { getState, dispatch }) => { + const { data, reset } = payload + + const currentState = getState() as RootState + if (checkForm(currentState.adminAddIntersection)) { + let json = updateJson(data, currentState.adminAddIntersection) + let res = await dispatch(createIntersection({ json, reset })) + if ((res.payload as any).success) { + return { submitAttempt: false, success: true, message: 'Intersection Created Successfully' } + } else { + return { submitAttempt: false, success: false, message: (res.payload as any).message } + } + } else { + return { submitAttempt: true, success: false, message: 'Please fill out all required fields' } + } + } +) + +export const adminAddIntersectionSlice = createSlice({ + name: 'adminAddIntersection', + initialState: { + loading: false, + value: initialState, + }, + reducers: { + updateSelectedOrganizations: ( + state, + action: PayloadAction< + { + id: number + name: string + }[] + > + ) => { + state.value.selectedOrganizations = action.payload + }, + updateSelectedRsus: ( + state, + action: PayloadAction< + { + id: number + name: string + }[] + > + ) => { + state.value.selectedRsus = action.payload + }, + resetForm: (state) => { + state.value.selectedOrganizations = [] + state.value.selectedRsus = [] + }, + }, + extraReducers: (builder) => { + builder + .addCase(getIntersectionCreationData.pending, (state) => { + state.loading = true + }) + .addCase(getIntersectionCreationData.fulfilled, (state, action) => { + state.loading = false + state.value.apiData = action.payload + }) + .addCase(getIntersectionCreationData.rejected, (state) => { + state.loading = false + }) + .addCase(createIntersection.pending, (state) => { + state.loading = true + }) + .addCase(createIntersection.fulfilled, (state, action) => { + state.loading = false + }) + .addCase(createIntersection.rejected, (state) => { + state.loading = false + }) + .addCase(submitForm.fulfilled, (state, action) => { + state.value.submitAttempt = action.payload.submitAttempt + }) + }, +}) + +export const { resetForm, updateSelectedOrganizations, updateSelectedRsus } = adminAddIntersectionSlice.actions + +export const selectApiData = (state: RootState) => state.adminAddIntersection.value.apiData +export const selectOrganizations = (state: RootState) => state.adminAddIntersection.value.apiData?.organizations ?? [] +export const selectRsus = (state: RootState) => state.adminAddIntersection.value.apiData?.rsus ?? [] + +export const selectSelectedOrganizations = (state: RootState) => state.adminAddIntersection.value.selectedOrganizations +export const selectSelectedRsus = (state: RootState) => state.adminAddIntersection.value.selectedRsus +export const selectSubmitAttempt = (state: RootState) => state.adminAddIntersection.value.submitAttempt +export const selectLoading = (state: RootState) => state.adminAddIntersection.loading + +export default adminAddIntersectionSlice.reducer diff --git a/webapp/src/features/adminEditIntersection/AdminEditIntersection.test.tsx b/webapp/src/features/adminEditIntersection/AdminEditIntersection.test.tsx new file mode 100644 index 00000000..05fbb32a --- /dev/null +++ b/webapp/src/features/adminEditIntersection/AdminEditIntersection.test.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { render } from '@testing-library/react' +import AdminEditIntersection from './AdminEditIntersection' +import { Provider } from 'react-redux' +import { setupStore } from '../../store' +import { replaceChaoticIds } from '../../utils/test-utils' +import { BrowserRouter } from 'react-router-dom' + +it('should take a snapshot', () => { + const { container } = render( + + + + + + ) + + expect(replaceChaoticIds(container)).toMatchSnapshot() +}) diff --git a/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx new file mode 100644 index 00000000..61b38d40 --- /dev/null +++ b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx @@ -0,0 +1,323 @@ +import React, { useEffect, useState } from 'react' +import { Form } from 'react-bootstrap' +import { useForm } from 'react-hook-form' +import { ErrorMessage } from '@hookform/error-message' +import { Multiselect } from 'react-widgets' +import { + selectApiData, + selectOrganizations, + selectSelectedOrganizations, + selectRsus, + selectSelectedRsus, + selectSubmitAttempt, + + // actions + getIntersectionInfo, + submitForm, + setSelectedOrganizations, + setSelectedRsus, +} from './adminEditIntersectionSlice' +import { useSelector, useDispatch } from 'react-redux' + +import '../adminIntersectionTab/Admin.css' +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' +import { RootState } from '../../store' +import { AdminIntersection } from '../../models/Intersection' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { selectTableData, updateTableData } from '../adminIntersectionTab/adminIntersectionTabSlice' +import { Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' +import toast from 'react-hot-toast' + +export type AdminEditIntersectionFormType = { + intersection_id: string + ref_pt: { + latitude: string + longitude: string + } + bbox?: { + latitude1: string + longitude1: string + latitude2: string + longitude2: string + } + intersection_name?: string + origin_ip?: string + organizations: string[] + organizations_to_add: string[] + organizations_to_remove: string[] + rsus: string[] + rsus_to_add: string[] + rsus_to_remove: string[] +} + +const AdminEditIntersection = () => { + const dispatch: ThunkDispatch = useDispatch() + const apiData = useSelector(selectApiData) + const organizations = useSelector(selectOrganizations) + const selectedOrganizations = useSelector(selectSelectedOrganizations) + const rsus = useSelector(selectRsus) + const selectedRsus = useSelector(selectSelectedRsus) + const submitAttempt = useSelector(selectSubmitAttempt) + const intersectionTableData = useSelector(selectTableData) + + const [open, setOpen] = useState(true) + const navigate = useNavigate() + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + } = useForm({ + defaultValues: { + intersection_id: '', + ref_pt: { + latitude: '', + longitude: '', + }, + bbox: { + latitude1: '', + longitude1: '', + latitude2: '', + longitude2: '', + }, + intersection_name: '', + origin_ip: '', + organizations_to_add: [], + organizations_to_remove: [], + rsus_to_add: [], + rsus_to_remove: [], + }, + }) + + const { intersectionId } = useParams<{ intersectionId: string }>() + + useEffect(() => { + if ( + (intersectionTableData ?? []).find( + (intersection: AdminIntersection) => intersection.intersection_id === intersectionId + ) && + Object.keys(apiData).length == 0 + ) { + dispatch(getIntersectionInfo(intersectionId)) + } + }, [dispatch, intersectionId, intersectionTableData]) + + useEffect(() => { + const currIntersection = (intersectionTableData ?? []).find( + (intersection: AdminIntersection) => intersection.intersection_id === intersectionId + ) + if (currIntersection) { + setValue('intersection_id', currIntersection.intersection_id) + setValue('ref_pt.latitude', currIntersection.ref_pt.latitude.toString()) + setValue('ref_pt.longitude', currIntersection.ref_pt.longitude.toString()) + setValue('bbox.latitude1', currIntersection.bbox.latitude1.toString()) + setValue('bbox.longitude1', currIntersection.bbox.longitude1.toString()) + setValue('bbox.latitude2', currIntersection.bbox.latitude2.toString()) + setValue('bbox.longitude2', currIntersection.bbox.longitude2.toString()) + setValue('intersection_name', currIntersection.intersection_name) + setValue('origin_ip', currIntersection.origin_ip) + } + }, [apiData, setValue]) + + useEffect(() => { + dispatch(updateTableData()) + }, [dispatch]) + + const onSubmit = (data: AdminEditIntersectionFormType) => { + dispatch(submitForm(data)).then((data: any) => { + if (data.payload.success) { + toast.success('Intersection updated successfully') + } else { + toast.error('Failed to update Intersection: ' + data.payload.message) + } + }) + setOpen(false) + navigate('/dashboard/admin/intersections') + } + + return ( + + Edit Intersection + + {Object.keys(apiData ?? {}).length != 0 ? ( +
+ + Intersection ID + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
+ + + Reference Point Latitude + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
+ + + Reference Point Longitude + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
+ + + Intersection Name + + {errors.intersection_name && ( +

+ {errors.intersection_name.message} +

+ )} +
+ + + Origin IP + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
+ + + Organization + { + dispatch(setSelectedOrganizations(value)) + }} + /> + {selectedOrganizations.length === 0 && submitAttempt && ( +

+ Must select an organization +

+ )} +
+ + + RSUs + { + dispatch(setSelectedRsus(value)) + }} + /> + +
+ ) : ( + + Unknown Intersection ID. Either this Intersection does not exist, or you do not have access to it.{' '} + Intersections + + )} +
+ + + + +
+ ) +} + +export default AdminEditIntersection diff --git a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts new file mode 100644 index 00000000..cadc0a19 --- /dev/null +++ b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts @@ -0,0 +1,516 @@ +import reducer from './adminEditIntersectionSlice' +import { + // async thunks + getIntersectionInfo, + editIntersection, + submitForm, + + // functions + checkForm, + updateJson, + + // reducers + setSelectedOrganizations, + setSelectedRsus, + updateStates, + + // selectors + selectLoading, + selectApiData, + selectOrganizations, + selectSelectedOrganizations, + selectRsus, + selectSelectedRsus, + selectSubmitAttempt, +} from './adminEditIntersectionSlice' +import apiHelper from '../../apis/api-helper' +import EnvironmentVars from '../../EnvironmentVars' +import { RootState } from '../../store' + +describe('admin edit Intersection reducer', () => { + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + loading: false, + value: { + apiData: {}, + organizations: [], + selectedOrganizations: [], + rsus: [], + selectedRsus: [], + submitAttempt: false, + }, + }) + }) +}) + +describe('async thunks', () => { + const initialState: RootState['adminEditIntersection'] = { + loading: null, + value: { + apiData: null, + organizations: null, + selectedOrganizations: null, + rsus: null, + selectedRsus: null, + submitAttempt: null, + }, + } + + beforeAll(() => { + jest.mock('../../apis/api-helper') + }) + + afterAll(() => { + jest.unmock('../../apis/api-helper') + }) + + describe('getIntersectionInfo', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const intersection_id = '1' + const action = getIntersectionInfo(intersection_id) + + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 200, message: 'message', body: 'body' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: true, message: '', data: 'body' }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + + dispatch = jest.fn() + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: false, message: 'message' }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminEditIntersection/getIntersectionInfo/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + const apiData = 'apiData' as any + + let state = reducer( + { ...initialState, value: { ...initialState.value, apiData } }, + { + type: 'adminEditIntersection/getIntersectionInfo/fulfilled', + payload: { message: 'message', success: true, data: apiData }, + } + ) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, apiData }, + }) + + // Error Case + state = reducer(initialState, { + type: 'adminEditIntersection/getIntersectionInfo/fulfilled', + payload: { message: 'message', success: false }, + }) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminEditIntersection/getIntersectionInfo/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('editIntersection', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const json = { intersection_id: '1' } as any + const action = editIntersection(json) + + global.setTimeout = jest.fn((cb) => cb()) as any + try { + apiHelper._patchData = jest.fn().mockReturnValue({ status: 200, message: 'message', body: 'body' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: true, message: 'Changes were successfully applied!' }) + expect(apiHelper._patchData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id: json.intersection_id }, + body: JSON.stringify(json), + }) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + } catch (e) { + ;(global.setTimeout as any).mockClear() + throw e + } + + dispatch = jest.fn() + global.setTimeout = jest.fn((cb) => cb()) as any + try { + apiHelper._patchData = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: false, message: 'message' }) + expect(apiHelper._patchData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id: json.intersection_id }, + body: JSON.stringify(json), + }) + expect(setTimeout).not.toHaveBeenCalled() + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + } catch (e) { + ;(global.setTimeout as any).mockClear() + throw e + } + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminEditIntersection/editIntersection/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + + let state = reducer( + { ...initialState, value: { ...initialState.value } }, + { + type: 'adminEditIntersection/editIntersection/fulfilled', + payload: { message: 'message', success: true }, + } + ) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + + // Error Case + state = reducer(initialState, { + type: 'adminEditIntersection/editIntersection/fulfilled', + payload: { message: 'message', success: false }, + }) + + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminEditIntersection/editIntersection/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('submitForm', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + let getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + adminEditIntersection: { + value: { + selectedOrganizations: [{ name: 'org1' }, { name: 'org2' }, { name: 'org3' }], + selectedRsus: [{ name: 'rsu1' }, { name: 'rsu2' }, { name: 'rsu3' }], + apiData: { + allowed_selections: { + organizations: ['org1', 'org2', 'org4'], + rsus: ['rsu1', 'rsu2', 'rsu4'], + }, + intersection_data: { + organizations: ['org2', 'org4'], + rsus: ['rsu2', 'rsu4'], + }, + }, + }, + }, + }) + const data = { data: 'data' } as any + + let action = submitForm(data) + let resp = await action(dispatch, getState, undefined) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + + // invalid checkForm + dispatch = jest.fn() + getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + adminEditIntersection: { + value: { + selectedOrganizations: [], + selectedRsus: [], + }, + }, + }) + action = submitForm(data) + resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ + message: 'Please fill out all required fields', + submitAttempt: true, + success: false, + }) + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + }) + + it('Updates the state correctly fulfilled', async () => { + const submitAttempt = 'submitAttempt' + + const state = reducer(initialState, { + type: 'adminEditIntersection/submitForm/fulfilled', + payload: { submitAttempt: 'submitAttempt' }, + }) + + expect(state).toEqual({ + ...initialState, + value: { ...initialState.value, submitAttempt }, + }) + }) + }) +}) + +describe('functions', () => { + it('checkForm selectedOrganizations', async () => { + expect( + checkForm({ + value: { + selectedOrganizations: [], + }, + } as any) + ).toEqual(false) + }) + + it('checkForm selectedRsus', async () => { + expect( + checkForm({ + value: { + selectedRsus: null, + }, + } as any) + ).toEqual(false) + }) + + it('checkForm all invalid', async () => { + expect( + checkForm({ + value: { + selectedOrganizations: [], + selectedRsus: null, + }, + } as any) + ).toEqual(false) + }) + + it('checkForm all valid', async () => { + expect( + checkForm({ + value: { + selectedOrganizations: ['org1'], + selectedRsus: ['rsu1'], + }, + } as any) + ).toEqual(true) + }) + + it('updateJson', async () => { + const data = { + intersection_name: 'a', + } as any + const state = { + value: { + apiData: { + allowed_selections: { + organizations: ['org1', 'org2', 'org4'], + rsus: ['rsu1', 'rsu2', 'rsu4'], + }, + intersection_data: { + organizations: ['org2', 'org4'], + rsus: ['rsu2', 'rsu4'], + }, + }, + selectedOrganizations: [{ name: 'org1' }, { name: 'org2' }, { name: 'org3' }], + }, + } as any + + const expected = { + intersection_name: 'a', + organizations_to_add: ['org1'], + organizations_to_remove: ['org4'], + rsus_to_add: ['org1'], + rsus_to_remove: ['org4'], + } + + expect(updateJson(data, state)).toEqual(expected) + }) + + it('updateJson selectedRoute Other', async () => { + const data = { + intersection_name: 'a', + } as any + const state = { + value: { + apiData: { + allowed_selections: { + organizations: ['org1', 'org2', 'org4'], + rsus: ['rsu1', 'rsu2', 'rsu4'], + }, + intersection_data: { + organizations: ['org2', 'org4'], + rsus: ['rsu2', 'rsu4'], + }, + }, + selectedOrganizations: [{ name: 'org1' }, { name: 'org2' }, { name: 'org3' }], + selectedRsus: [{ name: 'rsu1' }, { name: 'rsu2' }, { name: 'rsu3' }], + }, + } as any + + const expected = { + intersection_name: 'a', + organizations_to_add: ['org1'], + organizations_to_remove: ['org4'], + rsus_to_add: ['rsu1'], + rsus_to_remove: ['rsu4'], + } + + expect(updateJson(data, state)).toEqual(expected) + }) +}) + +describe('reducers', () => { + const initialState: RootState['adminEditIntersection'] = { + loading: null, + value: { + apiData: null, + organizations: null, + selectedOrganizations: null, + rsus: null, + selectedRsus: null, + submitAttempt: null, + }, + } + + it('setSelectedOrganizations reducer updates state correctly', async () => { + const selectedOrganizations = 'selectedOrganizations' + expect(reducer(initialState, setSelectedOrganizations(selectedOrganizations))).toEqual({ + ...initialState, + value: { ...initialState.value, selectedOrganizations }, + }) + }) + + it('setSelectedRsus reducer updates state correctly', async () => { + const selectedRsus = 'selectedRsus' + expect(reducer(initialState, setSelectedRsus(selectedRsus))).toEqual({ + ...initialState, + value: { ...initialState.value, selectedRsus }, + }) + }) + + it('updateStates', async () => { + // write test for updateApiJson + const apiData = { + allowed_selections: { + organizations: ['org1', 'org2'], + rsus: ['rsu1', 'rsu2'], + }, + intersection_data: { + organizations: ['org1', 'org2'], + rsus: ['rsu1', 'rsu2'], + }, + } as any + + const values = { + organizations: [{ name: 'org1' }, { name: 'org2' }], + rsus: [{ name: 'rsu1' }, { name: 'rsu2' }], + selectedOrganizations: [{ name: 'org1' }, { name: 'org2' }], + selectedRsus: [{ name: 'rsu1' }, { name: 'rsu2' }], + } + expect(reducer(initialState, updateStates(apiData))).toEqual({ + ...initialState, + value: { ...initialState.value, ...values, apiData }, + }) + }) +}) + +describe('selectors', () => { + const initialState = { + loading: 'loading', + value: { + apiData: 'apiData', + organizations: 'organizations', + selectedOrganizations: 'selectedOrganizations', + rsus: 'rsus', + selectedRsus: 'selectedRsus', + submitAttempt: 'submitAttempt', + }, + } + const state = { adminEditIntersection: initialState } as any + + it('selectors return the correct value', async () => { + expect(selectLoading(state)).toEqual('loading') + + expect(selectApiData(state)).toEqual('apiData') + expect(selectOrganizations(state)).toEqual('organizations') + expect(selectSelectedOrganizations(state)).toEqual('selectedOrganizations') + expect(selectRsus(state)).toEqual('rsus') + expect(selectSelectedRsus(state)).toEqual('selectedRsus') + expect(selectSubmitAttempt(state)).toEqual('submitAttempt') + }) +}) diff --git a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx new file mode 100644 index 00000000..2f81b682 --- /dev/null +++ b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx @@ -0,0 +1,219 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' +import { selectToken } from '../../generalSlices/userSlice' +import EnvironmentVars from '../../EnvironmentVars' +import apiHelper from '../../apis/api-helper' +import { updateTableData as updateIntersectionTableData } from '../adminIntersectionTab/adminIntersectionTabSlice' +import { RootState } from '../../store' +import { AdminEditIntersectionFormType } from './AdminEditIntersection' + +export type adminEditIntersectionData = { + intersection_data: AdminEditIntersectionFormType + allowed_selections: { + organizations: string[] + rsus: string[] + } +} + +const initialState = { + apiData: {} as adminEditIntersectionData, + organizations: [] as { name: string }[], + selectedOrganizations: [] as { name: string }[], + rsus: [] as { name: string }[], + selectedRsus: [] as { name: string }[], + submitAttempt: false, +} + +export const checkForm = (state: RootState['adminEditIntersection']) => { + if (state.value.selectedOrganizations.length === 0) { + return false + } else { + return true + } +} + +export const updateJson = (data: AdminEditIntersectionFormType, state: RootState['adminEditIntersection']) => { + let json = data + + let organizationsToAdd = [] + let organizationsToRemove = [] + for (const org of state.value.apiData.allowed_selections.organizations) { + if ( + state.value.selectedOrganizations.some((e) => e.name === org) && + !state.value.apiData.intersection_data.organizations.includes(org) + ) { + organizationsToAdd.push(org) + } + if ( + state.value.apiData.intersection_data.organizations.includes(org) && + state.value.selectedOrganizations.some((e) => e.name === org) === false + ) { + organizationsToRemove.push(org) + } + } + + json.organizations_to_add = organizationsToAdd + json.organizations_to_remove = organizationsToRemove + + let rsusToAdd = [] + let rsusToRemove = [] + for (const rsu of state.value.apiData.allowed_selections.rsus) { + if ( + state.value.selectedRsus.some((e) => e.name === rsu) && + !state.value.apiData.intersection_data.rsus.includes(rsu) + ) { + rsusToAdd.push(rsu) + } + if ( + state.value.apiData.intersection_data.rsus.includes(rsu) && + state.value.selectedRsus.some((e) => e.name === rsu) === false + ) { + rsusToRemove.push(rsu) + } + } + + json.rsus_to_add = rsusToAdd + json.rsus_to_remove = rsusToRemove + + return json +} + +export const getIntersectionInfo = createAsyncThunk( + 'adminEditIntersection/getIntersectionInfo', + async (intersection_id: string, { getState, dispatch }) => { + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await apiHelper._getDataWithCodes({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_id }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + + switch (data.status) { + case 200: + dispatch(adminEditIntersectionSlice.actions.updateStates(data.body)) + return { success: true, message: '', data: data.body } + default: + return { success: false, message: data.message } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const editIntersection = createAsyncThunk( + 'adminEditIntersection/editIntersection', + async (json: { intersection_id: string }, { getState, dispatch }) => { + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await apiHelper._patchData({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_id: json.intersection_id }, + body: JSON.stringify(json), + }) + + switch (data.status) { + case 200: + dispatch(updateIntersectionTableData()) + return { success: true, message: 'Changes were successfully applied!' } + default: + return { success: false, message: data.message } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const submitForm = createAsyncThunk( + 'adminEditIntersection/submitForm', + async (data: AdminEditIntersectionFormType, { getState, dispatch }) => { + const currentState = getState() as RootState + if (checkForm(currentState.adminEditIntersection)) { + let json = updateJson(data, currentState.adminEditIntersection) + let res = await dispatch(editIntersection(json)) + if ((res.payload as any).success) { + return { submitAttempt: false, success: true, message: 'Intersection Updated Successfully' } + } else { + return { submitAttempt: false, success: false, message: (res.payload as any).message } + } + } else { + return { submitAttempt: true, success: false, message: 'Please fill out all required fields' } + } + } +) + +export const adminEditIntersectionSlice = createSlice({ + name: 'adminEditIntersection', + initialState: { + loading: false, + value: initialState, + }, + reducers: { + setSelectedOrganizations: (state, action) => { + state.value.selectedOrganizations = action.payload + }, + setSelectedRsus: (state, action) => { + state.value.selectedRsus = action.payload + }, + updateStates: (state, action: { payload: adminEditIntersectionData }) => { + const apiData = action.payload + + const allowedSelections = apiData.allowed_selections + state.value.organizations = allowedSelections.organizations.map((val) => { + return { name: val } + }) + state.value.rsus = allowedSelections.rsus.map((val) => { + return { name: val.replace('/32', '') } + }) + + state.value.selectedOrganizations = apiData.intersection_data.organizations.map((val) => { + return { name: val } + }) + state.value.selectedRsus = apiData.intersection_data.rsus.map((val) => { + return { name: val } + }) + + state.value.apiData = apiData + }, + }, + extraReducers: (builder) => { + builder + .addCase(getIntersectionInfo.pending, (state) => { + state.loading = true + }) + .addCase(getIntersectionInfo.fulfilled, (state, action) => { + state.loading = false + if (action.payload.success) { + state.value.apiData = action.payload.data + } + }) + .addCase(getIntersectionInfo.rejected, (state) => { + state.loading = false + }) + .addCase(editIntersection.pending, (state) => { + state.loading = true + }) + .addCase(editIntersection.fulfilled, (state, action) => { + state.loading = false + }) + .addCase(editIntersection.rejected, (state) => { + state.loading = false + }) + .addCase(submitForm.fulfilled, (state, action) => { + state.value.submitAttempt = action.payload.submitAttempt + }) + }, +}) + +export const { setSelectedOrganizations, setSelectedRsus, updateStates } = adminEditIntersectionSlice.actions + +export const selectLoading = (state: RootState) => state.adminEditIntersection.loading +export const selectApiData = (state: RootState) => state.adminEditIntersection.value.apiData +export const selectOrganizations = (state: RootState) => state.adminEditIntersection.value.organizations +export const selectSelectedOrganizations = (state: RootState) => state.adminEditIntersection.value.selectedOrganizations +export const selectRsus = (state: RootState) => state.adminEditIntersection.value.rsus +export const selectSelectedRsus = (state: RootState) => state.adminEditIntersection.value.selectedRsus +export const selectSubmitAttempt = (state: RootState) => state.adminEditIntersection.value.submitAttempt + +export default adminEditIntersectionSlice.reducer diff --git a/webapp/src/features/adminIntersectionTab/Admin.css b/webapp/src/features/adminIntersectionTab/Admin.css new file mode 100644 index 00000000..418b827e --- /dev/null +++ b/webapp/src/features/adminIntersectionTab/Admin.css @@ -0,0 +1,294 @@ +#admin { + width: 100%; + height: calc(100vh - 135px); +} + +.adminHeader { + padding-top: 20px; + padding-left: 25px; + padding-bottom: 20px; + text-align: left; + color: white; + font-family: sans-serif; +} + +.errorMsg { + max-width: 350px; + color: #f83a25; + padding: 2px 0; + margin-top: 2px; + font-size: 14px; + font-weight: 300; +} + +.react-tabs { + display: flex; + width: 100%; + height: calc(100% - 77px); + color: white; + background: #1c1d1f; +} + +.react-tabs__tab-list { + display: flex; + flex-direction: column; + width: 170px; + margin: 0; + padding: 0; + color: white; + background: #333; +} + +.react-tabs__tab { + list-style: none; + padding: 30px; + cursor: pointer; + font-weight: bold; + font-size: large; + color: #bbb; + font-family: sans-serif; +} + +.react-tabs__tab--selected { + background: #0e2052; + border-color: #1c1d1f; + border-left: 4px solid #d16d15; + color: white; +} + +.react-tabs__tab-panel { + display: none; + width: 100%; +} + +.react-tabs__tab-panel--selected { + display: block; +} + +.react-tabs__tab { + padding-left: 25px; +} + +.react-tabs__tab--selected { + padding-left: 21px; +} + +.panel-header { + margin-bottom: 20px; + padding: 5px; + font-family: sans-serif; + font-size: 25px; + color: #d16d15; + background-color: rgb(28, 29, 31); + height: fit-content; +} + +.panel-content { + text-align: left; + margin-top: 20px; + margin-left: 30px; + font-family: sans-serif; +} + +.form-control { + display: flex; + flex-direction: column; + margin-bottom: 15px; + width: 400px; + height: 30px; + font-size: 15px; +} + +.form-check { + margin-bottom: 15px; +} + +.form-select { + margin-left: 10px; + width: 150px; +} + +.admin-button { + font-family: Arial, Helvetica, sans-serif; + font-weight: 550; + background-color: #b55e12; + border: none; + color: white; + padding: 8px 10px; + text-align: center; + font-size: 12px; + cursor: pointer; + border-radius: 3px; + max-width: 400px; +} + +.admin_table_button { + font-family: sans-serif; + font-size: 15px; + margin-top: -4px; + margin-right: 10px; + padding: 6px 6px; + background-color: #b55e12; + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + text-align: center; +} + +.org-form-test { + margin-bottom: 10px; +} +.org-form-dropdown { + width: 400px; + max-width: 400px; + margin-top: 5px; + margin-bottom: 15px; +} + +.form-multiselect, +.form-dropdown { + width: 400px; + max-width: 400px; + margin-top: 3px; + margin-bottom: 15px; +} + +.org-multiselect { + min-width: 300px; + max-width: 95%; + margin-right: 10px; +} + +.spacer { + margin-top: 0px; + margin-bottom: 10px; +} + +.spacer-large-intersection { + margin-top: 0px; + margin-bottom: 30px; + display: flex; +} + +.spacer-large-user { + margin-top: 0px; + margin-bottom: 30px; + display: flex; + align-items: center; +} + +.column { + flex: 50%; +} + +.expand { + color: #e98125; +} + +.success-msg { + color: rgb(0, 255, 0); +} + +.error-msg { + color: #f83a25; + margin-bottom: 10px; + background-color: #1c1d1f; +} + +.accordion { + margin-bottom: 10px; +} + +.accordion-header { + font-weight: bold; +} + +.scroll-div { + height: calc(100vh - 275px); + overflow-y: auto; + margin-right: 10px; +} + +.scroll-div-tab { + height: calc(100vh - 340px); + overflow-y: auto; +} + +.scroll-div-org-tab { + height: calc(100vh - 390px); + overflow-y: auto; +} + +.admin_button { + font-family: sans-serif; + font-size: 15px; + margin-top: 0px; + margin-bottom: 0px; + margin-right: 10px; + padding: 5px; +} + +.plus_button { + font-family: sans-serif; + font-size: 15px; + margin-top: -4px; + margin-right: 10px; + float: right; + padding: 6px 6px; + background-color: #b55e12; + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + text-align: center; + max-height: 35px; +} + +.delete_button { + font-family: sans-serif; + font-size: 15px; + margin-top: 5px; + margin-left: 10px; + float: left; + padding: 6px 6px; + background-color: #b55e12; + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + text-align: center; + max-height: 35px; +} + +.table_div { + width: calc(100% - 10px); + overflow-wrap: normal; + overflow: hidden; +} + +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url(https://fonts.gstatic.com/s/materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2'); +} + +.material-icons { + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; +} + +.form-check-label { + color: 'white'; +} diff --git a/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.test.tsx b/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.test.tsx new file mode 100644 index 00000000..3325d116 --- /dev/null +++ b/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.test.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import AdminIntersectionTab from './AdminIntersectionTab' +import { Provider } from 'react-redux' +import { setupStore } from '../../store' +import { replaceChaoticIds } from '../../utils/test-utils' +import { BrowserRouter } from 'react-router-dom' + +it('snapshot add', () => { + const { container } = render( + + + + + + ) + + fireEvent.click(screen.queryByTitle('Add Intersection')) + + expect(replaceChaoticIds(container)).toMatchSnapshot() +}) + +it('snapshot refresh', () => { + const { container } = render( + + + + + + ) + + fireEvent.click(screen.queryByTitle('Refresh Intersections')) + + expect(replaceChaoticIds(container)).toMatchSnapshot() +}) diff --git a/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.tsx b/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.tsx new file mode 100644 index 00000000..ab38430b --- /dev/null +++ b/webapp/src/features/adminIntersectionTab/AdminIntersectionTab.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import AdminAddIntersection from '../adminAddIntersection/AdminAddIntersection' +import AdminEditIntersection, { AdminEditIntersectionFormType } from '../adminEditIntersection/AdminEditIntersection' +import AdminTable from '../../components/AdminTable' +import { IoRefresh } from 'react-icons/io5' +import { AiOutlinePlusCircle } from 'react-icons/ai' +import { confirmAlert } from 'react-confirm-alert' +import { Options } from '../../components/AdminDeletionOptions' +import { + selectLoading, + selectTableData, + + // actions + updateTableData, + deleteMultipleIntersections, + deleteIntersection, + setEditIntersectionRowData, + selectColumns, +} from './adminIntersectionTabSlice' +import { useSelector, useDispatch } from 'react-redux' + +import './Admin.css' +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' +import { RootState } from '../../store' +import { Action } from '@material-table/core' +import { Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { NotFound } from '../../pages/404' +import toast from 'react-hot-toast' + +const getTitle = (activeTab: string) => { + if (activeTab === undefined) { + return 'CV Manager Intersections' + } else if (activeTab === 'editIntersection') { + return '' + } else if (activeTab === 'addIntersection') { + return '' + } + return 'Unknown' +} + +const AdminIntersectionTab = () => { + const dispatch: ThunkDispatch = useDispatch() + const navigate = useNavigate() + const location = useLocation() + + const activeTab = location.pathname.split('/')[4] + const title = getTitle(activeTab) + + const tableData = useSelector(selectTableData) + const columns = useSelector(selectColumns) + + const loading = useSelector(selectLoading) + + const tableActions: Action[] = [ + { + icon: 'delete', + tooltip: 'Delete Intersection', + position: 'row', + onClick: (event, rowData: AdminEditIntersectionFormType) => { + const buttons = [ + { label: 'Yes', onClick: () => onDelete(rowData) }, + { label: 'No', onClick: () => {} }, + ] + const alertOptions = Options( + 'Delete Intersection', + 'Are you sure you want to delete "' + rowData.intersection_id + '"?', + buttons + ) + confirmAlert(alertOptions) + }, + }, + { + icon: 'edit', + tooltip: 'Edit Intersection', + position: 'row', + onClick: (event, rowData: AdminEditIntersectionFormType) => onEdit(rowData), + }, + { + tooltip: 'Remove All Selected From Organization', + icon: 'delete', + onClick: (event, rowData: AdminEditIntersectionFormType[]) => { + const buttons = [ + { label: 'Yes', onClick: () => multiDelete(rowData) }, + { label: 'No', onClick: () => {} }, + ] + const alertOptions = Options( + 'Delete Selected Intersections', + 'Are you sure you want to delete ' + rowData.length + ' Intersections?', + buttons + ) + confirmAlert(alertOptions) + }, + }, + ] + + const onEdit = (row: AdminEditIntersectionFormType) => { + dispatch(setEditIntersectionRowData(row)) + navigate('editIntersection/' + row.intersection_id) + } + + const onDelete = (row: AdminEditIntersectionFormType) => { + dispatch(deleteIntersection({ intersection_id: row.intersection_id, shouldUpdateTableData: true })).then( + (data: any) => { + data.payload.success + ? toast.success('Intersection Deleted Successfully') + : toast.error('Failed to delete Intersection due to error: ' + data.payload) + } + ) + } + + const multiDelete = (rows: AdminEditIntersectionFormType[]) => { + dispatch(deleteMultipleIntersections(rows)).then((data: any) => { + data.payload.success ? toast.success('Intersections Deleted Successfully') : toast.error(data.payload.message) + }) + } + + return ( +
+
+

+ {title} + {activeTab === undefined && [ + , + , + ]} +

+
+ + + ({ ...column }))} + actions={tableActions} + /> +
+ ) + } + /> + } /> + } /> + + } + /> + + + ) +} + +export default AdminIntersectionTab diff --git a/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.test.ts b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.test.ts new file mode 100644 index 00000000..239b54cd --- /dev/null +++ b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.test.ts @@ -0,0 +1,261 @@ +import reducer from './adminIntersectionTabSlice' +import { + // async thunks + updateTableData, + deleteIntersection, + deleteMultipleIntersections, + + // reducers + setEditIntersectionRowData, + + // selectors + selectLoading, + selectTableData, + selectColumns, + selectEditIntersectionRowData, +} from './adminIntersectionTabSlice' +import apiHelper from '../../apis/api-helper' +import EnvironmentVars from '../../EnvironmentVars' +import { RootState } from '../../store' + +describe('admin Intersection tab reducer', () => { + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + loading: false, + value: { + tableData: [], + title: 'Intersections', + columns: [ + { title: 'Intersection ID', field: 'intersection_id', id: 0 }, + { title: 'Intersection Name', field: 'intersection_name', id: 1 }, + { title: 'Origin IP', field: 'origin_ip', id: 2 }, + { title: 'Linked RSUs', field: 'rsus', id: 3 }, + ], + editIntersectionRowData: {}, + }, + }) + }) +}) + +describe('async thunks', () => { + const initialState: RootState['adminIntersectionTab'] = { + loading: null, + value: { + tableData: null, + title: null, + columns: null, + editIntersectionRowData: null, + }, + } + + beforeAll(() => { + jest.mock('../../apis/api-helper') + }) + + afterAll(() => { + jest.unmock('../../apis/api-helper') + }) + + describe('updateTableData', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const action = updateTableData() + + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 200, message: 'message', body: 'data' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual('data') + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id: 'all' }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + + dispatch = jest.fn() + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + resp = await action(dispatch, getState, undefined) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id: 'all' }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminIntersectionTab/updateTableData/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + let intersection_data = 'intersection_data' + let state = reducer(initialState, { + type: 'adminIntersectionTab/updateTableData/fulfilled', + payload: { intersection_data }, + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, tableData: intersection_data }, + }) + + intersection_data = undefined + state = reducer(initialState, { + type: 'adminIntersectionTab/updateTableData/fulfilled', + payload: { intersection_data }, + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, tableData: intersection_data }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminIntersectionTab/updateTableData/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('deleteIntersection', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const intersection_id = '1' + let shouldUpdateTableData = true + + let action = deleteIntersection({ intersection_id, shouldUpdateTableData }) + + apiHelper._deleteData = jest.fn().mockReturnValue({ status: 200, message: 'message', body: 'data' }) + let resp = await action(dispatch, getState, undefined) + expect(apiHelper._deleteData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id }, + }) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + + shouldUpdateTableData = false + dispatch = jest.fn() + action = deleteIntersection({ intersection_id, shouldUpdateTableData }) + + apiHelper._deleteData = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + resp = await action(dispatch, getState, undefined) + expect(apiHelper._deleteData).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_id }, + }) + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminIntersectionTab/deleteIntersection/pending', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + let state = reducer(initialState, { + type: 'adminIntersectionTab/deleteIntersection/fulfilled', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminIntersectionTab/deleteIntersection/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('deleteMultipleIntersections', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const rows = [{ intersection_id: '1' }, { intersection_id: '2' }, { intersection_id: '3' }] as any + + let action = deleteMultipleIntersections(rows) + + await action(dispatch, getState, undefined) + expect(dispatch).toHaveBeenCalledTimes(rows.length + 1 + 2) + }) + }) +}) + +describe('reducers', () => { + const initialState: RootState['adminIntersectionTab'] = { + loading: null, + value: { + tableData: null, + title: null, + columns: null, + editIntersectionRowData: null, + }, + } + + it('setEditIntersectionRowData reducer updates state correctly', async () => { + const editIntersectionRowData = 'editIntersectionRowData' + expect(reducer(initialState, setEditIntersectionRowData(editIntersectionRowData))).toEqual({ + ...initialState, + value: { ...initialState.value, editIntersectionRowData }, + }) + }) +}) + +describe('selectors', () => { + const initialState = { + loading: 'loading', + value: { + tableData: 'tableData', + title: 'title', + columns: 'columns', + editIntersectionRowData: 'editIntersectionRowData', + }, + } + const state = { adminIntersectionTab: initialState } as any + + it('selectors return the correct value', async () => { + expect(selectLoading(state)).toEqual('loading') + expect(selectTableData(state)).toEqual('tableData') + expect(selectColumns(state)).toEqual('columns') + expect(selectEditIntersectionRowData(state)).toEqual('editIntersectionRowData') + }) +}) diff --git a/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx new file mode 100644 index 00000000..d5f9a499 --- /dev/null +++ b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx @@ -0,0 +1,143 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' +import { selectToken } from '../../generalSlices/userSlice' +import EnvironmentVars from '../../EnvironmentVars' +import apiHelper from '../../apis/api-helper' +import { getIntersections } from '../../generalSlices/intersectionSlice' +import { RootState } from '../../store' +import { AdminEditIntersectionFormType } from '../adminEditIntersection/AdminEditIntersection' +import { AdminIntersection } from '../../models/Intersection' + +const initialState = { + tableData: [] as AdminEditIntersectionFormType[], + title: 'Intersections', + columns: [ + { title: 'Intersection ID', field: 'intersection_id', id: 0 }, + { title: 'Intersection Name', field: 'intersection_name', id: 0 }, + { title: 'Origin IP', field: 'origin_ip', id: 2 }, + { title: 'Linked RSUs', field: 'rsus', id: 3 }, + ], + editIntersectionRowData: {} as AdminEditIntersectionFormType, +} + +export const updateTableData = createAsyncThunk( + 'adminIntersectionTab/updateTableData', + async (_, { getState, dispatch }) => { + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await apiHelper._getDataWithCodes({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_id: 'all' }, + additional_headers: { 'Content-Type': 'application/json' }, + }) + + switch (data.status) { + case 200: + return data.body + default: + console.error(data.message) + return + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const deleteIntersection = createAsyncThunk( + 'adminIntersectionTab/deleteIntersection', + async (payload: { intersection_id: string; shouldUpdateTableData: boolean }, { getState, dispatch }) => { + const { intersection_id, shouldUpdateTableData } = payload + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await apiHelper._deleteData({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_id }, + }) + + var return_val = {} + + switch (data.status) { + case 200: + console.debug('Successfully deleted Intersection: ' + intersection_id) + return_val = { success: true, message: 'Successfully deleted Intersection: ' + intersection_id } + break + default: + return_val = { success: false, message: data.message } + break + } + if (shouldUpdateTableData) { + dispatch(updateTableData()) + } + return return_val + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const deleteMultipleIntersections = createAsyncThunk( + 'adminIntersectionTabSlice/deleteMultipleIntersections', + async (rows: AdminEditIntersectionFormType[], { dispatch }) => { + let promises = [] + for (const row of rows) { + promises.push( + dispatch(deleteIntersection({ intersection_id: row.intersection_id, shouldUpdateTableData: false })) + ) + } + var res = await Promise.all(promises) + dispatch(updateTableData()) + for (const r of res) { + if (!r.payload.success) { + return { success: false, message: 'Failed to delete one or more Intersection(s)' } + } + } + return { success: true, message: 'Intersections Deleted Successfully' } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const adminIntersectionTabSlice = createSlice({ + name: 'adminIntersectionTab', + initialState: { + loading: false, + value: initialState, + }, + reducers: { + setTitle: (state) => {}, + setEditIntersectionRowData: (state, action) => { + state.value.editIntersectionRowData = action.payload + }, + }, + extraReducers: (builder) => { + builder + .addCase(updateTableData.pending, (state) => { + state.loading = true + }) + .addCase(updateTableData.fulfilled, (state, action) => { + state.loading = false + state.value.tableData = action.payload?.intersection_data + }) + .addCase(updateTableData.rejected, (state) => { + state.loading = false + }) + .addCase(deleteIntersection.pending, (state) => { + state.loading = true + }) + .addCase(deleteIntersection.fulfilled, (state, action) => { + state.loading = false + }) + .addCase(deleteIntersection.rejected, (state) => { + state.loading = false + }) + }, +}) + +export const { setEditIntersectionRowData } = adminIntersectionTabSlice.actions + +export const selectLoading = (state: RootState) => state.adminIntersectionTab.loading +export const selectTableData = (state: RootState) => state.adminIntersectionTab.value.tableData +export const selectColumns = (state: RootState) => state.adminIntersectionTab.value.columns +export const selectEditIntersectionRowData = (state: RootState) => + state.adminIntersectionTab.value.editIntersectionRowData + +export default adminIntersectionTabSlice.reducer diff --git a/webapp/src/features/adminOrganizationTab/AdminOrganizationTab.tsx b/webapp/src/features/adminOrganizationTab/AdminOrganizationTab.tsx index 75dd7bcb..f6dba341 100644 --- a/webapp/src/features/adminOrganizationTab/AdminOrganizationTab.tsx +++ b/webapp/src/features/adminOrganizationTab/AdminOrganizationTab.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react' import AdminAddOrganization from '../adminAddOrganization/AdminAddOrganization' import AdminOrganizationTabRsu from '../adminOrganizationTabRsu/AdminOrganizationTabRsu' +import AdminOrganizationTabIntersection from '../adminOrganizationTabIntersection/AdminOrganizationTabIntersection' import AdminOrganizationTabUser from '../adminOrganizationTabUser/AdminOrganizationTabUser' import AdminEditOrganization from '../adminEditOrganization/AdminEditOrganization' import AdminOrganizationDeleteMenu from '../../components/AdminOrganizationDeleteMenu' @@ -15,6 +16,7 @@ import { selectSelectedOrgName, selectSelectedOrgEmail, selectRsuTableData, + selectIntersectionTableData, selectUserTableData, // actions @@ -58,6 +60,7 @@ const AdminOrganizationTab = () => { const selectedOrgName = useSelector(selectSelectedOrgName) const selectedOrgEmail = useSelector(selectSelectedOrgEmail) const rsuTableData = useSelector(selectRsuTableData) + const intersectionTableData = useSelector(selectIntersectionTableData) const userTableData = useSelector(selectUserTableData) const notifySuccess = (message: string) => toast.success(message) @@ -202,6 +205,13 @@ const AdminOrganizationTab = () => { tableData={rsuTableData} key="rsu" /> + { orgData: null, selectedOrg: null, rsuTableData: null, + intersectionTableData: null, userTableData: null, }, } @@ -336,6 +337,7 @@ describe('reducers', () => { orgData: null, selectedOrg: null, rsuTableData: null, + intersectionTableData: null, userTableData: null, }, } diff --git a/webapp/src/features/adminOrganizationTab/adminOrganizationTabSlice.tsx b/webapp/src/features/adminOrganizationTab/adminOrganizationTabSlice.tsx index e9a1d67b..a1968f6b 100644 --- a/webapp/src/features/adminOrganizationTab/adminOrganizationTabSlice.tsx +++ b/webapp/src/features/adminOrganizationTab/adminOrganizationTabSlice.tsx @@ -3,19 +3,19 @@ import { selectToken } from '../../generalSlices/userSlice' import EnvironmentVars from '../../EnvironmentVars' import apiHelper from '../../apis/api-helper' import { RootState } from '../../store' -import { ApiMsgRespWithCodes } from '../../apis/rsu-api-types' -import { AdminRsu } from '../../models/Rsu' export type AdminOrgSummary = { name: string email: string user_count: number rsu_count: number + intersection_count: number } export type AdminOrgSingle = { org_users: AdminOrgUser[] org_rsus: AdminOrgRsu[] + org_intersections: AdminOrgIntersection[] } export type AdminOrgUser = { @@ -33,6 +33,15 @@ export type AdminOrgRsu = { milepost: number } +export type AdminOrgIntersection = { + intersection_id: string + intersection_name: string + ref_pt: { + latitude: string + longitude: string + } +} + export type adminOrgPatch = { orig_name?: string name: string @@ -42,6 +51,8 @@ export type adminOrgPatch = { users_to_remove?: { email: string; role: string }[] rsus_to_add?: string[] rsus_to_remove?: string[] + intersections_to_add?: string[] + intersections_to_remove?: string[] } const initialState = { @@ -50,6 +61,7 @@ const initialState = { orgData: [] as AdminOrgSummary[], selectedOrg: {} as AdminOrgSummary, rsuTableData: [] as AdminOrgRsu[], + intersectionTableData: [] as AdminOrgIntersection[], userTableData: [] as AdminOrgUser[], } @@ -127,6 +139,8 @@ export const editOrg = createAsyncThunk( users_to_remove: [], rsus_to_add: [], rsus_to_remove: [], + intersections_to_add: [], + intersections_to_remove: [], ...json, } @@ -205,6 +219,7 @@ export const adminOrganizationTabSlice = createSlice({ } else { const org_data = data?.org_data as AdminOrgSingle state.value.rsuTableData = org_data?.org_rsus + state.value.intersectionTableData = org_data?.org_intersections state.value.userTableData = org_data?.org_users } } else { @@ -239,6 +254,7 @@ export const selectSelectedOrg = (state: RootState) => state.adminOrganizationTa export const selectSelectedOrgName = (state: RootState) => state.adminOrganizationTab.value.selectedOrg?.name export const selectSelectedOrgEmail = (state: RootState) => state.adminOrganizationTab.value.selectedOrg?.email export const selectRsuTableData = (state: RootState) => state.adminOrganizationTab.value.rsuTableData +export const selectIntersectionTableData = (state: RootState) => state.adminOrganizationTab.value.intersectionTableData export const selectUserTableData = (state: RootState) => state.adminOrganizationTab.value.userTableData export default adminOrganizationTabSlice.reducer diff --git a/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.test.tsx b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.test.tsx new file mode 100644 index 00000000..7b72a6f7 --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.test.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { render } from '@testing-library/react' +import AdminOrganizationTabIntersection from './AdminOrganizationTabIntersection' +import { Provider } from 'react-redux' +import { setupStore } from '../../store' +import { replaceChaoticIds } from '../../utils/test-utils' + +it('should take a snapshot', () => { + const { container } = render( + + {}} + /> + + ) + + expect(replaceChaoticIds(container)).toMatchSnapshot() +}) diff --git a/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.tsx b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.tsx new file mode 100644 index 00000000..ea35f090 --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersection.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect } from 'react' +import AdminTable from '../../components/AdminTable' +import { AiOutlinePlusCircle } from 'react-icons/ai' +import { ThemeProvider, StyledEngineProvider } from '@mui/material' +import Accordion from '@mui/material/Accordion' +import AccordionSummary from '@mui/material/AccordionSummary' +import AccordionDetails from '@mui/material/AccordionDetails' +import Typography from '@mui/material/Typography' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { Multiselect } from 'react-widgets' +import { confirmAlert } from 'react-confirm-alert' +import { Options } from '../../components/AdminDeletionOptions' +import { + selectAvailableIntersectionList, + selectSelectedIntersectionList, + + // actions + setSelectedIntersectionList, + getIntersectionData, + intersectionDeleteSingle, + intersectionDeleteMultiple, + intersectionAddMultiple, +} from './adminOrganizationTabIntersectionSlice' +import { selectLoadingGlobal } from '../../generalSlices/userSlice' +import { useSelector, useDispatch } from 'react-redux' + +import '../adminIntersectionTab/Admin.css' +import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' +import { RootState } from '../../store' +import { Action, Column } from '@material-table/core' +import { AdminOrgIntersection } from '../adminOrganizationTab/adminOrganizationTabSlice' +import toast from 'react-hot-toast' + +import { accordionTheme, outerAccordionTheme } from '../../styles' + +interface AdminOrganizationTabIntersectionProps { + selectedOrg: string + selectedOrgEmail: string + tableData: AdminOrgIntersection[] + updateTableData: (orgname: string) => void +} + +const AdminOrganizationTabIntersection = (props: AdminOrganizationTabIntersectionProps) => { + const { selectedOrg, selectedOrgEmail, updateTableData } = props + const dispatch: ThunkDispatch = useDispatch() + + const availableIntersectionList = useSelector(selectAvailableIntersectionList) + const selectedIntersectionList = useSelector(selectSelectedIntersectionList) + const loadingGlobal = useSelector(selectLoadingGlobal) + const [intersectionColumns] = useState[]>([ + { title: 'ID', field: 'intersection_id', id: 0, width: '31%' }, + { title: 'Name', field: 'intersection_name', id: 1, width: '31%' }, + ]) + + let intersectionActions: Action[] = [ + { + icon: 'delete', + tooltip: 'Remove From Organization', + position: 'row', + onClick: (event, rowData: AdminOrgIntersection) => { + const buttons = [ + { label: 'Yes', onClick: () => intersectionOnDelete(rowData) }, + { label: 'No', onClick: () => {} }, + ] + const alertOptions = Options( + 'Delete Intersection', + 'Are you sure you want to delete "' + rowData.intersection_id + '" from ' + selectedOrg + ' organization?', + buttons + ) + confirmAlert(alertOptions) + }, + }, + { + tooltip: 'Remove All Selected From Organization', + icon: 'delete', + onClick: (event, rowData: AdminOrgIntersection[]) => { + const buttons = [ + { label: 'Yes', onClick: () => intersectionMultiDelete(rowData) }, + { label: 'No', onClick: () => {} }, + ] + const alertOptions = Options( + 'Delete Selected Intersections', + 'Are you sure you want to delete ' + rowData.length + ' Intersections from ' + selectedOrg + ' organization?', + buttons + ) + confirmAlert(alertOptions) + }, + }, + ] + + useEffect(() => { + dispatch(setSelectedIntersectionList([])) + dispatch(getIntersectionData(selectedOrg)) + }, [selectedOrg, dispatch]) + + const intersectionOnDelete = async (intersection: AdminOrgIntersection) => { + dispatch(intersectionDeleteSingle({ intersection, selectedOrg, selectedOrgEmail, updateTableData })).then( + (data) => { + if (!(data.payload as any).success) { + toast.error((data.payload as any).message) + } else { + toast.success((data.payload as any).message) + } + } + ) + } + + const intersectionMultiDelete = async (rows: AdminOrgIntersection[]) => { + dispatch(intersectionDeleteMultiple({ rows, selectedOrg, selectedOrgEmail, updateTableData })).then((data) => { + if (!(data.payload as any).success) { + toast.error((data.payload as any).message) + } else { + toast.success((data.payload as any).message) + } + }) + } + + const intersectionMultiAdd = async (intersectionList: AdminOrgIntersection[]) => { + dispatch(intersectionAddMultiple({ intersectionList, selectedOrg, selectedOrgEmail, updateTableData })).then( + (data) => { + if (!(data.payload as any).success) { + toast.error((data.payload as any).message) + } else { + toast.success((data.payload as any).message) + } + } + ) + } + + return ( +
+ + + + } + aria-controls="panel1a-content" + id="panel1a-header" + > + {selectedOrg} Intersections + + + {loadingGlobal === false && [ +
+ + + + } + aria-controls="panel1a-content" + id="panel1a-header" + > + Add Intersections to {selectedOrg} + + +
+ { + dispatch(setSelectedIntersectionList(value)) + }} + /> + + +
+
+
+
+
+
, +
+ +
, + ]} +
+
+
+
+
+ ) +} + +export default AdminOrganizationTabIntersection diff --git a/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersectionTypes.d.ts b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersectionTypes.d.ts new file mode 100644 index 00000000..1d4e08be --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/AdminOrganizationTabIntersectionTypes.d.ts @@ -0,0 +1,26 @@ +import { AdminOrgIntersection } from '../adminOrganizationTab/adminOrganizationTabSlice' + +export type AdminOrgIntersectionWithId = AdminOrgIntersection & { + id?: number +} + +export type AdminOrgTabIntersectionAddMultiple = { + intersectionList: AdminOrgIntersection[] + selectedOrg: string + selectedOrgEmail: string + updateTableData: (org: string) => void +} + +export type AdminOrgIntersectionDeleteSingle = { + intersection: AdminOrgIntersection + selectedOrg: string + selectedOrgEmail: string + updateTableData: (org: string) => void +} + +export type AdminOrgIntersectionDeleteMultiple = { + rows: AdminOrgIntersection[] + selectedOrg: string + selectedOrgEmail: string + updateTableData: (org: string) => void +} diff --git a/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap b/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap new file mode 100644 index 00000000..ba300c43 --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should take a snapshot 1`] = ` +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.test.ts b/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.test.ts new file mode 100644 index 00000000..944369b6 --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.test.ts @@ -0,0 +1,347 @@ +import reducer from './adminOrganizationTabIntersectionSlice' +import { + // async thunks + getIntersectionData, + intersectionDeleteSingle, + intersectionDeleteMultiple, + intersectionAddMultiple, + refresh, + + // functions + getIntersectionDataById, + + // reducers + setSelectedIntersectionList, + + // selectors + selectLoading, + selectAvailableIntersectionList, + selectSelectedIntersectionList, +} from './adminOrganizationTabIntersectionSlice' +import apiHelper from '../../apis/api-helper' +import EnvironmentVars from '../../EnvironmentVars' +import { RootState } from '../../store' + +describe('admin organization tab Intersection reducer', () => { + it('should handle initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual({ + loading: false, + value: { + availableIntersectionList: [], + selectedIntersectionList: [], + }, + }) + }) +}) + +describe('async thunks', () => { + const initialState: RootState['adminOrganizationTabIntersection'] = { + loading: null, + value: { + availableIntersectionList: null, + selectedIntersectionList: null, + }, + } + + beforeAll(() => { + jest.mock('../../apis/api-helper') + }) + + afterAll(() => { + jest.unmock('../../apis/api-helper') + }) + + describe('getIntersectionData', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const orgName = 'orgName' + const action = getIntersectionData(orgName) + + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 200, message: 'message', body: 'data' }) + let resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: true, message: '', data: 'data', orgName }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_ip: 'all' }, + }) + + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + resp = await action(dispatch, getState, undefined) + expect(resp.payload).toEqual({ success: false, message: 'message' }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_ip: 'all' }, + }) + }) + + it('Updates the state correctly pending', async () => { + const loading = true + const state = reducer(initialState, { + type: 'adminOrganizationTabIntersection/getIntersectionData/pending', + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value }, + }) + }) + + it('Updates the state correctly fulfilled', async () => { + const loading = false + let orgName = 'org2' + const data = { + intersection_data: [ + { + intersection_id: '1', + organizations: ['org1', 'org2'], + }, + ], + } + let state = reducer(initialState, { + type: 'adminOrganizationTabIntersection/getIntersectionData/fulfilled', + payload: { data, orgName, success: true }, + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, availableIntersectionList: [] }, + }) + + // No matching organizations + data['intersection_data'][0]['organizations'] = ['org1', 'org3'] + + state = reducer(initialState, { + type: 'adminOrganizationTabIntersection/getIntersectionData/fulfilled', + payload: { data, orgName, success: true }, + }) + expect(state).toEqual({ + ...initialState, + loading, + value: { ...initialState.value, availableIntersectionList: [{ id: 0, intersection_id: '1' }] }, + }) + }) + + it('Updates the state correctly rejected', async () => { + const loading = false + const state = reducer(initialState, { + type: 'adminOrganizationTabIntersection/getIntersectionData/rejected', + }) + expect(state).toEqual({ ...initialState, loading, value: { ...initialState.value } }) + }) + }) + + describe('intersectionDeleteSingle', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const intersection = { intersection_id: '1' } as any + const selectedOrg = 'selectedOrg' + const selectedOrgEmail = 'name@email.com' + const updateTableData = jest.fn() + + let intersectionData = { intersection_data: { organizations: ['org1', 'org2'] } } + + let action = intersectionDeleteSingle({ intersection, selectedOrg, selectedOrgEmail, updateTableData }) + + const jsdomAlert = window.alert + try { + window.alert = jest.fn() + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ body: intersectionData }) + await action(dispatch, getState, undefined) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token: 'token', + query_params: { intersection_ip: intersection.intersection_id }, + }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(2 + 2) + expect(window.alert).not.toHaveBeenCalled() + + // Only 1 organization + dispatch = jest.fn() + intersectionData = { intersection_data: { organizations: ['org1'] } } + + action = intersectionDeleteSingle({ intersection, selectedOrg, selectedOrgEmail, updateTableData }) + + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 500, message: 'message' }) + window.alert = jest.fn() + await action(dispatch, getState, undefined) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + expect(window.alert).toHaveBeenCalledWith( + 'Cannot remove Intersection 1 from selectedOrg because it must belong to at least one organization.' + ) + } catch (e) { + window.alert = jsdomAlert + throw e + } + }) + }) + + describe('intersectionDeleteMultiple', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const rows = [{ intersection_id: '1' }, { intersection_id: '2' }, { intersection_id: '3' }] as any + const selectedOrg = 'selectedOrg' + const selectedOrgEmail = 'name@email.com' + const updateTableData = jest.fn() + + const intersectionData = { intersection_data: { organizations: ['org1', 'org2', 'org3'] } } + const invalidIntersectionData = { intersection_data: { organizations: ['org1'] } } + + let action = intersectionDeleteMultiple({ rows, selectedOrg, selectedOrgEmail, updateTableData }) + + const jsdomAlert = window.alert + try { + window.alert = jest.fn() + apiHelper._getDataWithCodes = jest + .fn() + .mockReturnValueOnce({ body: intersectionData }) + .mockReturnValueOnce({ body: intersectionData }) + .mockReturnValueOnce({ body: intersectionData }) + await action(dispatch, getState, undefined) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledTimes(3) + expect(dispatch).toHaveBeenCalledTimes(2 + 2) + expect(window.alert).not.toHaveBeenCalled() + + // Only 1 organization + dispatch = jest.fn() + + action = intersectionDeleteMultiple({ rows, selectedOrg, selectedOrgEmail, updateTableData }) + + window.alert = jest.fn() + apiHelper._getDataWithCodes = jest + .fn() + .mockReturnValueOnce({ body: intersectionData }) + .mockReturnValueOnce({ body: invalidIntersectionData }) + .mockReturnValueOnce({ body: invalidIntersectionData }) + await action(dispatch, getState, undefined) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledTimes(3) + expect(dispatch).toHaveBeenCalledTimes(0 + 2) + expect(window.alert).toHaveBeenCalledWith( + 'Cannot remove Intersection(s) 2, 3 from selectedOrg because they must belong to at least one organization.' + ) + } catch (e) { + window.alert = jsdomAlert + throw e + } + }) + }) + + describe('intersectionAddMultiple', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const intersectionList = [{ intersection_id: '1' }, { intersection_id: '2' }, { intersection_id: '3' }] as any + const selectedOrg = 'selectedOrg' + const selectedOrgEmail = 'name@email.com' + const fetchPatchOrganization = jest.fn() + const updateTableData = jest.fn() + + let action = intersectionAddMultiple({ intersectionList, selectedOrg, selectedOrgEmail, updateTableData }) + + await action(dispatch, getState, undefined) + expect(dispatch).toHaveBeenCalledTimes(2 + 2) + }) + }) + + describe('refresh', () => { + it('returns and calls the api correctly', async () => { + let dispatch = jest.fn() + const getState = jest.fn().mockReturnValue({ + user: { + value: { + authLoginData: { token: 'token' }, + }, + }, + }) + const selectedOrg = 'selectedOrg' + const updateTableData = jest.fn() + + let action = refresh({ selectedOrg, updateTableData }) + + await action(dispatch, getState, undefined) + expect(updateTableData).toHaveBeenCalledTimes(1) + expect(updateTableData).toHaveBeenCalledWith(selectedOrg) + expect(dispatch).toHaveBeenCalledTimes(1 + 2) + }) + }) +}) + +describe('reducers', () => { + const initialState: RootState['adminOrganizationTabIntersection'] = { + loading: null, + value: { + availableIntersectionList: null, + selectedIntersectionList: null, + }, + } + + it('setSelectedIntersectionList reducer updates state correctly', async () => { + const selectedIntersectionList = 'selectedIntersectionList' + expect(reducer(initialState, setSelectedIntersectionList(selectedIntersectionList))).toEqual({ + ...initialState, + value: { ...initialState.value, selectedIntersectionList }, + }) + }) +}) + +describe('functions', () => { + it('getIntersectionDataById', async () => { + const intersection_ip = '1' + const token = 'token' + apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ data: 'data' }) + const resp = await getIntersectionDataById(intersection_ip, token) + expect(resp).toEqual({ data: 'data' }) + expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_ip }, + }) + }) +}) + +describe('selectors', () => { + const initialState = { + loading: 'loading', + value: { + availableIntersectionList: 'availableIntersectionList', + selectedIntersectionList: 'selectedIntersectionList', + }, + } + const state = { adminOrganizationTabIntersection: initialState } as any + + it('selectors return the correct value', async () => { + expect(selectLoading(state)).toEqual('loading') + expect(selectAvailableIntersectionList(state)).toEqual('availableIntersectionList') + expect(selectSelectedIntersectionList(state)).toEqual('selectedIntersectionList') + }) +}) diff --git a/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.tsx b/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.tsx new file mode 100644 index 00000000..8e7ecde0 --- /dev/null +++ b/webapp/src/features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice.tsx @@ -0,0 +1,221 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' +import { selectToken } from '../../generalSlices/userSlice' +import EnvironmentVars from '../../EnvironmentVars' +import apiHelper from '../../apis/api-helper' +import { RootState } from '../../store' +import { + AdminOrgIntersectionDeleteMultiple, + AdminOrgIntersectionDeleteSingle, + AdminOrgIntersectionWithId, + AdminOrgTabIntersectionAddMultiple, +} from './AdminOrganizationTabIntersectionTypes' +import { adminOrgPatch, editOrg } from '../adminOrganizationTab/adminOrganizationTabSlice' + +const initialState = { + availableIntersectionList: [] as AdminOrgIntersectionWithId[], + selectedIntersectionList: [] as AdminOrgIntersectionWithId[], +} + +export const getIntersectionDataById = async (intersection_id: string, token: string) => { + const data = await apiHelper._getDataWithCodes({ + url: EnvironmentVars.adminIntersection, + token, + query_params: { intersection_id }, + }) + + return data +} + +export const getIntersectionData = createAsyncThunk( + 'adminOrganizationTabIntersection/getIntersectionData', + async (orgName: string, { getState, dispatch }) => { + const currentState = getState() as RootState + const token = selectToken(currentState) + + const data = await getIntersectionDataById('all', token) + + switch (data.status) { + case 200: + return { success: true, message: '', data: data.body, orgName } + default: + return { success: false, message: data.message } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const intersectionDeleteSingle = createAsyncThunk( + 'adminOrganizationTabIntersection/intersectionDeleteSingle', + async (payload: AdminOrgIntersectionDeleteSingle, { getState, dispatch }) => { + const { intersection, selectedOrg, selectedOrgEmail, updateTableData } = payload + const currentState = getState() as RootState + const token = selectToken(currentState) + + let promises = [] + const intersectionData = (await getIntersectionDataById(intersection.intersection_id, token)).body + if (intersectionData?.intersection_data?.organizations?.length > 1) { + const patchJson: adminOrgPatch = { + name: selectedOrg, + email: selectedOrgEmail, + intersections_to_remove: [intersection.intersection_id], + } + promises.push(dispatch(editOrg(patchJson))) + } else { + alert( + 'Cannot remove Intersection ' + + intersection.intersection_id + + ' from ' + + selectedOrg + + ' because it must belong to at least one organization.' + ) + } + var res = await Promise.all(promises) + dispatch(refresh({ selectedOrg, updateTableData })) + + if ((res[0].payload as any).success) { + return { success: true, message: 'Intersection deleted successfully' } + } else { + return { success: false, message: 'Failed to delete Intersection' } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const intersectionDeleteMultiple = createAsyncThunk( + 'adminOrganizationTabIntersection/intersectionDeleteMultiple', + async (payload: AdminOrgIntersectionDeleteMultiple, { getState, dispatch }) => { + const { rows, selectedOrg, selectedOrgEmail, updateTableData } = payload + const currentState = getState() as RootState + const token = selectToken(currentState) + + const invalidIntersections = [] + const patchJson: adminOrgPatch = { + name: selectedOrg, + email: selectedOrgEmail, + intersections_to_remove: [], + } + for (const row of rows) { + const intersectionData = (await getIntersectionDataById(row.intersection_id, token)).body + if (intersectionData?.intersection_data?.organizations?.length > 1) { + patchJson.intersections_to_remove.push(row.intersection_id) + } else { + invalidIntersections.push(row.intersection_id) + } + } + if (invalidIntersections.length === 0) { + var res = await dispatch(editOrg(patchJson)) + dispatch(refresh({ selectedOrg, updateTableData })) + if ((res.payload as any).success) { + return { success: true, message: 'Intersection(s) deleted successfully' } + } else { + return { success: false, message: 'Failed to delete Intersection(s)' } + } + } else { + alert( + 'Cannot remove Intersection(s) ' + + invalidIntersections.map((ip) => ip.toString()).join(', ') + + ' from ' + + selectedOrg + + ' because they must belong to at least one organization.' + ) + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const intersectionAddMultiple = createAsyncThunk( + 'adminOrganizationTabIntersection/intersectionAddMultiple', + async (payload: AdminOrgTabIntersectionAddMultiple, { getState, dispatch }) => { + const { intersectionList, selectedOrg, selectedOrgEmail, updateTableData } = payload + + const patchJson: adminOrgPatch = { + name: selectedOrg, + email: selectedOrgEmail, + intersections_to_add: [], + } + for (const row of intersectionList) { + patchJson.intersections_to_add.push(row.intersection_id) + } + var res = await dispatch(editOrg(patchJson)) + dispatch(refresh({ selectedOrg, updateTableData })) + if ((res.payload as any).success) { + return { success: true, message: 'Intersection(s) added successfully' } + } else { + return { success: false, message: 'Failed to add Intersection(s)' } + } + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const refresh = createAsyncThunk( + 'adminOrganizationTabIntersection/refresh', + async ( + payload: { + selectedOrg: string + updateTableData: (selectedOrg: string) => void + }, + { dispatch } + ) => { + const { selectedOrg, updateTableData } = payload + updateTableData(selectedOrg) + dispatch(getIntersectionData(selectedOrg)) + }, + { condition: (_, { getState }) => selectToken(getState() as RootState) != undefined } +) + +export const adminOrganizationTabIntersectionSlice = createSlice({ + name: 'adminOrganizationTabIntersection', + initialState: { + loading: false, + value: initialState, + }, + reducers: { + setSelectedIntersectionList: (state, action) => { + state.value.selectedIntersectionList = action.payload + }, + }, + extraReducers: (builder) => { + builder + .addCase(getIntersectionData.pending, (state) => { + state.loading = true + }) + .addCase(getIntersectionData.fulfilled, (state, action) => { + state.loading = false + if (action.payload.success) { + const intersectionData = action.payload.data + let availableIntersectionList = [] as AdminOrgIntersectionWithId[] + let counter = 0 + if (intersectionData?.intersection_data) { + for (const intersection of intersectionData.intersection_data) { + const intersectionOrgs = intersection?.organizations + if (!intersectionOrgs.includes(action.payload.orgName)) { + let tempValue = { + id: counter, + intersection_id: intersection.intersection_id, + } as AdminOrgIntersectionWithId + availableIntersectionList.push(tempValue) + counter += 1 + } + } + } + state.value.availableIntersectionList = availableIntersectionList + } + }) + .addCase(getIntersectionData.rejected, (state) => { + state.loading = false + }) + .addCase(refresh.fulfilled, (state) => { + state.value.selectedIntersectionList = [] + }) + }, +}) + +export const { setSelectedIntersectionList } = adminOrganizationTabIntersectionSlice.actions + +export const selectLoading = (state: RootState) => state.adminOrganizationTabIntersection.loading +export const selectAvailableIntersectionList = (state: RootState) => + state.adminOrganizationTabIntersection.value.availableIntersectionList +export const selectSelectedIntersectionList = (state: RootState) => + state.adminOrganizationTabIntersection.value.selectedIntersectionList + +export default adminOrganizationTabIntersectionSlice.reducer diff --git a/webapp/src/models/Intersection.d.ts b/webapp/src/models/Intersection.d.ts new file mode 100644 index 00000000..17f60e40 --- /dev/null +++ b/webapp/src/models/Intersection.d.ts @@ -0,0 +1,17 @@ +export type AdminIntersection = { + intersection_id: string + ref_pt: { + latitude: string + longitude: string + } + bbox?: { + latitude1: string + longitude1: string + latitude2: string + longitude2: string + } + intersection_name?: string + origin_ip?: string + organizations: string[] + rsus: string[] +} diff --git a/webapp/src/pages/Admin.tsx b/webapp/src/pages/Admin.tsx index e0a6335d..02f65a73 100644 --- a/webapp/src/pages/Admin.tsx +++ b/webapp/src/pages/Admin.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react' import { useDispatch } from 'react-redux' import { updateTableData as updateRsuTableData } from '../features/adminRsuTab/adminRsuTabSlice' +import { updateTableData as updateIntersectionTableData } from '../features/adminIntersectionTab/adminIntersectionTabSlice' import { getAvailableUsers } from '../features/adminUserTab/adminUserTabSlice' import '../features/adminRsuTab/Admin.css' import { AnyAction, ThunkDispatch } from '@reduxjs/toolkit' @@ -12,12 +13,14 @@ import { NotFound } from './404' import { SecureStorageManager } from '../managers' import { getUserNotifications } from '../features/adminNotificationTab/adminNotificationTabSlice' import VerticalTabs from '../components/VerticalTabs' +import AdminIntersectionTab from '../features/adminIntersectionTab/AdminIntersectionTab' function Admin() { const dispatch: ThunkDispatch = useDispatch() useEffect(() => { dispatch(updateRsuTableData()) + dispatch(updateIntersectionTableData()) dispatch(getAvailableUsers()) dispatch(getUserNotifications()) }, [dispatch]) @@ -46,6 +49,11 @@ function Admin() { title: 'RSUs', child: , }, + { + path: 'intersections', + title: 'Intersections', + child: , + }, { path: 'users', title: 'Users', diff --git a/webapp/src/store.tsx b/webapp/src/store.tsx index 4bfd8f59..0ab302ac 100644 --- a/webapp/src/store.tsx +++ b/webapp/src/store.tsx @@ -6,14 +6,18 @@ import configReducer from './generalSlices/configSlice' import intersectionReducer from './generalSlices/intersectionSlice' import adminAddOrganizationReducer from './features/adminAddOrganization/adminAddOrganizationSlice' import adminAddRsuReducer from './features/adminAddRsu/adminAddRsuSlice' +import adminAddIntersectionReducer from './features/adminAddIntersection/adminAddIntersectionSlice' import adminAddUserReducer from './features/adminAddUser/adminAddUserSlice' import adminEditOrganizationReducer from './features/adminEditOrganization/adminEditOrganizationSlice' import adminEditRsuReducer from './features/adminEditRsu/adminEditRsuSlice' +import adminEditIntersectionReducer from './features/adminEditIntersection/adminEditIntersectionSlice' import adminEditUserReducer from './features/adminEditUser/adminEditUserSlice' import adminOrganizationTabReducer from './features/adminOrganizationTab/adminOrganizationTabSlice' import adminOrganizationTabUserReducer from './features/adminOrganizationTabUser/adminOrganizationTabUserSlice' import adminOrganizationTabRsuReducer from './features/adminOrganizationTabRsu/adminOrganizationTabRsuSlice' +import adminOrganizationTabIntersectionReducer from './features/adminOrganizationTabIntersection/adminOrganizationTabIntersectionSlice' import adminRsuTabReducer from './features/adminRsuTab/adminRsuTabSlice' +import adminIntersectionTabReducer from './features/adminIntersectionTab/adminIntersectionTabSlice' import adminUserTabReducer from './features/adminUserTab/adminUserTabSlice' import adminNotificationTabReducer from './features/adminNotificationTab/adminNotificationTabSlice' import adminAddNotificationReducer from './features/adminAddNotification/adminAddNotificationSlice' @@ -33,14 +37,18 @@ export const setupStore = (preloadedState: any) => { intersection: intersectionReducer, adminAddOrganization: adminAddOrganizationReducer, adminAddRsu: adminAddRsuReducer, + adminAddIntersection: adminAddIntersectionReducer, adminAddUser: adminAddUserReducer, adminEditOrganization: adminEditOrganizationReducer, adminEditRsu: adminEditRsuReducer, + adminEditIntersection: adminEditIntersectionReducer, adminEditUser: adminEditUserReducer, adminOrganizationTab: adminOrganizationTabReducer, adminOrganizationTabUser: adminOrganizationTabUserReducer, adminOrganizationTabRsu: adminOrganizationTabRsuReducer, + adminOrganizationTabIntersection: adminOrganizationTabIntersectionReducer, adminRsuTab: adminRsuTabReducer, + adminIntersectionTab: adminIntersectionTabReducer, adminUserTab: adminUserTabReducer, adminNotificationTab: adminNotificationTabReducer, adminAddNotification: adminAddNotificationReducer, @@ -57,6 +65,7 @@ export const setupStore = (preloadedState: any) => { serializableCheck: false, immutableCheck: false, }), + devTools: true, }) } diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 2a3a024d..6fc9f891 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -6202,6 +6202,71 @@ csstype@^3.1.3: resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +"cv-manager@file:": + version "1.2.0-SNAPSHOT" + resolved "file:" + dependencies: + "@date-io/date-fns" "^2.15.0" + "@emotion/react" "^11.11.4" + "@emotion/styled" "^11.11.5" + "@hookform/error-message" "^2.0.1" + "@material-table/core" "^6.1.6" + "@material-ui/core" "^4.12.3" + "@mui/icons-material" "^5.15.19" + "@mui/material" "^5.15.19" + "@mui/styled-engine-sc" "^5.11.9" + "@mui/styles" "^5.15.19" + "@mui/x-date-pickers" "^5.0.15" + "@react-keycloak/web" "3.4.0" + "@react-oauth/google" "^0.2.8" + "@reduxjs/toolkit" "^1.9.1" + "@stomp/stompjs" "^7.0.0" + "@syncfusion/ej2-react-calendars" "^19.4.56" + "@testing-library/jest-dom" "^5.16.5" + "@testing-library/react" "^12.1.2" + "@turf/turf" "^6.5.0" + "@types/react-tabs" "^5.0.5" + abort-controller "^3.0.0" + axios "^1.6.5" + color "^4.2.3" + cv-manager "file:" + dayjs "^1.11.7" + env-cmd "^10.1.0" + fbjs "^3.0.4" + file-saver "^2.0.5" + formik "^2.4.6" + isomorphic-fetch "^3.0.0" + jest-fetch-mock "^3.0.3" + jszip "^3.10.1" + keycloak-js "21.1.1" + luxon "^2.3.2" + mapbox-gl "^2.9.1" + rc-slider "^10.1.0" + react "^17.0.2" + react-bootstrap "^2.7.0" + react-confirm-alert "^2.8.0" + react-dom "^17.0.2" + react-hook-form "^7.41.5" + react-hot-toast "^2.4.1" + react-icons "^4.3.1" + react-map-gl "^7.0.21" + react-perfect-scrollbar "^1.5.8" + react-redux "^8.0.5" + react-router-dom "^6.2.2" + react-scripts "^5.0.0" + react-secure-storage "^1.3.2" + react-select "^5.3.2" + react-spinners "^0.11.0" + react-table "^7.8.0" + react-tabs "^4.2.1" + react-widgets "5.8.4" + reactstrap "^9.2.2" + recharts "^2.12.6" + styled-components "^5.3.6" + worker-loader "^3.0.8" + yup "^1.4.0" + zustand "4.3.1" + d3-array@^3.1.6: version "3.2.4" resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz" @@ -7782,6 +7847,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@^2.1.2, fsevents@^2.3.2, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1, function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" From 75c5c7303b9fb84be28b6569434489c908bb9092 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Wed, 18 Sep 2024 02:44:23 -0600 Subject: [PATCH 03/12] Final intersection admin logical touches --- .../sql_scripts/CVManager_SampleData.sql | 14 +- services/api/src/admin_intersection.py | 35 +- .../adminAddIntersectionSlice.tsx | 12 +- .../AdminEditIntersection.tsx | 357 +++++++++--------- .../adminEditIntersectionSlice.tsx | 40 +- .../adminIntersectionTabSlice.tsx | 3 + webapp/src/models/Intersection.d.ts | 2 +- 7 files changed, 264 insertions(+), 199 deletions(-) diff --git a/resources/sql_scripts/CVManager_SampleData.sql b/resources/sql_scripts/CVManager_SampleData.sql index cb423808..ec4fb241 100644 --- a/resources/sql_scripts/CVManager_SampleData.sql +++ b/resources/sql_scripts/CVManager_SampleData.sql @@ -72,4 +72,16 @@ INSERT INTO public.snmp_msgfwd_config( INSERT INTO public.email_type( email_type) - VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'); \ No newline at end of file + VALUES ('Support Requests'), ('Firmware Upgrade Failures'), ('Daily Message Counts'); + +INSERT INTO public.intersections( + intersection_number, ref_pt, intersection_name) + VALUES (1, ST_GeomFromText('POINT(-105.014182 39.740422)'), 'Test Intersection'); + +INSERT INTO public.intersection_organization( + intersection_id, organization_id) + VALUES (1, 1); + +INSERT INTO public.rsu_intersection( + rsu_id, intersection_id) + VALUES (1, 1); \ No newline at end of file diff --git a/services/api/src/admin_intersection.py b/services/api/src/admin_intersection.py index f2a9ade6..fe87dd36 100644 --- a/services/api/src/admin_intersection.py +++ b/services/api/src/admin_intersection.py @@ -9,16 +9,16 @@ def get_intersection_data(intersection_id): query = ( "SELECT to_jsonb(row) " "FROM (" - "SELECT intersection_number, ST_X(geography::geometry) AS ref_pt_longitude, ST_Y(geography::geometry) AS ref_pt_latitude, " - "ST_XMin(geography::geometry) AS bbox_longitude_1, ST_YMin(geography::geometry) AS bbox_latitude_1, " - "ST_XMax(geography::geometry) AS bbox_longitude_2, ST_YMax(geography::geometry) AS bbox_latitude_2, " + "SELECT intersection_number, ST_X(ref_pt::geometry) AS ref_pt_longitude, ST_Y(ref_pt::geometry) AS ref_pt_latitude, " + "ST_XMin(bbox::geometry) AS bbox_longitude_1, ST_YMin(bbox::geometry) AS bbox_latitude_1, " + "ST_XMax(bbox::geometry) AS bbox_longitude_2, ST_YMax(bbox::geometry) AS bbox_latitude_2, " "intersection_name, origin_ip, " "org.name AS org_name, rsu.ipv4_address AS rsu_ip " "FROM public.intersections " "JOIN public.intersection_organization AS ro ON ro.intersection_id = intersections.intersection_id " "JOIN public.organizations AS org ON org.organization_id = ro.organization_id " - "JOIN public.rsu_intersection AS rsu_intersection ON ro.intersection_id = intersections.intersection_id " - "JOIN public.rsus AS rsu ON rsu.rsu_id = rsu_intersection.rsu_id" + "LEFT JOIN public.rsu_intersection AS rsu_intersection ON ro.intersection_id = intersections.intersection_id " + "LEFT JOIN public.rsus AS rsu ON rsu.rsu_id = rsu_intersection.rsu_id" ) if intersection_id != "all": query += f" WHERE intersection_number = '{intersection_id}'" @@ -47,10 +47,12 @@ def get_intersection_data(intersection_id): "organizations": [], "rsus": [], } - intersection_dict[str(row["intersection_number"])]["organizations"].append( - row["org_name"] - ) - intersection_dict[str(row["intersection_number"])]["rsus"].append(row["rsu_ip"]) + orgs = intersection_dict[str(row["intersection_number"])]["organizations"] + rsus = intersection_dict[str(row["intersection_number"])]["rsus"] + if row["org_name"] not in orgs: + orgs.append(row["org_name"]) + if row["rsu_ip"] not in orgs and row["rsu_ip"] is not None: + rsus.append(row["rsu_ip"]) intersection_list = list(intersection_dict.values()) # If list is empty and a single Intersection was requested, return empty object @@ -86,12 +88,15 @@ def modify_intersection(intersection_spec): # Modify the existing Intersection data query = ( "UPDATE public.intersections SET " - f"ref_pt=ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})'), " - f"bbox=ST_MakeEnvelope({str(intersection_spec['bbox']['longitude1'])},{str(intersection_spec['bbox']['latitude1'])},{str(intersection_spec['bbox']['longitude2'])},{str(intersection_spec['bbox']['latitude2'])}), " - f"intersection_name='{intersection_spec['intersection_name']}', " - f"origin_ip='{intersection_spec['origin_ip']}'" - f"WHERE intersection_number='{intersection_spec['intersection_id']}'" + f"ref_pt=ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})')" ) + if "bbox" in intersection_spec: + query += f", bbox=ST_MakeEnvelope({str(intersection_spec['bbox']['longitude1'])},{str(intersection_spec['bbox']['latitude1'])},{str(intersection_spec['bbox']['longitude2'])},{str(intersection_spec['bbox']['latitude2'])})" + if "intersection_name" in intersection_spec: + query += f", intersection_name='{intersection_spec['intersection_name']}'" + if "origin_ip" in intersection_spec: + query += f", origin_ip='{intersection_spec['origin_ip']}'" + query += f"WHERE intersection_number='{intersection_spec['intersection_id']}'" pgquery.write_db(query) # Add the intersection-to-organization relationships for the organizations to add @@ -125,7 +130,7 @@ def modify_intersection(intersection_spec): rsu_add_query += ( " (" f"(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '{ip}'), " - f"(SELECT intersection_id FROM public.intersections WHERE ipv4_address = '{intersection_spec['intersection_id']}')" + f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}')" ")," ) rsu_add_query = rsu_add_query[:-1] diff --git a/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx index ae9560a9..47784abe 100644 --- a/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx +++ b/webapp/src/features/adminAddIntersection/adminAddIntersectionSlice.tsx @@ -56,7 +56,7 @@ export const updateApiJson = (apiJson: AdminIntersectionCreationInfo): AdminInte data = [] for (let i = 0; i < apiJson['rsus'].length; i++) { let value = apiJson['rsus'][i] - let temp = { id: i, name: value.replace('/32', '') } + let temp = { id: i, name: value?.replace('/32', '') } data.push(temp) } keyedApiJson.rsus = data @@ -133,6 +133,16 @@ export const createIntersection = createAsyncThunk( const currentState = getState() as RootState const token = selectToken(currentState) + if (!json?.bbox) { + delete json.bbox + } + if (!json?.intersection_name) { + delete json.intersection_name + } + if (!json?.origin_ip) { + delete json.origin_ip + } + const data = await apiHelper._postData({ url: EnvironmentVars.adminAddIntersection, body: JSON.stringify(json), diff --git a/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx index 61b38d40..ee117bb4 100644 --- a/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx +++ b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx @@ -96,8 +96,7 @@ const AdminEditIntersection = () => { if ( (intersectionTableData ?? []).find( (intersection: AdminIntersection) => intersection.intersection_id === intersectionId - ) && - Object.keys(apiData).length == 0 + ) ) { dispatch(getIntersectionInfo(intersectionId)) } @@ -109,12 +108,12 @@ const AdminEditIntersection = () => { ) if (currIntersection) { setValue('intersection_id', currIntersection.intersection_id) - setValue('ref_pt.latitude', currIntersection.ref_pt.latitude.toString()) - setValue('ref_pt.longitude', currIntersection.ref_pt.longitude.toString()) - setValue('bbox.latitude1', currIntersection.bbox.latitude1.toString()) - setValue('bbox.longitude1', currIntersection.bbox.longitude1.toString()) - setValue('bbox.latitude2', currIntersection.bbox.latitude2.toString()) - setValue('bbox.longitude2', currIntersection.bbox.longitude2.toString()) + setValue('ref_pt.latitude', currIntersection.ref_pt?.latitude?.toString()) + setValue('ref_pt.longitude', currIntersection.ref_pt?.longitude?.toString()) + setValue('bbox.latitude1', currIntersection.bbox?.latitude1?.toString()) + setValue('bbox.longitude1', currIntersection.bbox?.longitude1?.toString()) + setValue('bbox.latitude2', currIntersection.bbox?.latitude2?.toString()) + setValue('bbox.longitude2', currIntersection.bbox?.longitude2?.toString()) setValue('intersection_name', currIntersection.intersection_name) setValue('origin_ip', currIntersection.origin_ip) } @@ -139,183 +138,195 @@ const AdminEditIntersection = () => { return ( Edit Intersection - - {Object.keys(apiData ?? {}).length != 0 ? ( -
- - Intersection ID - - ( -

- {' '} - {message}{' '} -

- )} - /> -
+ {Object.keys(apiData ?? {}).length != 0 ? ( + <> + + + + Intersection ID + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
- - Reference Point Latitude - - ( -

- {' '} - {message}{' '} -

- )} - /> -
+ + Reference Point Latitude + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
+ + + Reference Point Longitude + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
- - Reference Point Longitude - - ( + + Intersection Name + + {errors.intersection_name && (

- {' '} - {message}{' '} + {errors.intersection_name.message}

)} - /> -
+
- - Intersection Name - - {errors.intersection_name && ( -

- {errors.intersection_name.message} -

- )} -
+ + Origin IP + + ( +

+ {' '} + {message}{' '} +

+ )} + /> +
- - Origin IP - - ( -

- {' '} - {message}{' '} + + Organization + { + dispatch(setSelectedOrganizations(value)) + }} + /> + {selectedOrganizations.length === 0 && submitAttempt && ( +

+ Must select an organization

)} - /> -
+ - - Organization - { - dispatch(setSelectedOrganizations(value)) - }} - /> - {selectedOrganizations.length === 0 && submitAttempt && ( -

- Must select an organization -

- )} -
- - - RSUs - { - dispatch(setSelectedRsus(value)) - }} - /> - - - ) : ( + + RSUs + { + dispatch(setSelectedRsus(value)) + }} + /> + + +
+ + + + + + ) : ( + - Unknown Intersection ID. Either this Intersection does not exist, or you do not have access to it.{' '} - Intersections + Unknown Intersection ID. Either this Intersection does not exist, or you do not have access to it. - )} - - - - - + +
+ )}
) } diff --git a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx index 2f81b682..5d6a6707 100644 --- a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx +++ b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx @@ -23,6 +23,10 @@ const initialState = { submitAttempt: false, } +function isEmptyObject(obj: any): boolean { + return obj && Object.keys(obj).length === 0 +} + export const checkForm = (state: RootState['adminEditIntersection']) => { if (state.value.selectedOrganizations.length === 0) { return false @@ -34,6 +38,17 @@ export const checkForm = (state: RootState['adminEditIntersection']) => { export const updateJson = (data: AdminEditIntersectionFormType, state: RootState['adminEditIntersection']) => { let json = data + console.log('json BBOX', json?.bbox) + if (!json.bbox || !json.bbox.latitude1 || !json.bbox.longitude1 || !json.bbox.latitude2 || !json.bbox.longitude2) { + delete json.bbox + } + if (!json.intersection_name) { + delete json.intersection_name + } + if (!json.origin_ip) { + delete json.origin_ip + } + let organizationsToAdd = [] let organizationsToRemove = [] for (const org of state.value.apiData.allowed_selections.organizations) { @@ -57,17 +72,26 @@ export const updateJson = (data: AdminEditIntersectionFormType, state: RootState let rsusToAdd = [] let rsusToRemove = [] for (const rsu of state.value.apiData.allowed_selections.rsus) { + const formattedRsu = rsu?.replace('/32', '') + console.log( + 'RSU Updating', + formattedRsu, + state.value.selectedRsus, + state.value.selectedRsus.some((e) => e.name === formattedRsu), + state.value.apiData.intersection_data.rsus, + state.value.apiData.intersection_data.rsus.includes(formattedRsu) + ) if ( - state.value.selectedRsus.some((e) => e.name === rsu) && - !state.value.apiData.intersection_data.rsus.includes(rsu) + state.value.selectedRsus.some((e) => e.name === formattedRsu) && + !state.value.apiData.intersection_data.rsus.includes(formattedRsu) ) { - rsusToAdd.push(rsu) + rsusToAdd.push(formattedRsu) } if ( - state.value.apiData.intersection_data.rsus.includes(rsu) && - state.value.selectedRsus.some((e) => e.name === rsu) === false + state.value.apiData.intersection_data.rsus.includes(formattedRsu) && + state.value.selectedRsus.some((e) => e.name === formattedRsu) === false ) { - rsusToRemove.push(rsu) + rsusToRemove.push(formattedRsu) } } @@ -164,14 +188,14 @@ export const adminEditIntersectionSlice = createSlice({ return { name: val } }) state.value.rsus = allowedSelections.rsus.map((val) => { - return { name: val.replace('/32', '') } + return { name: val?.replace('/32', '') } }) state.value.selectedOrganizations = apiData.intersection_data.organizations.map((val) => { return { name: val } }) state.value.selectedRsus = apiData.intersection_data.rsus.map((val) => { - return { name: val } + return { name: val?.replace('/32', '') } }) state.value.apiData = apiData diff --git a/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx index d5f9a499..f8d26784 100644 --- a/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx +++ b/webapp/src/features/adminIntersectionTab/adminIntersectionTabSlice.tsx @@ -116,6 +116,9 @@ export const adminIntersectionTabSlice = createSlice({ .addCase(updateTableData.fulfilled, (state, action) => { state.loading = false state.value.tableData = action.payload?.intersection_data + state.value.tableData.forEach((element: AdminIntersection) => { + element.rsus = (element.rsus as string[])?.join(', ') + }) }) .addCase(updateTableData.rejected, (state) => { state.loading = false diff --git a/webapp/src/models/Intersection.d.ts b/webapp/src/models/Intersection.d.ts index 17f60e40..3d903e22 100644 --- a/webapp/src/models/Intersection.d.ts +++ b/webapp/src/models/Intersection.d.ts @@ -13,5 +13,5 @@ export type AdminIntersection = { intersection_name?: string origin_ip?: string organizations: string[] - rsus: string[] + rsus: string[] | string } From 06d3355ddc803c6753ac27fcce8e60d6caadbbbe Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Wed, 18 Sep 2024 10:45:11 -0600 Subject: [PATCH 04/12] Updating intersection unit tests --- services/api/src/admin_org.py | 75 +++++++++- .../AdminFormManager.test.tsx.snap | 65 +++++++++ .../AdminAddIntersection.test.tsx.snap | 7 + .../adminAddIntersectionSlice.test.ts | 4 +- .../AdminEditIntersection.test.tsx.snap | 7 + .../adminEditIntersectionSlice.test.ts | 18 +-- .../adminEditIntersectionSlice.tsx | 9 -- .../AdminIntersectionTab.tsx | 2 +- .../AdminIntersectionTab.test.tsx.snap | 133 ++++++++++++++++++ .../adminIntersectionTabSlice.test.ts | 9 +- .../adminIntersectionTabSlice.tsx | 4 +- .../AdminOrganizationTab.test.tsx.snap | 65 +++++++++ .../adminOrganizationTabSlice.test.ts | 7 + ...nOrganizationTabIntersection.test.tsx.snap | 2 +- ...inOrganizationTabIntersectionSlice.test.ts | 12 +- 15 files changed, 381 insertions(+), 38 deletions(-) create mode 100644 webapp/src/features/adminAddIntersection/__snapshots__/AdminAddIntersection.test.tsx.snap create mode 100644 webapp/src/features/adminEditIntersection/__snapshots__/AdminEditIntersection.test.tsx.snap create mode 100644 webapp/src/features/adminIntersectionTab/__snapshots__/AdminIntersectionTab.test.tsx.snap diff --git a/services/api/src/admin_org.py b/services/api/src/admin_org.py index d668b603..a85ff1f0 100644 --- a/services/api/src/admin_org.py +++ b/services/api/src/admin_org.py @@ -11,7 +11,8 @@ def get_all_orgs(): "FROM (" "SELECT org.name, org.email, " "(SELECT COUNT(*) FROM public.user_organization uo WHERE uo.organization_id = org.organization_id) num_users, " - "(SELECT COUNT(*) FROM public.rsu_organization ro WHERE ro.organization_id = org.organization_id) num_rsus " + "(SELECT COUNT(*) FROM public.rsu_organization ro WHERE ro.organization_id = org.organization_id) num_rsus, " + "(SELECT COUNT(*) FROM public.intersection_organization io WHERE io.organization_id = org.organization_id) num_intersections " "FROM public.organizations org" ") as row" ) @@ -25,6 +26,7 @@ def get_all_orgs(): org_obj["email"] = row["email"] org_obj["user_count"] = row["num_users"] org_obj["rsu_count"] = row["num_rsus"] + org_obj["intersection_count"] = row["num_intersections"] return_obj.append(org_obj) return return_obj @@ -81,6 +83,29 @@ def get_org_data(org_name): rsu_obj["milepost"] = row["milepost"] org_obj["org_rsus"].append(rsu_obj) + # Get all Intersection members of the organization + intersection_query = ( + "SELECT to_jsonb(row) " + "FROM (" + "SELECT i.intersection_number, i.intersection_name, i.origin_ip " + "FROM public.organizations AS org " + "JOIN (" + "SELECT io.organization_id, intersections.intersection_number, intersections.intersection_name, intersections.origin_ip " + "FROM public.intersection_organization io " + "JOIN public.intersections ON io.intersection_id = intersections.intersection_id" + ") i ON i.organization_id = org.organization_id " + f"WHERE org.name = '{org_name}'" + ") as row" + ) + data = pgquery.query_db(intersection_query) + for row in data: + row = dict(row[0]) + intersection_obj = {} + intersection_obj["intersection_id"] = str(row["intersection_number"]) + intersection_obj["intersection_name"] = row["intersection_name"] + intersection_obj["origin_ip"] = row["origin_ip"] + org_obj["org_intersections"].append(intersection_obj) + return org_obj @@ -209,6 +234,28 @@ def modify_org(org_spec): f"AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = '{org_spec['name']}')" ) pgquery.write_db(rsu_remove_query) + + # Add the intersection-to-organization relationships + if len(org_spec["intersections_to_add"]) > 0: + intersection_add_query = "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES" + for intersection_id in org_spec["intersections_to_add"]: + intersection_add_query += ( + " (" + f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_id}'), " + f"(SELECT organization_id FROM public.organizations WHERE name = '{org_spec['name']}')" + ")," + ) + intersection_add_query = intersection_add_query[:-1] + pgquery.write_db(intersection_add_query) + + # Remove the intersection-to-organization relationships + for intersection_id in org_spec["intersections_to_remove"]: + intersection_remove_query = ( + "DELETE FROM public.intersection_organization WHERE " + f"intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_id}') " + f"AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = '{org_spec['name']}')" + ) + pgquery.write_db(intersection_remove_query) except sqlalchemy.exc.IntegrityError as e: failed_value = e.orig.args[0]["D"] failed_value = failed_value.replace("(", '"') @@ -230,6 +277,11 @@ def delete_org(org_name): "message": "Cannot delete organization that has one or more RSUs only associated with this organization" }, 400 + if check_orphan_intersections(org_name): + return { + "message": "Cannot delete organization that has one or more Intersections only associated with this organization" + }, 400 + if check_orphan_users(org_name): return { "message": "Cannot delete organization that has one or more users only associated with this organization" @@ -249,6 +301,13 @@ def delete_org(org_name): ) pgquery.write_db(rsu_org_remove_query) + # Delete intersection-to-organization relationships + intersection_org_remove_query = ( + "DELETE FROM public.intersection_organization WHERE " + f"organization_id = (SELECT organization_id FROM public.organizations WHERE name = '{org_name}')" + ) + pgquery.write_db(intersection_org_remove_query) + # Delete organization data org_remove_query = "DELETE FROM public.organizations WHERE " f"name = '{org_name}'" pgquery.write_db(org_remove_query) @@ -270,6 +329,20 @@ def check_orphan_rsus(org): return False +def check_orphan_intersections(org): + intersection_query = ( + "SELECT to_jsonb(row) " + "FROM (SELECT intersection_id, count(organization_id) count FROM intersection_organization WHERE intersection_id IN (SELECT intersection_id FROM intersection_organization WHERE organization_id = " + f"(SELECT organization_id FROM organizations WHERE name = '{org}')) GROUP BY intersection_id) as row" + ) + intersection_count = pgquery.query_db(intersection_query) + for row in intersection_count: + intersection = dict(row[0]) + if intersection["count"] == 1: + return True + return False + + def check_orphan_users(org): user_query = ( "SELECT to_jsonb(row) " diff --git a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap index 019359b8..086fc158 100644 --- a/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap +++ b/webapp/src/components/__snapshots__/AdminFormManager.test.tsx.snap @@ -245,6 +245,71 @@ exports[`snapshot organization 1`] = `
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
{ selectedOrg: {}, rsuTableData: [], userTableData: [], + intersectionTableData: [], }, }) }) @@ -153,6 +154,7 @@ describe('async thunks', () => { org_data: { org_users: 'org_users', org_rsus: 'org_rsus', + org_intersections: 'org_intersections', }, } @@ -168,6 +170,7 @@ describe('async thunks', () => { ...initialState.value, rsuTableData: data.org_data.org_rsus, userTableData: data.org_data.org_users, + intersectionTableData: data.org_data.org_intersections, }, }) }) @@ -256,6 +259,8 @@ describe('async thunks', () => { users_to_remove: [], rsus_to_add: [], rsus_to_remove: [], + intersections_to_add: [], + intersections_to_remove: [], ...json, }), }) @@ -273,6 +278,8 @@ describe('async thunks', () => { users_to_remove: [], rsus_to_add: [], rsus_to_remove: [], + intersections_to_add: [], + intersections_to_remove: [], ...json, }), }) diff --git a/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap b/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap index ba300c43..776525f0 100644 --- a/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap +++ b/webapp/src/features/adminOrganizationTabIntersection/__snapshots__/AdminOrganizationTabIntersection.test.tsx.snap @@ -24,7 +24,7 @@ exports[`should take a snapshot 1`] = ` style="font-size: 18px;" > - RSUs + Intersections

{ expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token: 'token', - query_params: { intersection_ip: 'all' }, + query_params: { intersection_id: 'all' }, }) apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ status: 500, message: 'message' }) @@ -79,7 +79,7 @@ describe('async thunks', () => { expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token: 'token', - query_params: { intersection_ip: 'all' }, + query_params: { intersection_id: 'all' }, }) }) @@ -166,7 +166,7 @@ describe('async thunks', () => { expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token: 'token', - query_params: { intersection_ip: intersection.intersection_id }, + query_params: { intersection_id: intersection.intersection_id }, }) expect(apiHelper._getDataWithCodes).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(2 + 2) @@ -316,15 +316,15 @@ describe('reducers', () => { describe('functions', () => { it('getIntersectionDataById', async () => { - const intersection_ip = '1' + const intersection_id = '1' const token = 'token' apiHelper._getDataWithCodes = jest.fn().mockReturnValue({ data: 'data' }) - const resp = await getIntersectionDataById(intersection_ip, token) + const resp = await getIntersectionDataById(intersection_id, token) expect(resp).toEqual({ data: 'data' }) expect(apiHelper._getDataWithCodes).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token, - query_params: { intersection_ip }, + query_params: { intersection_id }, }) }) }) From e166ee2d4e45284f65f98b31d19410143a11764d Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Wed, 9 Oct 2024 10:47:25 -0600 Subject: [PATCH 05/12] Updating intersection unit tests --- services/api/src/admin_org.py | 6 +- services/api/tests/data/admin_org_data.py | 73 ++++++++++++++++++- services/api/tests/src/test_admin_org.py | 48 ++++++++++-- .../apis/example_responses/admin-org_get.json | 3 +- 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/services/api/src/admin_org.py b/services/api/src/admin_org.py index a85ff1f0..ea2a4538 100644 --- a/services/api/src/admin_org.py +++ b/services/api/src/admin_org.py @@ -33,7 +33,7 @@ def get_all_orgs(): def get_org_data(org_name): - org_obj = {"org_users": [], "org_rsus": []} + org_obj = {"org_users": [], "org_rsus": [], "org_intersections": []} # Get all user members of the organization user_query = ( @@ -101,7 +101,7 @@ def get_org_data(org_name): for row in data: row = dict(row[0]) intersection_obj = {} - intersection_obj["intersection_id"] = str(row["intersection_number"]) + intersection_obj["intersection_id"] = row["intersection_number"] intersection_obj["intersection_name"] = row["intersection_name"] intersection_obj["origin_ip"] = row["origin_ip"] org_obj["org_intersections"].append(intersection_obj) @@ -382,6 +382,8 @@ class AdminOrgPatchSchema(Schema): users_to_remove = fields.List(fields.Nested(UserRoleSchema), required=True) rsus_to_add = fields.List(fields.IPv4(), required=True) rsus_to_remove = fields.List(fields.IPv4(), required=True) + intersections_to_add = fields.List(fields.Integer, required=True) + intersections_to_remove = fields.List(fields.Integer, required=True) class AdminOrg(Resource): diff --git a/services/api/tests/data/admin_org_data.py b/services/api/tests/data/admin_org_data.py index bb692f88..738d444b 100644 --- a/services/api/tests/data/admin_org_data.py +++ b/services/api/tests/data/admin_org_data.py @@ -17,6 +17,8 @@ "users_to_remove": [{"email": "test3@email.com", "role": "user"}], "rsus_to_add": ["10.0.0.2"], "rsus_to_remove": ["10.0.0.1"], + "intersections_to_add": [1111], + "intersections_to_remove": [1112], } request_json_bad = { @@ -27,6 +29,8 @@ "users_to_modify": [{"email": "test2@email.com", "role": "user"}], "rsus_to_add": ["10.0.0.2"], "rsus_to_remove": ["10.0.0.1"], + "intersections_to_add": [1111], + "intersections_to_modify": [1112], } request_json_unsafe_input = { @@ -38,6 +42,8 @@ "users_to_remove": [{"email": "test3@email.com", "role": "operator"}], "rsus_to_add": ["10.0.0.2"], "rsus_to_remove": ["10.0.0.1"], + "intersections_to_add": [1111], + "intersections_to_remove": [1112], } ##################################### function data ########################################### @@ -45,11 +51,25 @@ # get_all_orgs get_all_orgs_pgdb_return = [ - ({"name": "test org", "email": "test@email.com", "num_users": 12, "num_rsus": 30},), + ( + { + "name": "test org", + "email": "test@email.com", + "num_users": 12, + "num_rsus": 30, + "num_intersections": 42, + }, + ), ] get_all_orgs_result = [ - {"name": "test org", "email": "test@email.com", "user_count": 12, "rsu_count": 30} + { + "name": "test org", + "email": "test@email.com", + "user_count": 12, + "rsu_count": 30, + "intersection_count": 42, + } ] get_all_orgs_sql = ( @@ -57,7 +77,8 @@ "FROM (" "SELECT org.name, org.email, " "(SELECT COUNT(*) FROM public.user_organization uo WHERE uo.organization_id = org.organization_id) num_users, " - "(SELECT COUNT(*) FROM public.rsu_organization ro WHERE ro.organization_id = org.organization_id) num_rsus " + "(SELECT COUNT(*) FROM public.rsu_organization ro WHERE ro.organization_id = org.organization_id) num_rsus, " + "(SELECT COUNT(*) FROM public.intersection_organization io WHERE io.organization_id = org.organization_id) num_intersections " "FROM public.organizations org" ") as row" ) @@ -79,6 +100,16 @@ ({"ipv4_address": "10.0.0.1", "primary_route": "test", "milepost": "test"},), ] +get_org_data_intersection_return = [ + ( + { + "intersection_number": 1234, + "intersection_name": "test", + "origin_ip": "1.1.1.1", + }, + ), +] + get_org_data_result = { "org_users": [ { @@ -89,6 +120,13 @@ } ], "org_rsus": [{"ip": "10.0.0.1", "primary_route": "test", "milepost": "test"}], + "org_intersections": [ + { + "intersection_id": 1234, + "intersection_name": "test", + "origin_ip": "1.1.1.1", + }, + ], } get_org_data_user_sql = ( @@ -120,6 +158,20 @@ ") as row" ) +get_org_data_intersection_sql = ( + "SELECT to_jsonb(row) " + "FROM (" + "SELECT i.intersection_number, i.intersection_name, i.origin_ip " + "FROM public.organizations AS org " + "JOIN (" + "SELECT io.organization_id, intersections.intersection_number, intersections.intersection_name, intersections.origin_ip " + "FROM public.intersection_organization io " + "JOIN public.intersections ON io.intersection_id = intersections.intersection_id" + ") i ON i.organization_id = org.organization_id " + f"WHERE org.name = 'test org'" + ") as row" +) + # get_allowed_selections get_allowed_selections_return = [ @@ -178,10 +230,25 @@ "AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = 'test org')" ) +modify_org_add_intersection_sql = ( + "INSERT INTO public.intersection_organization(intersection_id, organization_id) VALUES" + " (" + "(SELECT intersection_id FROM public.intersections WHERE intersection_number = '1111'), " + "(SELECT organization_id FROM public.organizations WHERE name = 'test org')" + ")" +) + +modify_org_remove_intersection_sql = ( + "DELETE FROM public.intersection_organization WHERE " + "intersection_id=(SELECT intersection_id FROM public.intersections WHERE intersection_number = '1112') " + "AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = 'test org')" +) + # delete_org delete_org_calls = [ "DELETE FROM public.user_organization WHERE organization_id = (SELECT organization_id FROM public.organizations WHERE name = 'test org')", "DELETE FROM public.rsu_organization WHERE organization_id = (SELECT organization_id FROM public.organizations WHERE name = 'test org')", + "DELETE FROM public.intersection_organization WHERE organization_id = (SELECT organization_id FROM public.organizations WHERE name = 'test org')", "DELETE FROM public.organizations WHERE name = 'test org'", ] diff --git a/services/api/tests/src/test_admin_org.py b/services/api/tests/src/test_admin_org.py index ae9af46c..3a8f8bb1 100644 --- a/services/api/tests/src/test_admin_org.py +++ b/services/api/tests/src/test_admin_org.py @@ -134,6 +134,7 @@ def test_get_org_data(mock_query_db): mock_query_db.side_effect = [ admin_org_data.get_org_data_user_return, admin_org_data.get_org_data_rsu_return, + admin_org_data.get_org_data_intersection_return, ] expected_result = admin_org_data.get_org_data_result actual_result = admin_org.get_org_data("test org") @@ -141,6 +142,7 @@ def test_get_org_data(mock_query_db): calls = [ call(admin_org_data.get_org_data_user_sql), call(admin_org_data.get_org_data_rsu_sql), + call(admin_org_data.get_org_data_intersection_sql), ] mock_query_db.assert_has_calls(calls) assert actual_result == expected_result @@ -217,6 +219,8 @@ def test_modify_user_success(mock_pgquery, mock_check_safe_input): call(admin_org_data.modify_org_remove_user_sql), call(admin_org_data.modify_org_add_rsu_sql), call(admin_org_data.modify_org_remove_rsu_sql), + call(admin_org_data.modify_org_add_intersection_sql), + call(admin_org_data.modify_org_remove_intersection_sql), ] mock_pgquery.assert_has_calls(calls) assert actual_msg == expected_msg @@ -282,20 +286,52 @@ def test_delete_org(mock_query_db, mock_write_db): mock_write_db.assert_has_calls(calls) assert actual_result == expected_result + @patch("api.src.admin_org.pgquery.query_db") def test_delete_org_failure_orphan_rsu(mock_query_db): - mock_query_db.return_value = [[{"user_id": 1, "count": 2}], [{"user_id": 2, "count": 1}]] - expected_result = {"message": "Cannot delete organization that has one or more RSUs only associated with this organization"}, 400 + mock_query_db.return_value = [ + [{"user_id": 1, "count": 2}], + [{"user_id": 2, "count": 1}], + ] + expected_result = { + "message": "Cannot delete organization that has one or more RSUs only associated with this organization" + }, 400 + actual_result = admin_org.delete_org("test org") + + assert actual_result == expected_result + + +@patch("api.src.admin_org.pgquery.query_db") +@patch("api.src.admin_org.check_orphan_rsus") +@patch("api.src.admin_org.check_orphan_intersections") +def test_delete_org_failure_orphan_user( + mock_orphan_intersections, mock_orphan_rsus, mock_query_db +): + mock_orphan_intersections.return_value = False + mock_orphan_rsus.return_value = False + mock_query_db.return_value = [ + [{"user_id": 1, "count": 2}], + [{"user_id": 2, "count": 1}], + ] + expected_result = { + "message": "Cannot delete organization that has one or more users only associated with this organization" + }, 400 actual_result = admin_org.delete_org("test org") assert actual_result == expected_result + @patch("api.src.admin_org.pgquery.query_db") @patch("api.src.admin_org.check_orphan_rsus") -def test_delete_org_failure_orphan_user(mock_orphan_rsus, mock_query_db): +def test_delete_org_failure_orphan_intersection(mock_orphan_rsus, mock_query_db): mock_orphan_rsus.return_value = False - mock_query_db.return_value = [[{"user_id": 1, "count": 2}], [{"user_id": 2, "count": 1}]] - expected_result = {"message": "Cannot delete organization that has one or more users only associated with this organization"}, 400 + mock_query_db.return_value = [ + [{"user_id": 1, "count": 2}], + [{"user_id": 2, "count": 1}], + ] + expected_result = { + "message": "Cannot delete organization that has one or more Intersections only associated with this organization" + }, 400 actual_result = admin_org.delete_org("test org") - assert actual_result == expected_result \ No newline at end of file + assert actual_result == expected_result diff --git a/webapp/src/apis/example_responses/admin-org_get.json b/webapp/src/apis/example_responses/admin-org_get.json index 8f4ae4ef..7e628498 100644 --- a/webapp/src/apis/example_responses/admin-org_get.json +++ b/webapp/src/apis/example_responses/admin-org_get.json @@ -1,7 +1,8 @@ { "org_data": { "org_users": [{ "email": "test@test.com", "first_name": "test", "last_name": "test", "role": "admin" }], - "org_rsus": [{ "ip": "172.16.28.92", "primary_route": "I25", "milepost": 212.05 }] + "org_rsus": [{ "ip": "172.16.28.92", "primary_route": "I25", "milepost": 212.05 }], + "org_intersections": [{ "intersection_id": 1108, "intersection_name": "", "origin_ip": "172.16.28.92" }] }, "allowed_selections": { "user_roles": ["user", "operator", "admin"] } } From 4152487ad1c5599d5ce57dcdae53761dcbb4a9af Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 10 Oct 2024 12:05:03 -0600 Subject: [PATCH 06/12] Update CVManager_CreateTables.sql --- resources/sql_scripts/CVManager_CreateTables.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/sql_scripts/CVManager_CreateTables.sql b/resources/sql_scripts/CVManager_CreateTables.sql index f8a24965..a9b246df 100644 --- a/resources/sql_scripts/CVManager_CreateTables.sql +++ b/resources/sql_scripts/CVManager_CreateTables.sql @@ -308,7 +308,6 @@ CREATE TABLE IF NOT EXISTS public.rsu_organization ON DELETE NO ACTION ); --- TODO: should combine with intersection table? CREATE TABLE IF NOT EXISTS public.map_info ( ipv4_address inet NOT NULL, From cfdec82aa05567d70c2d5fb853c1d2f298fb1840 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 07:35:45 -0600 Subject: [PATCH 07/12] Creating intersection api unit tests --- .../api/tests/data/admin_intersection_data.py | 142 +++++++++ .../tests/data/admin_new_intersection_data.py | 32 ++ .../api/tests/src/test_admin_intersection.py | 285 ++++++++++++++++++ .../tests/src/test_admin_new_intersection.py | 111 +++++++ 4 files changed, 570 insertions(+) create mode 100644 services/api/tests/data/admin_intersection_data.py create mode 100644 services/api/tests/data/admin_new_intersection_data.py create mode 100644 services/api/tests/src/test_admin_intersection.py create mode 100644 services/api/tests/src/test_admin_new_intersection.py diff --git a/services/api/tests/data/admin_intersection_data.py b/services/api/tests/data/admin_intersection_data.py new file mode 100644 index 00000000..33ab6db2 --- /dev/null +++ b/services/api/tests/data/admin_intersection_data.py @@ -0,0 +1,142 @@ +import multidict + +##################################### request data ########################################### + +request_environ = multidict.MultiDict([]) + +request_args_rsu_good = {"rsu_ip": "10.0.0.1"} +request_args_all_good = {"rsu_ip": "all"} +request_args_str_bad = {"rsu_ip": 5} +request_args_ipv4_bad = {"rsu_ip": "test"} + +request_json_good = { + "intersection_id": "10.0.0.1", + "ref_pt": {"longitude": -100.0, "latitude": 38.0}, + "intersection_name": "test intersection", + "origin_ip": "10.0.0.1", + "organizations_to_add": ["Test Org2"], + "organizations_to_remove": ["Test Org1"], + "rsus_to_add": ["1.1.1.1"], + "rsus_to_remove": ["1.1.1.2"], +} + +request_json_bad = { + "intersection_id": "10.0.0.1", + "ref_pt": {"longitude": "test", "latitude": 38.0}, + "intersection_name": "test intersection", + "origin_ip": "10.0.0.1", + "organizations_to_add": ["Test Org2"], + "organizations_to_remove": ["Test Org1"], + "rsus_to_add": ["1.1.1.1"], + "rsus_to_remove": ["1.1.1.2"], +} + + +##################################### function data ########################################### + +get_rsu_data_return = [ + ( + { + "ipv4_address": "10.11.81.12", + "latitude": 45, + "longitude": 45, + "milepost": 45, + "primary_route": "test route", + "serial_number": "test", + "model": "test", + "iss_scms_id": "test", + "ssh_credential": "ssh test", + "snmp_credential": "snmp test", + "snmp_version": "snmp test", + "org_name": "test org", + }, + ), +] + +expected_get_rsu_all = [ + { + "ip": "10.11.81.12", + "geo_position": {"latitude": 45, "longitude": 45}, + "milepost": 45, + "primary_route": "test route", + "serial_number": "test", + "model": "test", + "scms_id": "test", + "ssh_credential_group": "ssh test", + "snmp_credential_group": "snmp test", + "snmp_version_group": "snmp test", + "organizations": ["test org"], + } +] + +expected_get_rsu_qeury_all = ( + "SELECT to_jsonb(row) " + "FROM (" + "SELECT ipv4_address, ST_X(geography::geometry) AS longitude, ST_Y(geography::geometry) AS latitude, " + "milepost, primary_route, serial_number, iss_scms_id, concat(man.name, ' ',rm.name) AS model, " + "rsu_cred.nickname AS ssh_credential, snmp_cred.nickname AS snmp_credential, snmp_ver.nickname AS snmp_version, org.name AS org_name " + "FROM public.rsus " + "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rsus.model " + "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " + "JOIN public.rsu_credentials AS rsu_cred ON rsu_cred.credential_id = rsus.credential_id " + "JOIN public.snmp_credentials AS snmp_cred ON snmp_cred.snmp_credential_id = rsus.snmp_credential_id " + "JOIN public.snmp_versions AS snmp_ver ON snmp_ver.snmp_version_id = rsus.snmp_version_id " + "JOIN public.rsu_organization AS ro ON ro.rsu_id = rsus.rsu_id " + "JOIN public.organizations AS org ON org.organization_id = ro.organization_id" + ") as row" +) + +expected_get_rsu_qeury_one = ( + "SELECT to_jsonb(row) " + "FROM (" + "SELECT ipv4_address, ST_X(geography::geometry) AS longitude, ST_Y(geography::geometry) AS latitude, " + "milepost, primary_route, serial_number, iss_scms_id, concat(man.name, ' ',rm.name) AS model, " + "rsu_cred.nickname AS ssh_credential, snmp_cred.nickname AS snmp_credential, snmp_ver.nickname AS snmp_version, org.name AS org_name " + "FROM public.rsus " + "JOIN public.rsu_models AS rm ON rm.rsu_model_id = rsus.model " + "JOIN public.manufacturers AS man ON man.manufacturer_id = rm.manufacturer " + "JOIN public.rsu_credentials AS rsu_cred ON rsu_cred.credential_id = rsus.credential_id " + "JOIN public.snmp_credentials AS snmp_cred ON snmp_cred.snmp_credential_id = rsus.snmp_credential_id " + "JOIN public.snmp_versions AS snmp_ver ON snmp_ver.snmp_version_id = rsus.snmp_version_id " + "JOIN public.rsu_organization AS ro ON ro.rsu_id = rsus.rsu_id " + "JOIN public.organizations AS org ON org.organization_id = ro.organization_id" + " WHERE ipv4_address = '10.11.81.12'" + ") as row" +) + +modify_rsu_sql = ( + "UPDATE public.rsus SET " + f"geography=ST_GeomFromText('POINT(-100.0 38.0)'), " + f"milepost=900.1, " + f"ipv4_address='10.0.0.1', " + f"serial_number='test', " + f"primary_route='Test Route', " + f"model=(SELECT rsu_model_id FROM public.rsu_models WHERE name = 'model'), " + f"credential_id=(SELECT credential_id FROM public.rsu_credentials WHERE nickname = 'test'), " + f"snmp_credential_id=(SELECT snmp_credential_id FROM public.snmp_credentials WHERE nickname = 'test'), " + f"snmp_version_id=(SELECT snmp_version_id FROM public.snmp_versions WHERE nickname = 'test'), " + f"iss_scms_id='test' " + f"WHERE ipv4_address='10.0.0.1'" +) + +add_org_sql = ( + "INSERT INTO public.rsu_organization(rsu_id, organization_id) VALUES" + " (" + "(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.0.0.1'), " + "(SELECT organization_id FROM public.organizations WHERE name = 'Test Org2')" + ")" +) + +remove_org_sql = ( + "DELETE FROM public.rsu_organization WHERE " + "rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.0.0.1') " + "AND organization_id=(SELECT organization_id FROM public.organizations WHERE name = 'Test Org1')" +) + +delete_rsu_calls = [ + "DELETE FROM public.rsu_organization WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.11.81.12')", + "DELETE FROM public.ping WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.11.81.12')", + "DELETE FROM public.scms_health WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.11.81.12')", + "DELETE FROM public.snmp_msgfwd_config WHERE rsu_id=(SELECT rsu_id FROM public.rsus WHERE ipv4_address = '10.11.81.12')", + "DELETE FROM public.rsus WHERE ipv4_address = '10.11.81.12'", +] diff --git a/services/api/tests/data/admin_new_intersection_data.py b/services/api/tests/data/admin_new_intersection_data.py new file mode 100644 index 00000000..5ed22357 --- /dev/null +++ b/services/api/tests/data/admin_new_intersection_data.py @@ -0,0 +1,32 @@ +import multidict + +##################################### request_data ########################################### + +request_params_good = multidict.MultiDict([]) + +request_json_good = { + "intersection_id": "1", + "ref_pt": {"longitude": -100.0, "latitude": 38.0}, + "origin_ip": "10.0.0.1", + "intersection_name": "test intersection", + "organizations": ["Test Org"], + "organizations": ["10.0.0.1"], +} + +request_json_bad = { + "intersection_id": "1", + "ref_pt": {"longitude": -100.0}, + "origin_ip": "10.0.0.1", + "intersection_name": "test intersection", + "organizations": ["Test Org"], + "organizations": ["10.0.0.1"], +} + +bad_input_check_safe_input = { + "intersection_id": "1", + "ref_pt": {"longitude": -100.0}, + "origin_ip": "10.0.0.1", + "intersection_name": "test intersection--", + "organizations": ["Test Org"], + "organizations": ["10.0.0.1"], +} diff --git a/services/api/tests/src/test_admin_intersection.py b/services/api/tests/src/test_admin_intersection.py new file mode 100644 index 00000000..5dd4a871 --- /dev/null +++ b/services/api/tests/src/test_admin_intersection.py @@ -0,0 +1,285 @@ +from unittest.mock import patch, MagicMock, call +import pytest +import api.src.admin_rsu as admin_rsu +import api.tests.data.admin_rsu_data as admin_rsu_data +import sqlalchemy +from werkzeug.exceptions import HTTPException + +###################################### Testing Requests ########################################## + +# OPTIONS endpoint test + + +def test_request_options(): + info = admin_rsu.AdminRsu() + (body, code, headers) = info.options() + assert body == "" + assert code == 204 + assert headers["Access-Control-Allow-Methods"] == "GET,PATCH,DELETE" + + +# GET endpoint tests + + +@patch("api.src.admin_rsu.get_modify_rsu_data") +def test_entry_get_rsu(mock_get_modify_rsu_data): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.args = admin_rsu_data.request_args_rsu_good + mock_get_modify_rsu_data.return_value = {} + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + (body, code, headers) = status.get() + + mock_get_modify_rsu_data.assert_called_once_with( + admin_rsu_data.request_args_rsu_good["rsu_ip"] + ) + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +@patch("api.src.admin_rsu.get_modify_rsu_data") +def test_entry_get_all(mock_get_modify_rsu_data): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.args = admin_rsu_data.request_args_all_good + mock_get_modify_rsu_data.return_value = {} + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + (body, code, headers) = status.get() + + mock_get_modify_rsu_data.assert_called_once_with( + admin_rsu_data.request_args_all_good["rsu_ip"] + ) + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +# Test schema for string value +def test_entry_get_schema_str(): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.json = admin_rsu_data.request_args_str_bad + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + with pytest.raises(HTTPException): + status.get() + + +# Test schema for IPv4 string if not "all" +def test_entry_get_schema_ipv4(): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.json = admin_rsu_data.request_args_ipv4_bad + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + with pytest.raises(HTTPException): + status.get() + + +# PATCH endpoint tests + + +@patch("api.src.admin_rsu.modify_rsu") +def test_entry_patch(mock_modify_rsu): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.json = admin_rsu_data.request_json_good + mock_modify_rsu.return_value = {}, 200 + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + (body, code, headers) = status.patch() + + mock_modify_rsu.assert_called_once() + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +def test_entry_patch_schema(): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.json = admin_rsu_data.request_json_bad + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + with pytest.raises(HTTPException): + status.patch() + + +# DELETE endpoint tests + + +@patch("api.src.admin_rsu.delete_rsu") +def test_entry_delete_rsu(mock_delete_rsu): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.args = admin_rsu_data.request_args_rsu_good + mock_delete_rsu.return_value = {} + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + (body, code, headers) = status.delete() + + mock_delete_rsu.assert_called_once_with( + admin_rsu_data.request_args_rsu_good["rsu_ip"] + ) + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +# Check single schema that requires IPv4 string +def test_entry_delete_schema(): + req = MagicMock() + req.environ = admin_rsu_data.request_environ + req.json = admin_rsu_data.request_args_ipv4_bad + with patch("api.src.admin_rsu.request", req): + status = admin_rsu.AdminRsu() + with pytest.raises(HTTPException): + status.delete() + + +###################################### Testing Functions ########################################## + +# get_rsu_data + + +@patch("api.src.admin_rsu.pgquery.query_db") +def test_get_rsu_data_all(mock_query_db): + mock_query_db.return_value = admin_rsu_data.get_rsu_data_return + expected_rsu_data = admin_rsu_data.expected_get_rsu_all + expected_query = admin_rsu_data.expected_get_rsu_qeury_all + actual_result = admin_rsu.get_rsu_data("all") + + mock_query_db.assert_called_with(expected_query) + assert actual_result == expected_rsu_data + + +@patch("api.src.admin_rsu.pgquery.query_db") +def test_get_rsu_data_rsu(mock_query_db): + mock_query_db.return_value = admin_rsu_data.get_rsu_data_return + expected_rsu_data = admin_rsu_data.expected_get_rsu_all[0] + expected_query = admin_rsu_data.expected_get_rsu_qeury_one + actual_result = admin_rsu.get_rsu_data("10.11.81.12") + + mock_query_db.assert_called_with(expected_query) + assert actual_result == expected_rsu_data + + +@patch("api.src.admin_rsu.pgquery.query_db") +def test_get_rsu_data_none(mock_query_db): + # get RSU should return an empty object if there are no RSUs with specified IP + mock_query_db.return_value = [] + expected_rsu_data = {} + expected_query = admin_rsu_data.expected_get_rsu_qeury_one + actual_result = admin_rsu.get_rsu_data("10.11.81.12") + + mock_query_db.assert_called_with(expected_query) + assert actual_result == expected_rsu_data + + +# get_modify_rsu_data + + +@patch("api.src.admin_rsu.get_rsu_data") +def test_get_modify_rsu_data_all(mock_get_rsu_data): + mock_get_rsu_data.return_value = ["test rsu data"] + expected_rsu_data = {"rsu_data": ["test rsu data"]} + actual_result = admin_rsu.get_modify_rsu_data("all") + + assert actual_result == expected_rsu_data + + +@patch("api.src.admin_rsu.admin_new_rsu.get_allowed_selections") +@patch("api.src.admin_rsu.get_rsu_data") +def test_get_modify_rsu_data_rsu(mock_get_rsu_data, mock_get_allowed_selections): + mock_get_allowed_selections.return_value = "test selections" + mock_get_rsu_data.return_value = "test rsu data" + expected_rsu_data = { + "rsu_data": "test rsu data", + "allowed_selections": "test selections", + } + actual_result = admin_rsu.get_modify_rsu_data("10.11.81.13") + + assert actual_result == expected_rsu_data + + +# modify_rsu + + +@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") +@patch("api.src.admin_rsu.pgquery.write_db") +def test_modify_rsu_success(mock_pgquery, mock_check_safe_input): + mock_check_safe_input.return_value = True + expected_msg, expected_code = {"message": "RSU successfully modified"}, 200 + actual_msg, actual_code = admin_rsu.modify_rsu(admin_rsu_data.request_json_good) + + calls = [ + call(admin_rsu_data.modify_rsu_sql), + call(admin_rsu_data.add_org_sql), + call(admin_rsu_data.remove_org_sql), + ] + mock_pgquery.assert_has_calls(calls) + assert actual_msg == expected_msg + assert actual_code == expected_code + + +@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") +@patch("api.src.admin_rsu.pgquery.write_db") +def test_modify_rsu_check_fail(mock_pgquery, mock_check_safe_input): + mock_check_safe_input.return_value = False + expected_msg, expected_code = { + "message": "No special characters are allowed: !\"#$%&'()*+,./:;<=>?@[\\]^`{|}~. No sequences of '-' characters are allowed" + }, 500 + actual_msg, actual_code = admin_rsu.modify_rsu(admin_rsu_data.request_json_good) + + calls = [] + mock_pgquery.assert_has_calls(calls) + assert actual_msg == expected_msg + assert actual_code == expected_code + + +@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") +@patch("api.src.admin_rsu.pgquery.write_db") +def test_modify_rsu_generic_exception(mock_pgquery, mock_check_safe_input): + mock_check_safe_input.return_value = True + mock_pgquery.side_effect = Exception("Test") + expected_msg, expected_code = {"message": "Encountered unknown issue"}, 500 + actual_msg, actual_code = admin_rsu.modify_rsu(admin_rsu_data.request_json_good) + + assert actual_msg == expected_msg + assert actual_code == expected_code + + +@patch("api.src.admin_rsu.admin_new_rsu.check_safe_input") +@patch("api.src.admin_rsu.pgquery.write_db") +def test_modify_rsu_sql_exception(mock_pgquery, mock_check_safe_input): + mock_check_safe_input.return_value = True + orig = MagicMock() + orig.args = ({"D": "SQL issue encountered"},) + mock_pgquery.side_effect = sqlalchemy.exc.IntegrityError("", {}, orig) + expected_msg, expected_code = {"message": "SQL issue encountered"}, 500 + actual_msg, actual_code = admin_rsu.modify_rsu(admin_rsu_data.request_json_good) + + assert actual_msg == expected_msg + assert actual_code == expected_code + + +# delete_rsu + + +@patch("api.src.admin_rsu.pgquery.write_db") +def test_delete_rsu(mock_write_db): + expected_result = {"message": "RSU successfully deleted"} + actual_result = admin_rsu.delete_rsu("10.11.81.12") + + calls = [ + call(admin_rsu_data.delete_rsu_calls[0]), + call(admin_rsu_data.delete_rsu_calls[1]), + call(admin_rsu_data.delete_rsu_calls[2]), + call(admin_rsu_data.delete_rsu_calls[3]), + call(admin_rsu_data.delete_rsu_calls[4]) + ] + mock_write_db.assert_has_calls(calls) + assert actual_result == expected_result diff --git a/services/api/tests/src/test_admin_new_intersection.py b/services/api/tests/src/test_admin_new_intersection.py new file mode 100644 index 00000000..a1f3da5c --- /dev/null +++ b/services/api/tests/src/test_admin_new_intersection.py @@ -0,0 +1,111 @@ +from unittest.mock import patch, MagicMock, call +import pytest +import api.src.admin_new_intersection as admin_new_intersection +import api.tests.data.admin_new_intersection_data as admin_new_intersection_data +import sqlalchemy +from werkzeug.exceptions import HTTPException + +###################################### Testing Requests ########################################## + + +def test_request_options(): + info = admin_new_intersection.AdminNewIntersection() + (body, code, headers) = info.options() + assert body == "" + assert code == 204 + assert headers["Access-Control-Allow-Methods"] == "GET,POST" + + +@patch("api.src.admin_new_intersection.get_allowed_selections") +def test_entry_get(mock_get_allowed_selections): + req = MagicMock() + req.environ = admin_new_intersection_data.request_params_good + mock_get_allowed_selections.return_value = {} + with patch("api.src.admin_new_intersection.request", req): + status = admin_new_intersection.AdminNewIntersection() + (body, code, headers) = status.get() + + mock_get_allowed_selections.assert_called_once() + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +@patch("api.src.admin_new_intersection.add_intersection") +def test_entry_post(mock_add_intersection): + req = MagicMock() + req.environ = admin_new_intersection_data.request_params_good + req.json = admin_new_intersection_data.request_json_good + mock_add_intersection.return_value = {}, 200 + with patch("api.src.admin_new_intersection.request", req): + status = admin_new_intersection.AdminNewIntersection() + (body, code, headers) = status.post() + + mock_add_intersection.assert_called_once() + assert code == 200 + assert headers["Access-Control-Allow-Origin"] == "test.com" + assert body == {} + + +def test_entry_post_schema(): + req = MagicMock() + req.environ = admin_new_intersection_data.request_params_good + req.json = admin_new_intersection_data.request_json_bad + with patch("api.src.admin_new_intersection.request", req): + status = admin_new_intersection.AdminNewIntersection() + with pytest.raises(HTTPException): + status.post() + + +###################################### Testing Functions ########################################## + + +@patch("api.src.admin_new_intersection.pgquery") +def test_query_and_return_list(mock_pgquery): + # sqlalchemy returns a list of tuples. This test replicates the tuple list + mock_pgquery.query_db.return_value = [ + ( + "AAA", + "BBB", + ), + ("CCC",), + ] + expected_intersection_data = ["AAA BBB", "CCC"] + expected_query = "SELECT * FROM test" + actual_result = admin_new_intersection.query_and_return_list("SELECT * FROM test") + + mock_pgquery.query_db.assert_called_with(expected_query) + assert actual_result == expected_intersection_data + + +@patch("api.src.admin_new_intersection.query_and_return_list") +def test_get_allowed_selections(mock_query_and_return_list): + mock_query_and_return_list.return_value = ["test"] + expected_result = { + "organizations": ["test"], + "rsus": ["test"], + } + actual_result = admin_new_intersection.get_allowed_selections() + + calls = [ + call("SELECT name FROM public.organizations ORDER BY name ASC"), + call("SELECT name FROM public.rsus ORDER BY ipv4_address ASC"), + ] + mock_query_and_return_list.assert_has_calls(calls) + assert actual_result == expected_result + + +def test_check_safe_input(): + expected_result = True + actual_result = admin_new_intersection.check_safe_input( + admin_new_intersection_data.request_json_good + ) + assert actual_result == expected_result + + +def test_check_safe_input_bad(): + expected_result = False + actual_result = admin_new_intersection.check_safe_input( + admin_new_intersection_data.bad_input_check_safe_input + ) + assert actual_result == expected_result From 83ba1139d544aed76cd1273c2a41c7212b685e2c Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 07:48:47 -0600 Subject: [PATCH 08/12] Finalizing intersection api unit tests --- services/api/src/admin_new_intersection.py | 2 +- services/api/tests/data/admin_new_intersection_data.py | 10 +++++----- services/api/tests/src/test_admin_new_intersection.py | 4 +++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/services/api/src/admin_new_intersection.py b/services/api/src/admin_new_intersection.py index c82616e3..c65bcc81 100644 --- a/services/api/src/admin_new_intersection.py +++ b/services/api/src/admin_new_intersection.py @@ -47,7 +47,7 @@ def check_safe_input(intersection_spec): return False else: if (k in unchecked_fields) or (value is None): - return True + continue if any(c in special_characters for c in str(value)) or "--" in str(value): return False return True diff --git a/services/api/tests/data/admin_new_intersection_data.py b/services/api/tests/data/admin_new_intersection_data.py index 5ed22357..79e26cd4 100644 --- a/services/api/tests/data/admin_new_intersection_data.py +++ b/services/api/tests/data/admin_new_intersection_data.py @@ -5,12 +5,12 @@ request_params_good = multidict.MultiDict([]) request_json_good = { - "intersection_id": "1", + "intersection_id": 1, "ref_pt": {"longitude": -100.0, "latitude": 38.0}, "origin_ip": "10.0.0.1", "intersection_name": "test intersection", "organizations": ["Test Org"], - "organizations": ["10.0.0.1"], + "rsus": ["10.0.0.1"], } request_json_bad = { @@ -19,14 +19,14 @@ "origin_ip": "10.0.0.1", "intersection_name": "test intersection", "organizations": ["Test Org"], - "organizations": ["10.0.0.1"], + "rsus": ["10.0.0.1"], } bad_input_check_safe_input = { - "intersection_id": "1", + "intersection_id": 1, "ref_pt": {"longitude": -100.0}, "origin_ip": "10.0.0.1", "intersection_name": "test intersection--", "organizations": ["Test Org"], - "organizations": ["10.0.0.1"], + "rsus": ["10.0.0.1"], } diff --git a/services/api/tests/src/test_admin_new_intersection.py b/services/api/tests/src/test_admin_new_intersection.py index a1f3da5c..ee53d430 100644 --- a/services/api/tests/src/test_admin_new_intersection.py +++ b/services/api/tests/src/test_admin_new_intersection.py @@ -89,7 +89,9 @@ def test_get_allowed_selections(mock_query_and_return_list): calls = [ call("SELECT name FROM public.organizations ORDER BY name ASC"), - call("SELECT name FROM public.rsus ORDER BY ipv4_address ASC"), + call( + "SELECT ipv4_address::text AS ipv4_address FROM public.rsus ORDER BY ipv4_address ASC" + ), ] mock_query_and_return_list.assert_has_calls(calls) assert actual_result == expected_result From 9b1ecf36a57f7e13ca17b1e3c2040a95f5c9c5d9 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 07:50:33 -0600 Subject: [PATCH 09/12] Create intersection_update.sql --- .../update_scripts/intersection_update.sql | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 resources/sql_scripts/update_scripts/intersection_update.sql diff --git a/resources/sql_scripts/update_scripts/intersection_update.sql b/resources/sql_scripts/update_scripts/intersection_update.sql new file mode 100644 index 00000000..0001d4ae --- /dev/null +++ b/resources/sql_scripts/update_scripts/intersection_update.sql @@ -0,0 +1,67 @@ +CREATE SCHEMA IF NOT EXISTS keycloak; + +-- Intersections +CREATE SEQUENCE public.intersections_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersections +( + intersection_id integer NOT NULL DEFAULT nextval('intersections_intersection_id_seq'::regclass), + intersection_number character varying(128) NOT NULL, + ref_pt GEOGRAPHY(POINT, 4326) NOT NULL, + bbox GEOGRAPHY(POLYGON, 4326), + intersection_name character varying(128), + origin_ip inet, + CONSTRAINT intersection_pkey PRIMARY KEY (intersection_id), + CONSTRAINT intersection_intersection_number UNIQUE (intersection_number) +); + +CREATE SEQUENCE public.intersection_organization_intersection_organization_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.intersection_organization +( + intersection_organization_id integer NOT NULL DEFAULT nextval('intersection_organization_intersection_organization_id_seq'::regclass), + intersection_id integer NOT NULL, + organization_id integer NOT NULL, + CONSTRAINT intersection_organization_pkey PRIMARY KEY (intersection_organization_id), + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_organization_id FOREIGN KEY (organization_id) + REFERENCES public.organizations (organization_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); + +CREATE SEQUENCE public.rsu_intersection_rsu_intersection_id_seq + INCREMENT 1 + START 1 + MINVALUE 1 + MAXVALUE 2147483647 + CACHE 1; + +CREATE TABLE IF NOT EXISTS public.rsu_intersection +( + rsu_intersection_id integer NOT NULL DEFAULT nextval('rsu_intersection_rsu_intersection_id_seq'::regclass), + rsu_id integer NOT NULL, + intersection_id integer NOT NULL, + CONSTRAINT rsu_intersection_pkey PRIMARY KEY (rsu_intersection_id), + CONSTRAINT fk_rsu_id FOREIGN KEY (rsu_id) + REFERENCES public.rsus (rsu_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION, + CONSTRAINT fk_intersection_id FOREIGN KEY (intersection_id) + REFERENCES public.intersections (intersection_id) MATCH SIMPLE + ON UPDATE NO ACTION + ON DELETE NO ACTION +); \ No newline at end of file From da1537f893bc13e101f5cab6e37aade47ff8f2f5 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 08:24:56 -0600 Subject: [PATCH 10/12] enabling editing of intersection ID --- services/api/src/admin_intersection.py | 8 ++++++-- .../adminEditIntersection/AdminEditIntersection.tsx | 3 +++ .../adminEditIntersectionSlice.test.ts | 4 ++-- .../adminEditIntersection/adminEditIntersectionSlice.tsx | 4 ++-- webapp/src/models/Intersection.d.ts | 1 + 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/services/api/src/admin_intersection.py b/services/api/src/admin_intersection.py index fe87dd36..fe7c867c 100644 --- a/services/api/src/admin_intersection.py +++ b/services/api/src/admin_intersection.py @@ -88,6 +88,7 @@ def modify_intersection(intersection_spec): # Modify the existing Intersection data query = ( "UPDATE public.intersections SET " + f"intersection_number='{intersection_spec['intersection_id']}'), " f"ref_pt=ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})')" ) if "bbox" in intersection_spec: @@ -96,7 +97,9 @@ def modify_intersection(intersection_spec): query += f", intersection_name='{intersection_spec['intersection_name']}'" if "origin_ip" in intersection_spec: query += f", origin_ip='{intersection_spec['origin_ip']}'" - query += f"WHERE intersection_number='{intersection_spec['intersection_id']}'" + query += ( + f"WHERE intersection_number='{intersection_spec['orig_intersection_id']}'" + ) pgquery.write_db(query) # Add the intersection-to-organization relationships for the organizations to add @@ -105,7 +108,7 @@ def modify_intersection(intersection_spec): for organization in intersection_spec["organizations_to_add"]: org_add_query += ( " (" - f"(SELECT intersection_id FROM public.intersections WHERE ipv4_address = '{intersection_spec['intersection_id']}'), " + f"(SELECT intersection_id FROM public.intersections WHERE intersection_number = '{intersection_spec['intersection_id']}'), " f"(SELECT organization_id FROM public.organizations WHERE name = '{organization}')" ")," ) @@ -209,6 +212,7 @@ class GeoPolygonSchema(Schema): class AdminIntersectionPatchSchema(Schema): + orig_intersection_id = fields.Integer(required=True) intersection_id = fields.Integer(required=True) ref_pt = fields.Nested(GeoPositionSchema, required=True) bbox = fields.Nested(GeoPolygonSchema, required=False) diff --git a/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx index ee117bb4..02455d2a 100644 --- a/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx +++ b/webapp/src/features/adminEditIntersection/AdminEditIntersection.tsx @@ -29,6 +29,7 @@ import { Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@ import toast from 'react-hot-toast' export type AdminEditIntersectionFormType = { + orig_intersection_id: string intersection_id: string ref_pt: { latitude: string @@ -70,6 +71,7 @@ const AdminEditIntersection = () => { setValue, } = useForm({ defaultValues: { + orig_intersection_id: '', intersection_id: '', ref_pt: { latitude: '', @@ -107,6 +109,7 @@ const AdminEditIntersection = () => { (intersection: AdminIntersection) => intersection.intersection_id === intersectionId ) if (currIntersection) { + setValue('orig_intersection_id', currIntersection.intersection_id) setValue('intersection_id', currIntersection.intersection_id) setValue('ref_pt.latitude', currIntersection.ref_pt?.latitude?.toString()) setValue('ref_pt.longitude', currIntersection.ref_pt?.longitude?.toString()) diff --git a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts index 43063759..e0851aa7 100644 --- a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts +++ b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.test.ts @@ -174,7 +174,7 @@ describe('async thunks', () => { expect(apiHelper._patchData).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token: 'token', - query_params: { intersection_id: json.intersection_id }, + query_params: { intersection_id: json.orig_intersection_id }, body: JSON.stringify(json), }) expect(dispatch).toHaveBeenCalledTimes(1 + 2) @@ -192,7 +192,7 @@ describe('async thunks', () => { expect(apiHelper._patchData).toHaveBeenCalledWith({ url: EnvironmentVars.adminIntersection, token: 'token', - query_params: { intersection_id: json.intersection_id }, + query_params: { intersection_id: json.orig_intersection_id }, body: JSON.stringify(json), }) expect(setTimeout).not.toHaveBeenCalled() diff --git a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx index 8a6eec5f..f5585095 100644 --- a/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx +++ b/webapp/src/features/adminEditIntersection/adminEditIntersectionSlice.tsx @@ -118,14 +118,14 @@ export const getIntersectionInfo = createAsyncThunk( export const editIntersection = createAsyncThunk( 'adminEditIntersection/editIntersection', - async (json: { intersection_id: string }, { getState, dispatch }) => { + async (json: { orig_intersection_id: string }, { getState, dispatch }) => { const currentState = getState() as RootState const token = selectToken(currentState) const data = await apiHelper._patchData({ url: EnvironmentVars.adminIntersection, token, - query_params: { intersection_id: json.intersection_id }, + query_params: { intersection_id: json.orig_intersection_id }, body: JSON.stringify(json), }) diff --git a/webapp/src/models/Intersection.d.ts b/webapp/src/models/Intersection.d.ts index 3d903e22..d7d9688a 100644 --- a/webapp/src/models/Intersection.d.ts +++ b/webapp/src/models/Intersection.d.ts @@ -1,4 +1,5 @@ export type AdminIntersection = { + orig_intersection_id: string intersection_id: string ref_pt: { latitude: string From 3a897663833c107cc413a2f4bfc77cea16dfaf31 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 08:37:15 -0600 Subject: [PATCH 11/12] Update admin_intersection.py --- services/api/src/admin_intersection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/src/admin_intersection.py b/services/api/src/admin_intersection.py index fe7c867c..bc8cb6b1 100644 --- a/services/api/src/admin_intersection.py +++ b/services/api/src/admin_intersection.py @@ -88,7 +88,7 @@ def modify_intersection(intersection_spec): # Modify the existing Intersection data query = ( "UPDATE public.intersections SET " - f"intersection_number='{intersection_spec['intersection_id']}'), " + f"intersection_number='{intersection_spec['intersection_id']}', " f"ref_pt=ST_GeomFromText('POINT({str(intersection_spec['ref_pt']['longitude'])} {str(intersection_spec['ref_pt']['latitude'])})')" ) if "bbox" in intersection_spec: @@ -98,7 +98,7 @@ def modify_intersection(intersection_spec): if "origin_ip" in intersection_spec: query += f", origin_ip='{intersection_spec['origin_ip']}'" query += ( - f"WHERE intersection_number='{intersection_spec['orig_intersection_id']}'" + f" WHERE intersection_number='{intersection_spec['orig_intersection_id']}'" ) pgquery.write_db(query) From 4ca8e6621adf476f5a43dfa13f1414fe44f58f75 Mon Sep 17 00:00:00 2001 From: jacob6838 Date: Thu, 17 Oct 2024 08:55:07 -0600 Subject: [PATCH 12/12] Update intersection_update.sql --- resources/sql_scripts/update_scripts/intersection_update.sql | 2 -- 1 file changed, 2 deletions(-) diff --git a/resources/sql_scripts/update_scripts/intersection_update.sql b/resources/sql_scripts/update_scripts/intersection_update.sql index 0001d4ae..ca116ef5 100644 --- a/resources/sql_scripts/update_scripts/intersection_update.sql +++ b/resources/sql_scripts/update_scripts/intersection_update.sql @@ -1,5 +1,3 @@ -CREATE SCHEMA IF NOT EXISTS keycloak; - -- Intersections CREATE SEQUENCE public.intersections_intersection_id_seq INCREMENT 1