diff --git a/src/dve/core_engine/backends/base/rules.py b/src/dve/core_engine/backends/base/rules.py index e24d165..fb44dff 100644 --- a/src/dve/core_engine/backends/base/rules.py +++ b/src/dve/core_engine/backends/base/rules.py @@ -387,20 +387,18 @@ def identify_and_remove_orphans( def process_node( node: HierarchyNode, - parent_entity_name: Optional[EntityName], orph_messages: Messages | None = None, ): - """Recursive helper to process a node and its children.""" - current_entity_name = node.entity_name + """Identify orphans and remove in a given node""" if orph_messages is None: orph_messages = [] - if parent_entity_name is not None: - self.logger.info(f"Identifying orphans in {current_entity_name}") + if node.parent_entity is not None: + self.logger.info(f"Identifying orphans in {node.entity_name}") join_expr = " AND ".join( - f"{parent_entity_name}.{k} = {current_entity_name}.{v}" + f"{node.parent_entity}.{k} = {node.entity_name}.{v}" for k, v in node.join_fields.items() ) @@ -408,15 +406,15 @@ def process_node( entities=entities, config=OrphanIdentification( id=list(node.join_fields.values())[0], - entity_name=current_entity_name, - target_name=parent_entity_name, + entity_name=node.entity_name, + target_name=node.parent_entity, join_condition=join_expr, ), ) if no_orphs > 0: self.logger.info( - f"Removing records with missing parent from {current_entity_name}" + f"Removing records with missing parent from {node.entity_name}" ) location = list(node.join_fields.values())[0] with BackgroundMessageWriter( @@ -428,7 +426,7 @@ def process_node( _orph_records = self.remove_orphans( entities=entities, config=OrphanRemoval( - entity_name=current_entity_name, + entity_name=node.entity_name, reporting=ReportingConfig( emit="record_failure", code=node.missing_parent_id_error_code, @@ -441,7 +439,7 @@ def process_node( msg_writer.write_queue.put( [ FeedbackMessage( - entity=current_entity_name, + entity=node.entity_name, record=record, # type: ignore error_location=location, error_message=node.missing_parent_id_error_message, @@ -455,12 +453,9 @@ def process_node( ] ) - if node.children: - for child_node in node.children: - process_node(child_node, current_entity_name, orph_messages) - - for root_node in entity_hierarchy.entity_trees.values(): - process_node(root_node, parent_entity_name=None) + for tree in entity_hierarchy.entity_trees.values(): + for node in tree.iterate_root_down(): + process_node(node) _orph_rel = entities.get(ORPHANED_RECORD_ENTITY_NAME) if _orph_rel: diff --git a/src/dve/core_engine/configuration/v1/hierarchy.py b/src/dve/core_engine/configuration/v1/hierarchy.py index 55c6df8..3da9f56 100644 --- a/src/dve/core_engine/configuration/v1/hierarchy.py +++ b/src/dve/core_engine/configuration/v1/hierarchy.py @@ -16,6 +16,7 @@ class HierarchyNode(BaseModel): """Stores entity hierarchy information""" entity_name: str + parent_entity: Optional[str] = None children: list["HierarchyNode"] = Field(default_factory=list) mandatory: bool = False join_fields: dict[str, str] = Field(default_factory=dict) @@ -26,14 +27,18 @@ class HierarchyNode(BaseModel): "Records removed due to no valid parent record" ) - def get_descendents(self) -> list[str]: + def get_descendents(self) -> list["HierarchyNode"]: """Recursively list all descendents of the node""" descendents = [] for node in self.children: # type: ignore - descendents.append(node.entity_name) + descendents.append(node) descendents.extend(node.get_descendents()) return descendents + def get_descendent_names(self) -> list[str]: + """Recursively list all names of descendents of the node""" + return [node.entity_name for node in self.get_descendents()] + def get_node(self, entity_name: str) -> Union["HierarchyNode", None]: """Recursively search for node and return if found""" node = None @@ -65,6 +70,20 @@ def as_dict(self) -> dict[str, dict[str, Any]]: return {self.entity_name: ret_dict} + def _get_full_tree(self): + """Get all nodes in tree, including the root""" + desc = self.get_descendents() + desc.insert(0, self) + return desc + + def iterate_root_down(self): + """Iterate through nodes from root to lowest descendent""" + yield from self._get_full_tree() + + def iterate_lowest_descendent_up(self): + """Iterate through nodes from lowest descendent to root""" + yield from self._get_full_tree()[::-1] + class EntityHierarchy: """Determines and stores entity hierarchy information from config""" @@ -83,6 +102,7 @@ def determine_trees( top_level_parents: dict[EntityName, HierarchyNode] = { entity_name: HierarchyNode( entity_name=entity_name, + parent_entity=None, **config.model_dump( exclude={ "parent_entity", @@ -102,6 +122,7 @@ def determine_trees( for entity_name in default_roots: top_level_parents[entity_name] = HierarchyNode( entity_name=entity_name, + parent_entity=None, missing_parent_id_error_code=None, missing_parent_id_error_message=None, ) @@ -110,13 +131,11 @@ def determine_trees( for main_entity, parent_node in top_level_parents.items(): if ( linkage_detail.parent_entity == main_entity - or linkage_detail.parent_entity in parent_node.get_descendents() + or linkage_detail.parent_entity in parent_node.get_descendent_names() ): parent_node.add_child_node( linkage_detail.parent_entity, - HierarchyNode( - entity_name=name, **linkage_detail.model_dump(exclude={"parent_entity"}) - ), + HierarchyNode(entity_name=name, **linkage_detail.model_dump()), ) break else: diff --git a/src/dve/pipeline/utils.py b/src/dve/pipeline/utils.py index 9163684..832baa5 100644 --- a/src/dve/pipeline/utils.py +++ b/src/dve/pipeline/utils.py @@ -60,7 +60,7 @@ def load_reader( if file_extension: err_msg = ( f"The supplied file extension `{file_extension}`" - +f" is not a supported file format for {model_name}." + + f" is not a supported file format for {model_name}." ) else: err_msg = "No supplied file extension. Unable to parse file without a file extension." diff --git a/tests/features/flights.feature b/tests/features/flights.feature index ea710e9..a041feb 100644 --- a/tests/features/flights.feature +++ b/tests/features/flights.feature @@ -10,6 +10,7 @@ Feature: Pipeline tests using the flights dataset When I run the file transformation phase Then the country entity is stored as a parquet after the file_transformation phase And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase And the flights entity is stored as a parquet after the file_transformation phase And the passengers entity is stored as a parquet after the file_transformation phase And the latest audit record for the submission is marked with processing status data_contract @@ -36,19 +37,20 @@ Feature: Pipeline tests using the flights dataset When I run the file transformation phase Then the country entity is stored as a parquet after the file_transformation phase And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase And the flights entity is stored as a parquet after the file_transformation phase And the passengers entity is stored as a parquet after the file_transformation phase And the latest audit record for the submission is marked with processing status data_contract When I run the data contract phase Then there are no file rejections from the data_contract phase - And there are no record rejections from the data_contract phase + And there is 1 record rejection from the data_contract phase When I run the business rules phase Then there are errors with the following details and associated error_count from the business_rules phase | ErrorType | ErrorCode | error_count | - | record | C1 | 1 | - | record | AG1 | 1 | - | record | FG1 | 2 | - | record | PG1 | 4 | + | record | AG1 | 3 | + | record | SG1 | 15 | + | record | FG1 | 10 | + | record | PG1 | 25 | When I run the error report phase Then An error report is produced # TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets @@ -66,6 +68,7 @@ Feature: Pipeline tests using the flights dataset When I run the file transformation phase Then the country entity is stored as a parquet after the file_transformation phase And the airport entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase And the flights entity is stored as a parquet after the file_transformation phase And the passengers entity is stored as a parquet after the file_transformation phase And the latest audit record for the submission is marked with processing status data_contract @@ -76,10 +79,10 @@ Feature: Pipeline tests using the flights dataset Then there are errors with the following details and associated error_count from the business_rules phase | ErrorType | ErrorCode | error_count | | record | F1 | 1 | - | record | PG1 | 2 | + | record | PG1 | 3 | When I run the error report phase Then An error report is produced - # TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets + #TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets # And The statistics entry for the submission shows the following information # | parameter | value | # | record_count | 1 | @@ -95,6 +98,7 @@ Feature: Pipeline tests using the flights dataset Then the country entity is stored as a parquet after the file_transformation phase And the airport entity is stored as a parquet after the file_transformation phase And the flights entity is stored as a parquet after the file_transformation phase + And the staff entity is stored as a parquet after the file_transformation phase And the passengers entity is stored as a parquet after the file_transformation phase And the latest audit record for the submission is marked with processing status data_contract When I run the data contract phase @@ -104,13 +108,14 @@ Feature: Pipeline tests using the flights dataset Then there are errors with the following details and associated error_count from the business_rules phase | ErrorType | ErrorCode | error_count | | record | F1 | 1 | - | record | PG1 | 2 | + | record | PG1 | 3 | | record | P1 | 1 | + | record | S1 | 7 | When I run the error report phase Then An error report is produced - # TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets - # And The statistics entry for the submission shows the following information - # | parameter | value | - # | record_count | 1 | - # | number_file_rejections | 0 | - # | number_record_rejections | 1 | +# TODO - fix the stats calculations as they're currently incorrect for hiearchical datasets +# And The statistics entry for the submission shows the following information +# | parameter | value | +# | record_count | 1 | +# | number_file_rejections | 0 | +# | number_record_rejections | 1 | diff --git a/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py b/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py index ddb9c81..5447e14 100644 --- a/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py +++ b/tests/test_core_engine/test_backends/test_implementations/test_duckdb/test_rules.py @@ -656,9 +656,11 @@ def test_identify_and_remove_orphans(self): children=[ HierarchyNode( entity_name="passengers", + parent_entity="flights", children=[ HierarchyNode( entity_name="food", + parent_entity="passengers", children=[], join_fields={"passenger_id": "passenger_id"}, mandatory=False diff --git a/tests/test_core_engine/test_hierarchy.py b/tests/test_core_engine/test_hierarchy.py index 0128a38..edf7d4c 100644 --- a/tests/test_core_engine/test_hierarchy.py +++ b/tests/test_core_engine/test_hierarchy.py @@ -260,8 +260,9 @@ def test_linkage_config_load(): assert not children_001[0].children assert children_001[1].entity_name == "ds_101" assert dict_rep_001 == json.loads(""" - { +{ "ds_001": { + "parent_entity": null, "join_fields": {}, "mandatory": false, "no_valid_records_error_code": "NoValidRecords", @@ -270,6 +271,7 @@ def test_linkage_config_load(): "missing_parent_id_error_message": null, "children": { "ds_003": { + "parent_entity": "ds_001", "join_fields": { "ds_001_id": "ds_001_id" }, @@ -281,6 +283,7 @@ def test_linkage_config_load(): "children": {} }, "ds_101": { + "parent_entity": "ds_001", "join_fields": { "ds_001_id": "ds_001_id" }, @@ -291,6 +294,7 @@ def test_linkage_config_load(): "missing_parent_id_error_message": "record removed as no parent", "children": { "ds_201": { + "parent_entity": "ds_101", "join_fields": { "referral_id": "ds_101_id" }, @@ -301,6 +305,7 @@ def test_linkage_config_load(): "missing_parent_id_error_message": "record removed as no parent", "children": { "ds_202": { + "parent_entity": "ds_201", "join_fields": { "ds_201_id": "ds_201_id" }, @@ -327,7 +332,7 @@ def test_linkage_config_load(): assert children_101[0].children[0].entity_name == "ds_202" assert not children_101[0].children[0].children assert dict_rep_101 == json.loads(""" - { + { "parent_entity": "ds_001", "join_fields": { "ds_001_id": "ds_001_id" }, @@ -338,6 +343,7 @@ def test_linkage_config_load(): "missing_parent_id_error_message": "record removed as no parent", "children": { "ds_201": { + "parent_entity": "ds_101", "join_fields": { "referral_id": "ds_101_id" }, @@ -348,6 +354,7 @@ def test_linkage_config_load(): "missing_parent_id_error_message": "record removed as no parent", "children": { "ds_202": { + "parent_entity": "ds_201", "join_fields": { "ds_201_id": "ds_201_id" }, diff --git a/tests/testdata/flights/flights.dischema.json b/tests/testdata/flights/flights.dischema.json index f4a7e0c..fd9e94b 100644 --- a/tests/testdata/flights/flights.dischema.json +++ b/tests/testdata/flights/flights.dischema.json @@ -9,6 +9,7 @@ } } }, + "error_details": "flights_contract_error_details.json", "datasets": { "country": { "fields": { @@ -24,7 +25,11 @@ } } }, - "key_field": "country_id" + "key_field": "country_id", + "mandatory_fields": [ + "country_id", + "country_name" + ] }, "airport": { "fields": { @@ -44,6 +49,24 @@ }, "key_field": "airport_id" }, + "staff": { + "fields": { + "airport_id": "int", + "staff_id": "int", + "staff_name": "str", + "role": "str" + }, + "reader_config": { + ".xml": { + "reader": "DuckDBXMLStreamReader", + "kwargs": { + "record_tag": "staff_member", + "root_tag": "country" + } + } + }, + "key_field": "staff_id" + }, "flights": { "fields": { "airport_id": "int", @@ -82,17 +105,6 @@ }, "transformations": { "filters": [ - { - "entity": "country", - "name": "country_id_missing", - "expression": "country_id IS NOT NULL", - "failure_type": "record", - "failure_message": "Record Rejected - Country is missing an id", - "reporting_field": "country_id", - "reporting_entity": "country", - "category": "Blank", - "error_code": "C1" - }, { "entity": "flights", "name": "flight_missing_id", @@ -114,6 +126,17 @@ "reporting_entity": "passengers", "category": "Blank", "error_code": "P1" + }, + { + "entity": "staff", + "name": "staff_id_is_null", + "expression": "staff_id IS NOT NULL", + "failure_type": "record", + "failure_message": "Record Rejected - staff_id is missing", + "reporting_field": "passenger_name", + "reporting_entity": "passengers", + "category": "Blank", + "error_code": "S1" } ] }, @@ -127,6 +150,15 @@ "missing_parent_id_error_code": "AG1", "missing_parent_id_error_message": "Group rejected - No valid country group found country" }, + "staff": { + "parent_entity": "airport", + "join_fields": { + "airport_id": "airport_id" + }, + "mandatory": true, + "missing_parent_id_error_code": "SG1", + "missing_parent_id_error_message": "Group rejected - No valid airport group found for staff" + }, "flights": { "parent_entity": "airport", "join_fields": { @@ -134,7 +166,7 @@ }, "mandatory": false, "missing_parent_id_error_code": "FG1", - "missing_parent_id_error_message": "Group rejected - No valid airport group found for airport" + "missing_parent_id_error_message": "Group rejected - No valid airport group found for flight" }, "passengers": { "parent_entity": "flights", diff --git a/tests/testdata/flights/flights_contract_error_details.json b/tests/testdata/flights/flights_contract_error_details.json new file mode 100644 index 0000000..f3c7e3f --- /dev/null +++ b/tests/testdata/flights/flights_contract_error_details.json @@ -0,0 +1,8 @@ +{ + "country_id": { + "Blank": { + "error_code": "C1", + "error_message": "Record Rejected - Country is missing an id" + } + } +} \ No newline at end of file diff --git a/tests/testdata/flights/missing_country_id.xml b/tests/testdata/flights/missing_country_id.xml index 5c73640..14ccec2 100644 --- a/tests/testdata/flights/missing_country_id.xml +++ b/tests/testdata/flights/missing_country_id.xml @@ -23,6 +23,11 @@ 2 Jane + + 1 + 3 + Peter + @@ -32,17 +37,286 @@ 2 - 3 + 4 Homer 2 - 4 + 5 Marge + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + - \ No newline at end of file + diff --git a/tests/testdata/flights/missing_flight_id.xml b/tests/testdata/flights/missing_flight_id.xml index f9969f1..fdd7cf7 100644 --- a/tests/testdata/flights/missing_flight_id.xml +++ b/tests/testdata/flights/missing_flight_id.xml @@ -23,6 +23,11 @@ 2 Jane + + 1 + 3 + Peter + @@ -32,17 +37,286 @@ 2 - 3 + 4 Homer 2 - 4 + 5 Marge + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + - \ No newline at end of file + diff --git a/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml b/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml index 411b2da..3e40b18 100644 --- a/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml +++ b/tests/testdata/flights/mixture_of_group_rej_and_bi_rej.xml @@ -23,6 +23,11 @@ 2 Jane + + 1 + 3 + Peter + @@ -32,22 +37,278 @@ 2 - 3 + 4 Homer 2 - 4 - Marge + 5 + + + + + 1 + 3 + New York + + + 3 + 6 + Lisa - 2 - 5 - + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + 3 + Kevin + Manager + + + 3 + Laura + Pilot + + + 3 + Michael + Air Traffic Controller + + + 3 + Natalie + Ground Crew + + + 3 + Oliver + Security + + + 3 + Paula + Engineer + + - \ No newline at end of file + diff --git a/tests/testdata/flights/perfect_flights.xml b/tests/testdata/flights/perfect_flights.xml index a581a6f..88346dc 100644 --- a/tests/testdata/flights/perfect_flights.xml +++ b/tests/testdata/flights/perfect_flights.xml @@ -24,6 +24,11 @@ 2 Jane + + 1 + 3 + Peter + @@ -33,17 +38,286 @@ 2 - 3 + 4 Homer 2 - 4 + 5 Marge + + 1 + 3 + New York + + + 3 + 6 + Lisa + + + 3 + 7 + Bart + + + 3 + 8 + Maggie + + + + + + + 1 + 1 + Alice + Manager + + + 1 + 2 + Bob + Pilot + + + 1 + 3 + Charlie + Ground Crew + + + 1 + 4 + Diana + Security + + + + + 1 + 2 + Gatwick + RH6 0NP + + + 2 + 4 + Amsterdam + + + 4 + 9 + Oliver + + + 4 + 10 + Emily + + + + + 2 + 5 + Rome + + + 5 + 11 + George + + + 5 + 12 + Charlotte + + + 5 + 13 + Harry + + + + + + 2 + 6 + Dubai + + + 6 + 14 + William + + + 6 + 15 + Amelia + + + + + + + 2 + 5 + Edward + Manager + + + 2 + 6 + Fiona + Air Traffic Controller + + + 2 + 7 + Graham + Ground Crew + + + 2 + 8 + Hannah + Security + + + 2 + 9 + Ian + Engineer + + + + + 1 + 3 + Manchester + M90 1QX + + + 3 + 7 + Dublin + + + 7 + 16 + Jack + + + 7 + 17 + Isla + + + + + 3 + 8 + Lisbon + + + 8 + 18 + Thomas + + + 8 + 19 + Grace + + + 8 + 20 + Jacob + + + + + 3 + 9 + Toronto + + + 9 + 21 + Leo + + + 9 + 22 + Sophie + + + + + 3 + 10 + New York + + + 10 + 23 + Daniel + + + 10 + 24 + Ella + + + 10 + 25 + Oscar + + + + + + 3 + 10 + Kevin + Manager + + + 3 + 11 + Laura + Pilot + + + 3 + 12 + Michael + Air Traffic Controller + + + 3 + 13 + Natalie + Ground Crew + + + 3 + 14 + Oliver + Security + + + 3 + 15 + Paula + Engineer + + - \ No newline at end of file +