diff --git a/README.md b/README.md index 9a9320d..a75b10b 100644 --- a/README.md +++ b/README.md @@ -160,19 +160,41 @@ They are used as "TC-". The value needs to be transferred to nova for # Usage notes - The TripResult used in the OJP fare service should not be based on short-term real-time information. So the TripRequest should usually contain a UseRealtime set to false. -- The price is only in one direction. If the full price in both directions is needed and artificial trip must be constructed, that contains all necessary legs in both direction (and works from the time view): Search A to B, some delay, search B to A, concatenate the trips into one. This IS necessary as sometimes the trip in both direction is cheaper than two single trips. +- The price is only for A to B. If the full price in both directions is needed and artificial trip must be constructed, that contains all necessary legs in both direction (and works from the time view): Search A to B, some delay, search B to A, concatenate the trips into one. This IS necessary as sometimes the trip in both direction is cheaper than two single trips. - We base on the commercial stops (as BPUIC). The calls are more and more based on the SLOID. It is important only provide the commerical stops to NOVA. In some cases the commercial stop is no longer directly based on the other one (e.g. Europaplatz). The right one must be obtained from the PlaceContext (done in `sloid2didok` function). - +- While OJP generelly supports multiple request. The current version of the software only processes the first request. # Testing In the folder `input` there are possible xml files. Some work, some are problematic -The selection of files to use in `test_network_flow.py` is done by `test_configuration.py` which basically contains an array -of the files to use. `test_configuration.py` contains the explanaition on what works and what not. +The selection of files to use in `test_network_flow.py` is done by `test_configuration.json`. -Be aware: For some discount tickets the trip needs to be several days in the future. Currently this needs to be set manually in the respective +Be aware: For some discount tickets the trip needs to be several days in the future. Currently, this needs to be set manually in the respective request file in `input`. We don't use the `2025-10-10T15:30:40` in many cases, as trips in the past don't have prives. If it is omitted, then `now` is used. but we keep it in the files commented out, so that you just can put in the time. +test_configuration.json contains an array of test cases: +- id: the identifier +- description: The description of the test case +- file: the file to be loaded (contains an OJPTripRequest) +- travellers: the information about the travelers + - age + - entitlements: the list of entitlement products (there is only a short list available. Most important HTA) + - typ: PERSON, DOG, xxx + - tkid: not supported yet + - birthday: not supported yet + - gender: not supported yet +- subscription: true or false. one ticket or a subscription +- relationship: a list of relevant relationships between the travelers. not supported yet +- result: pass or fail. Should there be a price. +- assert: if set, then the value provided should be found in the answer. not supported yet +- active: saying if this test is active when running the whole file +- future: if set indicates when in the future should be searched (days). The system then uses a random time between 8:00 and 12:00 to start +- start_time: HH:MM:SS, used to test a night bus. + +`test_network_flow.py` can be used with at most one parameter: +- `--all`: tests all tests in the test file +- `--id=`: tests the test with the given id +- Without parameter all tests that are set to active are tested. # Changelog @@ -182,6 +204,7 @@ If it is omitted, then `now` is used. but we keep it in the files commented out, ## 1.2 prepared for dockerization - Make sure that we can run this in our new environment. +- Fixed more bugs. ## 1.1 Bug fixes, better documentation, better testing - OJP 2.0 support (first version) diff --git a/input/input_Bern_Chur_SOB_Zukunft.xml b/input/input_Bern_Chur_SOB_Zukunft.xml index aa39a71..dd897db 100644 --- a/input/input_Bern_Chur_SOB_Zukunft.xml +++ b/input/input_Bern_Chur_SOB_Zukunft.xml @@ -13,7 +13,7 @@ Bern - 2024-12-08T16:30:40 --> + diff --git a/input/input_Bern_Zweisimmen_BLS_Zukunft.xml b/input/input_Bern_Zweisimmen_BLS_Zukunft.xml index e4ce0bc..a277a5a 100644 --- a/input/input_Bern_Zweisimmen_BLS_Zukunft.xml +++ b/input/input_Bern_Zweisimmen_BLS_Zukunft.xml @@ -13,8 +13,7 @@ Bern - 2025-11-24T15:30:40 - + 2026-03-10T15:30:40 diff --git a/input/input_Mattelift.xml b/input/input_Mattelift.xml new file mode 100644 index 0000000..48d2492 --- /dev/null +++ b/input/input_Mattelift.xml @@ -0,0 +1,39 @@ + + + + + + de + + 2026-03-03T12:36:55.154Z + OJP_DemoApp_Beta_OJP2.0 + + 2026-03-03T12:36:55.154Z + + + 8500258 + + n/a + + + + + + 8500249 + + n/a + + + + + 1 + explanatory + true + false + false + true + + + + + \ No newline at end of file diff --git a/input/input_Monatsabo_test.xml b/input/input_Monatsabo_test.xml new file mode 100644 index 0000000..05d0be1 --- /dev/null +++ b/input/input_Monatsabo_test.xml @@ -0,0 +1,42 @@ + + + + + + de + + 2026-02-17T22:00:01.474Z + OJP_DemoApp_Beta_OJP2.0 + + 2026-02-17T22:00:01.474Z + + + + 7.33995 + 46.81392 + + + origin + + + + + + 8571359 + + destination + + + + + 5 + explanatory + true + false + false + true + + + + + diff --git a/input/input_coordinates.xml b/input/input_coordinates.xml index fff516b..212f7f1 100644 --- a/input/input_coordinates.xml +++ b/input/input_coordinates.xml @@ -29,10 +29,10 @@ - 5 - true - true - true + 2 + false + false + false true diff --git a/input/input_coordinates_ojp2.xml b/input/input_coordinates_ojp2.xml new file mode 100644 index 0000000..de959c1 --- /dev/null +++ b/input/input_coordinates_ojp2.xml @@ -0,0 +1,47 @@ + + + + + + de + + 2026-03-03T15:00:17.446Z + OJP_DemoApp_Beta_OJP2.0 + + 2026-03-03T15:00:17.446Z + + + + 7.449772 + 46.962961 + + + + 46.962961,7.449772 + + + + + + + 7.489687 + 46.930422 + + + + 46.930422,7.489687 + + + + + 5 + explanatory + true + true + true + true + + + + + \ No newline at end of file diff --git a/input/input_menusio.xml b/input/input_menusio.xml new file mode 100644 index 0000000..469b185 --- /dev/null +++ b/input/input_menusio.xml @@ -0,0 +1,39 @@ + + + + + de + + 2026-03-05T15:57:19.971Z + OJP_DemoApp_Beta_OJP2.0 + + 2026-03-05T15:57:19.971Z + + + 8505000 + + n/a + + + 2026-03-10T21:00:00.000Z + + + + 8505417 + + n/a + + + + + 5 + explanatory + true + true + true + true + + + + + \ No newline at end of file diff --git a/input/input_nachtbus.xml b/input/input_nachtbus.xml index 328cad6..8421cdc 100644 --- a/input/input_nachtbus.xml +++ b/input/input_nachtbus.xml @@ -16,7 +16,7 @@ Bern, Bahnhof (Bern) - 2025-11-01T00:00:45.343Z + diff --git a/input/input_test_dornbir.xml b/input/input_test_dornbir.xml new file mode 100644 index 0000000..1a8f196 --- /dev/null +++ b/input/input_test_dornbir.xml @@ -0,0 +1,38 @@ + + + + + + de + + 2026-02-25T13:01:28Z + BLS_IOS_SDK_1.3.3 + + 2026-02-25T13:01:28Z + + + 8102329 + + Dornbirn + + + + + + 8506314 + + St. Margrethen SG + + + + + 6 + false + true + true + explanatory + + + + + \ No newline at end of file diff --git a/map_nova_to_ojp.py b/map_nova_to_ojp.py index 34a32e0..793b8ec 100644 --- a/map_nova_to_ojp.py +++ b/map_nova_to_ojp.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import datetime +from decimal import Decimal from typing import List, Optional, Dict from xsdata.models.datatype import XmlDateTime @@ -42,10 +43,10 @@ def map_preis_auspraegung_to_trip_fare_result(preis_auspraegungen: List[PreisAus fare_authority_ref='ch:1:sboid:101704', fare_authority_text='Alliance SwissPass', price=preis_auspraegung.preis.betrag, - net_price=round(float(preis_auspraegung.preis.betrag)*(1.0-VATRATE/100),2), + net_price=round(float(preis_auspraegung.preis.betrag)*(1.0- VATRATE/100),2), currency=preis_auspraegung.preis.waehrung, required_card=required_card, - vat_rate=VATRATE, + vat_rate=Decimal(VATRATE).quantize(Decimal("0.1")), travel_class=map_klasse_to_fareclass(preis_auspraegung.produkt_einfluss_faktoren.klasse))])) return FareResultStructure(result_id=id, trip_fare_result=tripfareresults) diff --git a/map_nova_to_ojp2.py b/map_nova_to_ojp2.py index 8324966..195f7ee 100644 --- a/map_nova_to_ojp2.py +++ b/map_nova_to_ojp2.py @@ -42,7 +42,7 @@ def map_preis_auspraegung_to_trip_fare_result(preis_auspraegungen: List[PreisAus net_price=round(float(preis_auspraegung.preis.betrag)*(1.0-VATRATE/100),2), currency=preis_auspraegung.preis.waehrung, required_card=required_card, - vat_rate=Decimal(VATRATE), + vat_rate=Decimal(VATRATE).quantize(Decimal("0.1")), fare_class=map_klasse_to_fareclass(preis_auspraegung.produkt_einfluss_faktoren.klasse))])) return FareResultStructure(id=id, trip_fare_result=tripfareresults) diff --git a/map_ojp2_to_nova.py b/map_ojp2_to_nova.py index 5598add..a8a03e9 100644 --- a/map_ojp2_to_nova.py +++ b/map_ojp2_to_nova.py @@ -21,15 +21,17 @@ def map_timed_leg_to_segment(timed_leg: TimedLegStructure) -> FahrplanVerbindung operator_ref = timed_leg.service.operator_ref # needs to be processed afterwards to get the verwaltungs_code gattungs_code = timed_leg.service.mode.short_name.text[0].value # is correct, but a bit of a hack + # in OJP 2.' the number is in the TrainNumber + verkehrs_mittel_nummer=timed_leg.service.train_number # unfortunately it is not in line_ref, but in Extension/ojp:PublishedJourneyNumber - _, verkehrs_mittel_nummer, _ = line_ref.split(':') + #_, verkehrs_mittel_nummer, _ = line_ref.split(':') # This is an other hack. - verkehrs_mittel_nummer = ''.join(filter(lambda x: x.isdigit(), verkehrs_mittel_nummer)) - try: + #verkehrs_mittel_nummer = ''.join(filter(lambda x: x.isdigit(), verkehrs_mittel_nummer)) + #try: # Set verkehrs_mittel_nummer to timed_leg.extension.publishedjourneynumber? - verkehrs_mittel_nummer = [x.children[0].text for x in timed_leg.extension.childen if x.qname == '{http://www.vdv.de/ojp}PublishedJourneyNumber'][0] - except: - pass + # verkehrs_mittel_nummer = [x.children[0].text for x in timed_leg.extension.childen if x.qname == '{http://www.vdv.de/ojp}PublishedJourneyNumber'][0] + #except: + # pass verwaltungs_code= process_operating_ref_ojp2(operator_ref) @@ -79,6 +81,14 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus and len(ojp.ojprequest.service_request.ojpfare_request[0].trip_fare_request.trip.leg) > 0): return None + #handling of abos (monthly) otherwise the product_taxonomie is set to the standard + produkt_taxonomie = "SBB Preisauskunft" + try: + val = ojp.ojprequest.service_request.ojpfare_request[0].params.fare_authority_filter[0] + if "NOVA-Subscription" in val.value: + produkt_taxonomie="SBB Abonnemente" + except: + pass try: if ojp.ojprequest.service_request.ojpfare_request[0].params.traveller is None: travellers = [] @@ -171,7 +181,7 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus correlation_id=str(uuid.uuid1()), geschaefts_prozess_id="1781786f-57ba-4e9a-bc29-287e2aa97f9a"), angebots_filter=[TaxonomieFilter( - produkt_taxonomie="SBB Preisauskunft", + produkt_taxonomie=produkt_taxonomie, taxonomie_klasse_pfad=[TaxonomieKlassePfad(EmptyType())])], reisender=reisende, verbindung=verbindungen diff --git a/map_ojp2_to_ojp2.py b/map_ojp2_to_ojp2.py index c8690ba..d81e235 100644 --- a/map_ojp2_to_ojp2.py +++ b/map_ojp2_to_ojp2.py @@ -39,22 +39,31 @@ def parse_ojp2(body: str) -> Ojp: parser = XmlParser(config) return parser.from_string(body, ojp2.ojp.Ojp) -def map_to_individual_ojpfarerequest(trip: TripStructure, now: XmlDateTime) -> OjpfareRequest: - travellers=[] - if USE_HTA: - entitlementproduct=EntitlementProductStructure(fare_authority_ref=FareAuthorityRef("NOVA"),entitlement_product_name="HTA", entitlement_product_ref="HTA") #TODO correct fare_authority_ref - - entitlementproducts=EntitlementProductListStructure(entitlement_product=[entitlementproduct]) - travellers.append(FarePassengerStructure(age=25, entitlement_products = entitlementproducts)) +def map_to_individual_ojpfarerequest(trip: TripStructure, now: XmlDateTime, ojp_fare_params:FareParamStructure) -> OjpfareRequest: + if ojp_fare_params is None: + travellers=[] + if USE_HTA: + entitlementproduct = EntitlementProductStructure(fare_authority_ref=FareAuthorityRef("NOVA"), + entitlement_product_name="HTA", + entitlement_product_ref="HTA") # TODO correct fare_authority_ref + + entitlementproducts = EntitlementProductListStructure(entitlement_product=[entitlementproduct]) + travellers.append(FarePassengerStructure(age=25, entitlement_products=entitlementproducts)) + else: + travellers.append( + FarePassengerStructure(passenger_category=PassengerCategoryEnumeration.ADULT, entitlement_products=[])) + + ojp_fare_params=FareParamStructure(fare_authority_filter=[FareAuthorityRef("ch:1:NOVA")], + passenger_category=[PassengerCategoryEnumeration.ADULT], + fare_class=FareClassEnumeration.SECOND_CLASS, + traveller=travellers) else: - travellers.append(FarePassengerStructure(passenger_category=PassengerCategoryEnumeration.ADULT,entitlement_products = [])) - + # the FareParamStructure is directly added + # TODO: Perhaps we should check it in more details + pass return OjpfareRequest( request_timestamp=RequestTimestamp(now), - params=FareParamStructure(fare_authority_filter=[FareAuthorityRef("ch:1:NOVA")], - passenger_category=[PassengerCategoryEnumeration.ADULT], - fare_class=FareClassEnumeration.SECOND_CLASS, - traveller=travellers), + params=ojp_fare_params, trip_fare_request=TripFareRequestStructure(trip=trip)) # def map_to_individual_ojptriprefinerequest(trip_result: TripResultStructure, now: XmlDateTime) -> OjptripRefineRequest: @@ -116,7 +125,7 @@ def preprocess_stops_to_commercial_stops(delivery: OjptripDeliveryStructure) -> return delivery -def map_ojp2_trip_result_to_ojp2_fare_request(ojp: Ojp) -> Optional[Ojp]: +def map_ojp2_trip_result_to_ojp2_fare_request(ojp: Ojp, ojp_fare_params: FareParamStructure) -> Optional[Ojp]: if len(ojp.ojpresponse.service_delivery.ojptrip_delivery) != 1: return None @@ -129,7 +138,7 @@ def map_ojp2_trip_result_to_ojp2_fare_request(ojp: Ojp) -> Optional[Ojp]: # # preprocess trip result to translate the quays to the commercial stop ojptrip_delivery=preprocess_stops_to_commercial_stops(ojptrip_delivery) for trip_result in ojptrip_delivery.trip_result: - farerequest += [map_to_individual_ojpfarerequest(trip_result.trip, now)] + farerequest += [map_to_individual_ojpfarerequest(trip_result.trip, now,ojp_fare_params)] return Ojp(ojprequest= Ojprequest(service_request= diff --git a/map_ojp_to_nova.py b/map_ojp_to_nova.py index 862feb0..d38478e 100644 --- a/map_ojp_to_nova.py +++ b/map_ojp_to_nova.py @@ -32,9 +32,8 @@ def map_timed_leg_to_segment(timed_leg: TimedLegStructure) -> FahrplanVerbindung _, verkehrs_mittel_nummer, _ = line_ref.split(':') # We try to extract the line for NOVA verkehrs_mittel_nummer = ''.join(filter(lambda x: x.isdigit(), verkehrs_mittel_nummer)) - # For trains and in OJP 1.0 it is needed to extract the train number from the extension + # For trains and in OJP 1.0 it is needed to extract the train number from the extension PublihedJourneyNumber # e.g. necessary for discounts of BLS in future travel - # TODO perhaps do only for rail try: # Set verkehrs_mittel_nummer to timed_leg.extension.publishedjourneynumber? verkehrs_mittel_nummer = [x.children[0].text for x in timed_leg.extension.children if x.qname == '{http://www.vdv.de/ojp}PublishedJourneyNumber'][0] @@ -85,17 +84,28 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus and ojp.ojprequest.service_request.ojpfare_request[0].trip_fare_request.trip and len(ojp.ojprequest.service_request.ojpfare_request[0].trip_fare_request.trip.trip_leg) > 0): return None - + #handling of abos (monthly) otherwise the product_taxonomie is set to the standard + produkt_taxonomie = "SBB Preisauskunft" + try: + val = ojp.ojprequest.service_request.ojpfare_request[0].params.fare_authority_filter[0] + if "NOVA-Subscription" in val.value: + produkt_taxonomie="SBB Abonnemente" + except: + pass + #handling of traveller + travellers = [] try: + if ojp.ojprequest.service_request.ojpfare_request[0].params.traveller is None: - travellers = [] - travellers.append(FarePassengerStructure(age=25, entitlement_product=["HTA"])) + #if we have no traveller, we invent one (with HTA) TODO perhaps we should instead throw an error + travellers.append(FarePassengerStructure(age=25, entitlement_product=["HTA"])) #fix for bad data in traveller else: - if not(ojp.ojprequest.service_request.ojpfare_request[0].params.traveller[0].age is int): - age = ojp.ojprequest.service_request.ojpfare_request[0].params.traveller[0].age - else: - age=25 - travellers = ojp.ojprequest.service_request.ojpfare_request[0].params.traveller + # we go through all travellers + for traveler in ojp.ojprequest.service_request.ojpfare_request[0].params.traveller: + #TODO we set age, but this might be wrongs + if not(traveler.age is int): + traveler.age=25 + travellers.append(traveler) except: pass @@ -136,14 +146,15 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus if no_pricable_leg is False: verbindungen += [VerbindungPreisAuskunft(externe_verbindungs_referenz_id=externeVerbindungsReferenzId + "_" + leg_start + "_" + leg_end, segment_hin_fahrt=segments)] + + # we process now the travellers into the nova strcuture reisende =[] for traveler in travellers: - # we only do one for the time being TODO r_alter=25 if traveler.age == None: - t_alter=25 + r_alter=25 else: - t_alter=traveler.age + r_alter=traveler.age r_typ = ReisendenTypCode.PERSON if traveler.passenger_category is None: r_typ =ReisendenTypCode.PERSON @@ -151,22 +162,68 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus r_typ = ReisendenTypCode.HUND elif traveler.passenger_category.value == "Bicycle": r_typ = ReisendenTypCode.VELO + elif traveler.passenger_category.value == "Adult": + r_typ = ReisendenTypCode.PERSON + elif traveler.passenger_category.value == "Child": + r_typ = ReisendenTypCode.PERSON + elif traveler.passenger_category.value == "Senior": + r_typ = ReisendenTypCode.PERSON + elif traveler.passenger_category.value == "Youth": + r_typ = ReisendenTypCode.PERSON + elif traveler.passenger_category.value == "Disabled": + r_typ = ReisendenTypCode.PERSON + elif traveler.passenger_category.value == "Motorcycle": + r_typ = ReisendenTypCode.PERSON + + #TODO Raise error + elif traveler.passenger_category.value == "Car": + r_typ = ReisendenTypCode.PERSON + #TODO Raise error + elif traveler.passenger_category.value == "Truck": + r_typ = ReisendenTypCode.PERSON + #TODO Raise error + elif traveler.passenger_category.value == "Group": + r_typ = ReisendenTypCode.PERSON + #TODO Raise error else: r_typ = ReisendenTypCode.PERSON + #TODO Raise error digits = "0123456789" r_referenz= ''.join(random.choice(digits) for _ in range(6)) - # we only process HTA for the time being! - reisender = ReisendenInfoPreisAuskunft(alter=r_alter, - externe_reisenden_referenz_id=r_referenz, - reisenden_typ=r_typ, - ermaessigungs_karte_code=[]) + entitlements =[] for entitlement_product in traveler.entitlement_product: - if "HTA" in entitlement_product: - reisender = ReisendenInfoPreisAuskunft(alter=r_alter, - externe_reisenden_referenz_id=r_referenz, - reisenden_typ=r_typ, - ermaessigungs_karte_code=["HTA"]) + if "HTA" in entitlement_product.entitlement_product_ref: + entitlements.append("HTA") + elif "JUNIORKARTE" in entitlement_product.entitlement_product_ref: + entitlements.append("JUNIORKARTE") + elif "EURAIL_CH_1KL" in entitlement_product.entitlement_product_ref: + entitlements.append("EURAIL_CH_1KL") + elif "EURAIL_CH_2KL" in entitlement_product.entitlement_product_ref: + entitlements.append("EURAIL_CH_2KL") + elif "INTERRAIL_CH_1KL" in entitlement_product.entitlement_product_ref: + entitlements.append("INTERRAIL_CH_1KL") + elif "INTERRAIL_CH_2KL" in entitlement_product.entitlement_product_ref: + entitlements.append("INTERRAIL_CH_2KL") + elif "GA_2KL" in entitlement_product.entitlement_product_ref: + entitlements.append("GA_2KL") + elif "GA_1KL" in entitlement_product.entitlement_product_ref: + entitlements.append("GA_1KL") + elif "ST_PASS_2KL" in entitlement_product.entitlement_product_ref: + entitlements.append("ST_PASS_2KL") + elif "ST_PASS_1KL" in entitlement_product.entitlement_product_ref: + entitlements.append("ST_PASS_1KL") + elif "KEINE_ERMAESSIGUNGSKARTE" in entitlement_product.entitlement_product_ref: + #Keine Ermässigungskarte + pass + else: + #TODO error ungültige Karte + pass + + reisender = ReisendenInfoPreisAuskunft(alter=r_alter, + externe_reisenden_referenz_id=r_referenz, + reisenden_typ=r_typ, + ermaessigungs_karte_code=entitlements) reisende.append(reisender) return PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftInput( body=PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftInput.Body( @@ -180,7 +237,7 @@ def map_fare_request_to_nova_request(ojp: Ojp, age: int=30) -> Optional[PreisAus correlation_id=str(uuid.uuid1()), geschaefts_prozess_id="1781786f-57ba-4e9a-bc29-287e2aa97f9a"), angebots_filter=[TaxonomieFilter( - produkt_taxonomie="SBB Preisauskunft", + produkt_taxonomie=produkt_taxonomie, taxonomie_klasse_pfad=[TaxonomieKlassePfad(EmptyType())])], reisender=reisende, verbindung=verbindungen diff --git a/map_ojp_to_ojp.py b/map_ojp_to_ojp.py index 7278a5d..7d8e564 100644 --- a/map_ojp_to_ojp.py +++ b/map_ojp_to_ojp.py @@ -26,19 +26,26 @@ def parse_ojp(body: str) -> Ojp: parser = XmlParser(config) return parser.from_string(body, Ojp) -def map_to_individual_ojpfarerequest(trip: TripStructure, now: XmlDateTime) -> OjpfareRequest: - travellers=[] - if USE_HTA: - travellers.append(FarePassengerStructure(age=25,entitlement_product = ["HTA"])) - else: - travellers.append(FarePassengerStructure(passenger_category=PassengerCategoryEnumeration.ADULT,entitlement_product = [])) +def map_to_individual_ojpfarerequest(trip: TripStructure, now: XmlDateTime, fare_params:FareParamStructure) -> OjpfareRequest: + if fare_params is None: + travellers = [] + if USE_HTA: + travellers.append(FarePassengerStructure(age=25, entitlement_product=["HTA"])) + else: + travellers.append( + FarePassengerStructure(passenger_category=PassengerCategoryEnumeration.ADULT, entitlement_product=[])) + + return OjpfareRequest( + request_timestamp=now, + params=FareParamStructure(fare_authority_filter=["ch:1:NOVA"], + passenger_category=[PassengerCategoryEnumeration.ADULT], + travel_class=TypeOfFareClassEnumeration.SECOND, + traveller=travellers), + trip_fare_request=TripFareRequestStructure(trip=trip)) return OjpfareRequest( request_timestamp=now, - params=FareParamStructure(fare_authority_filter=["ch:1:NOVA"], - passenger_category=[PassengerCategoryEnumeration.ADULT], - travel_class=TypeOfFareClassEnumeration.SECOND, - traveller=travellers), + params=fare_params, trip_fare_request=TripFareRequestStructure(trip=trip)) # def map_to_individual_ojptriprefinerequest(trip_result: TripResultStructure, now: XmlDateTime) -> OjptripRefineRequest: @@ -100,7 +107,7 @@ def preprocess_stops_to_commercial_stops(delivery: OjptripDeliveryStructure) -> leg_intermediate.stop_point_ref = parent.get(leg_intermediate.stop_point_ref,leg_intermediate.stop_point_ref) return delivery -def map_ojp_trip_result_to_ojp_fare_request(ojp: Ojp) -> Optional[Ojp]: +def map_ojp_trip_result_to_ojp_fare_request(ojp: Ojp,fare_params:FareParamStructure) -> Optional[Ojp]: if ojp.ojpresponse is None or ojp.ojpresponse.service_delivery is None or ojp.ojpresponse.service_delivery.ojptrip_delivery is None or len(ojp.ojpresponse.service_delivery.ojptrip_delivery) != 1: return None @@ -116,7 +123,7 @@ def map_ojp_trip_result_to_ojp_fare_request(ojp: Ojp) -> Optional[Ojp]: ojptrip_delivery=preprocess_stops_to_commercial_stops(ojptrip_delivery) for trip_result in ojptrip_delivery.trip_result: if trip_result.trip: - farerequest += [map_to_individual_ojpfarerequest(trip_result.trip, now)] + farerequest += [map_to_individual_ojpfarerequest(trip_result.trip, now,fare_params)] return Ojp(ojprequest= Ojprequest(service_request= diff --git a/support.py b/support.py index 543bb25..1394879 100644 --- a/support.py +++ b/support.py @@ -7,9 +7,14 @@ import logging from ojp2 import OperatorRef +from datetime import datetime, timedelta, timezone +import random +import re +from typing import Pattern logger = logging.getLogger(__name__) + # err_str = "" #global error string # define an error response immediatly and make sure the program can send it back (ignores all warnings that were before). @@ -20,20 +25,6 @@ def error_response(error_text:str) -> Ojp: producer_ref="OJP2NOVA", error_condition=ServiceDeliveryStructure.ErrorCondition(other_error=OtherError(error_text))))) -# storing warnings to be sent with the answer -#def add_error(error_text:str): -# global err_str -# err_str=err_str+error_text -# return - -# include accumulated warnings into the response. Status not affected (so should be warnings) -# def add_error_response(sd:ServiceDeliveryStructure): -# global err_str -# if err_str=="": -# return sd -# sd.ErrorCondition(other_error=OtherError(err_str)) -# return sd - def process_operating_ref_ojp2(operator_ref:OperatorRef) ->str: operator_ref_str=operator_ref.value return process_operating_ref(operator_ref_str) @@ -71,7 +62,6 @@ def sloid2didok(sloid:str)->int: "8014485": "8503463", "8014487": "8503462", } - try: # sloids are not integer, but didok are. So we first try to convert to id. If this works, we assume, it is a didok code didok=int(sloid) @@ -83,6 +73,7 @@ def sloid2didok(sloid:str)->int: #remove the right part of sloid, if it exist if ':' in sloid: tmp = sloid[:sloid.find(':')] + # if bigger than 100000 -> no add. This is used for the 11-14 prefixes that are used for sloid that are used for local public transport # outside Switzerland if int(tmp)>100000: @@ -91,6 +82,78 @@ def sloid2didok(sloid:str)->int: tmp=my_dict.get(str(tmp),str(tmp)) # replaces if it is in the table or gets the value back return tmp +def is_version_2_0(xml_string:str) -> bool: + #simple test to see if the xml is OJP version 2.0 (or should be) + # Split the string into lines + lines = xml_string.splitlines() + + # Check if there are at least two lines + if len(lines) < 2: + return False + #check the first line for the version (when the xml header was omitted) + if 'version="2.0"' in lines[0]: + return True + # Check the second line for the version + second_line = lines[1] + if 'version="2.0"' in second_line: + return True + return False + + +def build_timestamp(days_in_future: int | None = None, start_time: str | None = None) -> str: + if days_in_future is None and start_time is None: + raise ValueError("Either days_in_future or start_time must be provided.") + if days_in_future is not None: + if not isinstance(days_in_future, int) or days_in_future < 0: + raise ValueError("days_in_future must be a non-negative integer.") + + if start_time is not None: + TIME_RE = re.compile(r'^(\d{2}):(\d{2}):(\d{2})$') + m = TIME_RE.match(start_time) + if not m: + raise ValueError('start_time must be in "HH:MM:SS" format.') + h, mnt, s = map(int, m.groups()) + if not (0 <= h <= 23 and 0 <= mnt <= 59 and 0 <= s <= 59): + raise ValueError("start_time components out of range.") + hours, minutes, seconds = h, mnt, s + else: + start_sec = 8 * 3600 + end_sec = 12 * 3600 + rand_sec = random.randint(start_sec, end_sec - 1) + hours = rand_sec // 3600 + minutes = (rand_sec % 3600) // 60 + seconds = rand_sec % 60 + + days = 0 if days_in_future is None else days_in_future + + now_utc = datetime.now(timezone.utc) + target_date = (now_utc + timedelta(days=days)).replace(hour=hours, minute=minutes, second=seconds, microsecond=random.randrange(0,1000)*1000, tzinfo=timezone.utc) + # isoformat with Z + iso = target_date.isoformat(timespec='milliseconds') + if iso.endswith('+00:00'): + iso = iso[:-6] + 'Z' + return iso + + +def insert_before_line_with_substring(text: str, pattern: str, insert: str) -> str: + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if pattern in line: + lines.insert(i, insert) + return ''.join(lines) + return text + +def inject_departure_datetime(ojp_trip_request_xml : str, daysinthefuture : int,start_time : str) -> str: + # TODO: shaky + # build the date + ts=build_timestamp(daysinthefuture,start_time) + if is_version_2_0(ojp_trip_request_xml): + #wrap the result + ts=""+ts+"" + return insert_before_line_with_substring(ojp_trip_request_xml,"",ts) + #wrap the result + ts=""+ts+"" + return insert_before_line_with_substring(ojp_trip_request_xml,"",ts) # raising an error and sending it back. Does not add values from err_str class OJPError(Exception) : @@ -102,4 +165,6 @@ def __init__(self, value:str) -> None: # __str__ is to print() the value def __str__(self)->str: - return (repr(self.value)) \ No newline at end of file + return (repr(self.value)) + + diff --git a/test_configuration.json b/test_configuration.json new file mode 100644 index 0000000..0c704ca --- /dev/null +++ b/test_configuration.json @@ -0,0 +1,717 @@ +[ + { + "id": 1, + "description": "Check prices in Europaplatz", + "file": "input/input_problematic_Europaplatz_ojp2.xml", + "travellers": [ + { + "age": 14, + "entitlements": "HTA GA_1KL", + "typ": "PERSON", + "tkid": "1231231", + "birthday": "1999-10-01" + }, + { + "entitlements": "", + "typ": "DOG", + "tkid": "123131231" + } + ], + "subscriptions": true, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "162.00", + "active": true + }, + { + "id": 2, + "description": "Bern- Belp with Europplatz", + "file": "input/input_Bern_Belp.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "1231231" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": false + }, + { + "id": 3, + "description": "Special ticket with boat", + "file": "input/input_Bodensee_2.xml", + "travellers": [ + { + "age": 75, + "entitlements": "", + "typ": "PERSON", + "tkid": "12331231" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "12:80", + "active": false + }, + { + "id": 4, + "description": "Bodensee part 2", + "file": "input/input_Bodensee_2.xml", + "travellers": [ + { + "age": 75, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "12331231" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "6.40", + "active": false + }, + { + "id": 5, + "description": "Test with Postauto", + "file": "input/input_bus_postauto.xml", + "travellers": [ + { + "age": 13, + "entitlements": "", + "typ": "PERSON", + "tkid": "12331231" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "3.30", + "active": false + }, + { + "id": 6, + "description": "Basic OJP 2 test.", + "file": "input/input_ojp_2_test.xml", + "travellers": [ + { + "age": 25, + "entitlements": "", + "typ": "PERSON", + "tkid": "12331231" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "24.40", + "active": false + }, + { + "id": 7, + "description": "OJP test with multiple travellers", + "file": "input/input_ojp_2_test.xml", + "travellers": [ + { + "age": 25, + "entitlements": "", + "typ": "PERSON", + "tkid": "123312312" + }, + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312313" + }, + { + "age": 25, + "entitlements": "", + "typ": "PERSON", + "tkid": "123312314" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "24.40", + "active": false + }, + { + "id": 8, + "description": "Test für Mattelift", + "file": "input/input_Mattelift.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "fail", + "assert": "", + "active": false + }, + { + "id": 9, + "description": "Test mit Koordinaten für Arbeitsweg (Abos).", + "file": "input/input_coordinates.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": true, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "72.00", + "active": false + }, + { + "id": 10, + "description": "Test mit Koordinaten für Arbeitsweg (Abos).OJP 2.0", + "file": "input/input_coordinates_ojp2.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": true, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "result": "pass", + "assert": "82.00", + "active": false + }, + { + "id": 11, + "description": "Test Bern - Zweisimmen. Achtung: Datum muss gesetzt werden", + "file": "input/input_Bern_Zweisimmen_BLS_Zukunft.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": false + }, + { + "id": 12, + "description": "Test Luzern - Menusio, am 10. März. Achtung Datum", + "file": "input/input_menusio.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 13, + "description": "Monatsabo test", + "file": "input/input_Monatsabo_test.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": true, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 14, + "description": "", + "file": "input/input_test_dornbir.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 15, + "description": "", + "file": "input/input_oev_shart_plus_long.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 16, + "description": "", + "file": "input/input_oev_shart_plus_long.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 17, + "description": "", + "file": "input/input_Zuerich_Chur.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 18, + "description": "", + "file": "input/input_Visp_SaaS_Fee_problem_1_preis.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 19, + "description": "", + "file": "input/input_strange_price.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 20, + "description": "", + "file": "input/input_Zuerich_Bern.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 21, + "description": "", + "file": "input/input_Basel_Sargans.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 22, + "description": "", + "file": "input/input_Bern_Interlaken_Ost.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 23, + "description": "", + "file": "input/input_Bern_Interlaken_Gymnasium.xml", + "travellers": [ + { + "age": 25, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 24, + "description": "", + "file": "input/input_Bern_Guisanplatz_Interlaken_Gymnasium.xml", + "travellers": [ + { + "age": 70, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 25, + "description": "", + "file": "input/input_local.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 26, + "description": "", + "file": "input/input_sharing_intercity.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 27, + "description": "", + "file": "input/input_problematic_case_vasile.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 28, + "description": "", + "file": "input/input_Europaplatz.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 29, + "description": "", + "file": "input/input_Bodensee_1.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 30, + "description": "", + "file": "input/input_test_europaplatz.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 31, + "description": "", + "file": "input/input_problematic_Europaplatz_ojp1.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 32, + "description": "", + "file": "input/input_problem_footpath.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 33, + "description": "", + "file": "input/input_problematic_Europaplatz_4.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 34, + "description": "", + "file": "input/input_walk_only.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "status": "fail", + "active": true + }, + { + "id": 35, + "description": "", + "file": "input/input_sharing_only.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "status": "fail", + "active": true + }, + { + "id": 36, + "description": "", + "file": "input/input_odv_alone.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "status": "fail", + "active": true + }, + { + "id": 37, + "description": "time must be reset before run. Check if discounts exist on www.sbb.ch", + "file": "input/input_Bern_Chur_SOB_Zukunft.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "future": 3, + "active": true + }, + { + "id": 38, + "description": "Past is not handled", + "file": "input/input_in_the_past_not_handeled_well_in_Preisauskunft.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 39, + "description": "Ptime must be reset before run. Check if discounts exist on www.sbb.ch", + "file": "input/input_Bern_Zweisimmen_BLS_Zukunft.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "future": 6, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 40, + "description": "time must be reset before run. Check if discounts exist on www.sbb.ch", + "file": "input/input_bern_riehen.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "future": 8, + "active": true + }, + { + "id": 41, + "description": "xxx work needed", + "file": "input/input_aller_retour.xml", + "travellers": [ + { + "age": 18, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + }, + { + "id": 42, + "description": "xxx ", + "file": "input/input_Nachtbus.xml", + "travellers": [ + { + "age": 12, + "entitlements": "HTA", + "typ": "PERSON", + "tkid": "123312312" + } + ], + "subscriptions": false, + "start_time": "23:55:00", + "relationship": "KEINE_REISENDENBEZIEHUNG", + "active": true + } +] \ No newline at end of file diff --git a/test_configuration.py b/test_configuration.py deleted file mode 100644 index 6566259..0000000 --- a/test_configuration.py +++ /dev/null @@ -1,96 +0,0 @@ -READFILE = [] - - -READFILE.append("input/input_Bern_Belp.xml") - -READFILE.append("input/input_oev_shart_plus_long.xml") -READFILE.append("input/input_Zuerich_Chur.xml") -READFILE.append("input/input_Visp_SaaS_Fee_problem_1_preis.xml") #1. class price problematic -READFILE.append("input/input_strange_price.xml") -READFILE.append("input/input_Zuerich_Bern.xml") -READFILE.append("input/input_Basel_Sargans.xml") -READFILE.append("input/input_Bern_Interlaken_Ost.xml") -READFILE.append("input/input_Bern_Interlaken_Gymnasium.xml") -READFILE.append("input/input_Bern_Guisanplatz_Interlaken_Gymnasium.xml") -READFILE.append("input/input_local.xml") -READFILE.append("input/input_oev_shart_plus_long.xml") -READFILE.append("input/input_bus_postauto.xml") -READFILE.append("input/input_sharing_intercity.xml") -READFILE.append("input/input_problematic_case_vasile.xml") -READFILE.append("input/input_Europaplatz.xml") -READFILE.append("input/input_Bodensee_1.xml") -READFILE.append("input/input_test_europaplatz.xml") - - -# Working OJP 2.0 example -#READFILE.append("input/input_ojp_1_test.xml") -READFILE.append("input/input_ojp_2_test.xml") -READFILE.append("input/input_problematic_Europaplatz_ojp1.xml") -READFILE.append("input/input_problematic_Europaplatz_ojp2.xml") -READFILE.append("input/input_Bodensee_2.xml") -READFILE.append("input/input_problem_footpath.xml") -READFILE.append("input/input_problematic_Europaplatz_4.xml") - -''' ----------------------------------------------------------------- -Reservoir for tests - -# Working OJP 1.0 examples -READFILE.append("input/input_Bern_Belp.xml") - -READFILE.append("input/input_oev_shart_plus_long.xml") -READFILE.append("input/input_Zuerich_Chur.xml") -READFILE.append("input/input_Visp_SaaS_Fee_problem_1_preis.xml") #1. class price problematic -READFILE.append("input/input_strange_price.xml") -READFILE.append("input/input_Zuerich_Bern.xml") -READFILE.append("input/input_Basel_Sargans.xml") -READFILE.append("input/input_Bern_Interlaken_Ost.xml") -READFILE.append("input/input_Bern_Interlaken_Gymnasium.xml") -READFILE.append("input/input_Bern_Guisanplatz_Interlaken_Gymnasium.xml") -READFILE.append("input/input_local.xml") -READFILE.append("input/input_oev_shart_plus_long.xml") -READFILE.append("input/input_bus_postauto.xml") -READFILE.append("input/input_sharing_intercity.xml") -READFILE.append("input/input_problematic_case_vasile.xml") -READFILE.append("input/input_Europaplatz.xml") -READFILE.append("input/input_Bodensee_1.xml") -READFILE.append("input/input_test_europaplatz.xml") - - -# Working OJP 2.0 example -#READFILE.append("input/input_ojp_1_test.xml") -READFILE.append("input/input_ojp_2_test.xml") -READFILE.append("input/input_problematic_Europaplatz_ojp1.xml") -READFILE.append("input/input_problematic_Europaplatz_ojp2.xml") -READFILE.append("input/input_Bodensee_2.xml") -READFILE.append("input/input_problem_footpath.xml") -READFILE.append("input/input_problematic_Europaplatz_4.xml") - - - -# No result, but no result is ok -READFILE.append("input/input_walk_only.xml") -READFILE.append("input/input_sharing_only.xml") -READFILE.append("input/input_odv_alone.xml") -READFILE.append("input/input_in_the_past_not_handeled_well_in_Preisauskunft.xml") -READFILE.append("input/input_Bodensee.xml") # no data available there -READFILE.append("input/input_problematic_Europaplatz_ojp2_via.xml") # no longer ok as construction finished - - -# Contains DepArr that needs to be set to something useful before testing -READFILE.append("input/input_Bern_Chur_SOB_Zukunft.xml" ) #time must be reset before run. Check if discounts exist on www.sbb.ch -READFILE.append("input/input_Bern_Zweisimmen_BLS_Zukunft.xml") #time must be reset before run. Check if discounts exist on www.sbb.ch -READFILE.append("input/input_bern_riehen.xml") #time must be reset before run. Check if discounts exist on www.sbb.ch -READFILE.append("input/input_aller_retour.xml") -READFILE.append("input/input_Nachtbus.xml") - -# need analysis -#not working on Saturdays. Handling of ODV not working -READFILE.append("input/input_demand_responsive_saturday_after_1500.xml") - -#sometimes not working -READFILE.append("input/input_sharing_intercity.xml") - -''' - - diff --git a/test_network_flow.py b/test_network_flow.py index 9953eae..96b9bde 100644 --- a/test_network_flow.py +++ b/test_network_flow.py @@ -3,7 +3,8 @@ import json import traceback from typing import Tuple, Any, Optional - +import argparse +import sys import requests import urllib3 from xsdata.formats.dataclass.client import Client @@ -15,7 +16,7 @@ import ojp.fare_result_structure from api.errors import NoNovaResponseError from configuration import * -from support import OJPError +from support import OJPError, inject_departure_datetime from test_create_ojp_request import * from map_nova_to_ojp import test_nova_to_ojp from map_nova_to_ojp2 import test_nova_to_ojp2 @@ -28,9 +29,12 @@ from nova import PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunft, \ PreisAuskunftServicePortTypeSoapv14ErstellePreisAuskunftOutput -from ojp2 import Ojp as Ojp2 -from ojp import Ojp, OjpfareDelivery -from xslt_transform import transform_xml, is_version_2_0 +from ojp2 import Ojp as Ojp2, FareParamStructure as FareParamStructure2, \ + FarePassengerStructure as FarePassengerStructure2, FareAuthorityRefStructure as FareAuthorityRefStructure2, \ + PassengerCategoryEnumeration, FareClassEnumeration, EntitlementProductStructure, EntitlementProductListStructure +from ojp import Ojp, OjpfareDelivery, FareParamStructure, FarePassengerStructure, FareAuthorityRef, \ + TypeOfFareClassEnumeration, PassengerCategoryEnumeration, EntitlementProductRef +from support import is_version_2_0 import xml_logger import logging @@ -134,88 +138,266 @@ def check_configuration() ->None: logger.error("Nova client secret not set in the configuration") exit(1) -if __name__ == '__main__': + +def split_entitlements(value: Any) -> List[str]: + """ + Convert entitlements field into a list of strings. + - If value is None or empty string -> return empty list. + - If value is already a list -> return a shallow copy. + - If value is a string -> split on whitespace. + - Otherwise -> convert to string and split. + """ + if value is None: + return [] + if isinstance(value, list): + return value.copy() + if isinstance(value, str): + # split on any whitespace and ignore extra spaces + parts = value.split() + return parts + # Fallback: convert to string + return str(value).split() + +def build_ojp2_fare_params(travellers, subscriptions, relationship) -> FareParamStructure2: + + #travellers can't be empty. we already checked + ojptravellers = [] + for t_idx, traveller in enumerate(travellers): + if not isinstance(traveller, dict): + continue + typ = traveller.get("typ") + tkid = traveller.get("tkid") # we cannot process this for the time being + raw_ent = traveller.get("entitlements") + ent_list = split_entitlements(raw_ent) + entitlements = [] + for ent in ent_list: + entitlements.append(EntitlementProductStructure(fare_authority_ref="NOVA",entitlement_product_ref=ent,entitlement_product_name=ent)) + entitlement_list=EntitlementProductListStructure(entitlement_product=entitlements) + passenger_category = traveller.get("passenger_category") + birthday = traveller.get("birthday") #we cannot process this for the time being + age = traveller.get("age") + + ojptraveller = FarePassengerStructure2(age=age,passenger_category=passenger_category,entitlement_products=entitlement_list) + ojptravellers.append(ojptraveller) + filters =[] + if subscriptions: + # we use subscriptions instead of regular tickets + filters.append(FareAuthorityRefStructure2(value="NOVA-Subscription")) + else: + filters.append(FareAuthorityRefStructure2(value="NOVA")) + if relationship: + #TODO we will have to process relationships, need to put this in extensions + pass + return FareParamStructure2(fare_authority_filter=filters,traveller=ojptravellers,passenger_category=[PassengerCategoryEnumeration.ADULT],fare_class =FareClassEnumeration.SECOND_CLASS) + + + +def build_ojp_fare_params(travellers, subscriptions, relationship) -> FareParamStructure: + + #travellers can't be empty. we already checked + ojptravellers = [] + for t_idx, traveller in enumerate(travellers): + if not isinstance(traveller, dict): + continue + typ = traveller.get("typ") + tkid = traveller.get("tkid") # we cannot process this for the time being + raw_ent = traveller.get("entitlements") + ent_list = split_entitlements(raw_ent) + entitlements=[] + for ent in ent_list: + entitlements.append(EntitlementProductStructure(fare_authority_ref="NOVA",entitlement_product_ref=ent, entitlement_product_name=ent)) + passenger_category = traveller.get("passenger_category") + birthday = traveller.get("birthday") #we cannot process this for the time being + age = traveller.get("age") + + ojptraveller = FarePassengerStructure(age=age,passenger_category=passenger_category,entitlement_product=entitlements) + ojptravellers.append(ojptraveller) + filters =[] + if subscriptions: + # we use subscriptions instead of regular tickets + filters.append(FareAuthorityRef(value="NOVA-Subscription")) + else: + filters.append(FareAuthorityRef(value="NOVA")) + if relationship: + #TODO we will have to process relationships, need to put this in extensions + pass + + + + return FareParamStructure(fare_authority_filter=filters,traveller=ojptravellers,passenger_category=[PassengerCategoryEnumeration.ADULT],travel_class =FareClassEnumeration.SECOND_CLASS) + +def non_negative_int(value: str) -> int: + """argparse type function: ensures the provided value is a non-negative integer.""" + try: + ivalue = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") + if ivalue < 0: + raise argparse.ArgumentTypeError(f"value must be non-negative: {value}") + return ivalue + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description="Run network flow tests.") + # allow either --all or --id, or neither. If both provided prefer --id. + parser.add_argument("--all", action="store_true", help="Run all tests") + parser.add_argument("--id", type=non_negative_int, metavar="N", help="Run the test with the given id (non-negative integer)") + return parser.parse_args(argv) + +def main(argv=None) ->int: + args = parse_args(argv) + + # Priority: --id if provided, else --all, else default_action() + if args.id is not None: + processing="id"+str(args.id)+"id" + elif args.all: + processing="all" + else: + processing="active" + #check configuration ojp_trip_request_xml='' check_configuration() serializer_config = SerializerConfig(ignore_default_attributes=True, pretty_print=True) serializer = XmlSerializer(config=serializer_config) - for rf in READFILE: - if (not READTRIPREQUESTFILE): - ojp_trip_request = test_create_ojp_trip_request_simple_1() - ojp_trip_request_xml = serializer.render(ojp_trip_request, ns_map=ns_map) - else: - inputfile = open(rf, 'r', encoding='utf-8') + """ + Load JSON from json_path into test_run_dict, then loop through all top-level elements + and process those with "active": true. + + Example action: print file name and number of travellers. Replace the print block + with whatever processing you need. + """ + # Read file + with open("test_configuration.json", "r", encoding="utf-8") as f: + test_run_dict = json.load(f) + + # test_run_dict is typically a list (as in your example). If it's a dict containing + # the list under some key, adapt accordingly. + if not isinstance(test_run_dict, list): + raise ValueError("Expected JSON top-level to be a list of test runs") + + # Loop through the elements + for idx, element in enumerate(test_run_dict): + # Skip non-dict entries + if not isinstance(element, dict): + continue + + active = element.get("active", False) + if "all" in processing or "id"+str(element.get("id"))+"id" in processing or (active and "active" in processing): + id= element.get("id") + file_name = element.get("file") + travellers = element.get("travellers", []) + subscriptions = element.get("subscriptions") + relationship = element.get("relationship") + start_time = element.get("start_time") + daysinthefuture = element.get("future") + expectedstatus=element.get("status") + asserttext=element.get("assert") + + + print( + f"\n**************************************************************************************\n") + print(f"Active element #{idx}: file={file_name}") + print(f" id: {id}") + print(f" relationship: {relationship}") + print(f" subscriptions: {subscriptions}") + print(f" travellers: {travellers}") + if start_time: + print(f" start_time: {start_time}") + if daysinthefuture: + print(f" number of days in the future: {daysinthefuture}") + print(f" expected status: {expectedstatus}") + if asserttext: + print(f" assertion that the following string is in the response (usually a price): {asserttext}") + + + inputfile = open(file_name, 'r', encoding='utf-8') ojp_trip_request_xml = inputfile.read() inputfile.close() - xml_logger.log_serialized('ojp_trip_request.xml', ojp_trip_request_xml) - try: - print (f"\n********************************************\n{rf}\n********************************************\n") - if is_version_2_0(ojp_trip_request_xml): - #We process an OJP 2 request - status, r = call_ojp_20(ojp_trip_request_xml) - if status != 200: - message = f"call returned a wrong status {status}" - raise IOError(message) - ojp_trip_result = parse_ojp2(r) - ojp_trip_result_xml = serializer.render(ojp_trip_result, ns_map=ns_map) - xml_logger.log_serialized('ojp_trip_reply.xml', ojp_trip_result_xml) - ojp_fare_request = map_ojp2_trip_result_to_ojp2_fare_request(ojp_trip_result) - if ojp_fare_request is None: - raise OJPError("ERR102: No fare request could be generated from trip delivery.") + #inject date if necessary + if daysinthefuture is not None or start_time is not None: + ojp_trip_request_xml=inject_departure_datetime(ojp_trip_request_xml,daysinthefuture,start_time) + xml_logger.log_serialized('ojp_trip_request.xml', ojp_trip_request_xml) + try: + + if is_version_2_0(ojp_trip_request_xml): + print(f" OJP version: 2.0") + # We process an OJP 2 request + status, r = call_ojp_20(ojp_trip_request_xml) + if status != 200: + message = f"call returned a wrong status {status}" + raise IOError(message) + ojp_trip_result = parse_ojp2(r) + + ojp_trip_result_xml = serializer.render(ojp_trip_result, ns_map=ns_map) + xml_logger.log_serialized('ojp_trip_reply.xml', ojp_trip_result_xml) + ojp_fare_params = build_ojp2_fare_params(travellers, subscriptions, relationship) + ojp_fare_request = map_ojp2_trip_result_to_ojp2_fare_request(ojp_trip_result, ojp_fare_params) + if ojp_fare_request is None: + raise OJPError("ERR102: No fare request could be generated from trip delivery.") + else: + ojp_fare_request_xml = serializer.render(ojp_fare_request, ns_map=ns_map) + xml_logger.log_serialized('ojp_fare_request.xml', ojp_fare_request_xml) + nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) + if nova_response: + ojp_fare_result = test_nova_to_ojp2(nova_response) + if not (ojp_fare_result): + ojp_fare_result_xml = "Not a valid nova fare response received." # TODO Improve with better handling + xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml) + else: + ojp_fare_result_xml = serializer.render(ojp_fare_result, ns_map=ns_map) + for fr1 in ojp_fare_result.fare_result: + for fr in fr1.trip_fare_result: + print("Legs: " + str(fr.from_leg_id_ref) + "-" + str(fr.to_leg_id_ref)) + print(fr.fare_product) + print("\n") + xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml) + if asserttext: + if not (asserttext in ojp_fare_result_xml): + print(f"Assertion from test case failed! {asserttext} not found.") else: - ojp_fare_request_xml = serializer.render(ojp_fare_request, ns_map=ns_map) - xml_logger.log_serialized('ojp_fare_request.xml', ojp_fare_request_xml) - nova_response = test_nova_request_reply_for_ojp2(ojp_fare_request) - if nova_response: - ojp_fare_result = test_nova_to_ojp2(nova_response) - if not(ojp_fare_result): - ojp_fare_result_xml="Not a valid nova fare response received." #TODO Improve with better handling - xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml) + # We work on a OJP 1.0 request + print(f" OJP version: 1.0") + status, r = call_ojp_2000(ojp_trip_request_xml) + if status != 200: + message = f"call returned a wrong status {status}:\n{r}" + raise IOError(message) + ojp_trip_result1 = parse_ojp(r) + ojp_trip_result_xml1 = serializer.render(ojp_trip_result1, ns_map=ns_map) + xml_logger.log_serialized('ojp_trip_reply.xml', ojp_trip_result_xml1) + ojp_fare_params = build_ojp_fare_params(travellers, subscriptions, relationship) + ojp_fare_request1 = map_ojp_trip_result_to_ojp_fare_request(ojp_trip_result1,ojp_fare_params) + ojp_fare_request_xml1 = serializer.render(ojp_fare_request1, ns_map=ns_map) + xml_logger.log_serialized('ojp_fare_request.xml', ojp_fare_request_xml1) + + if ojp_fare_request1: + nova_response1 = test_nova_request_reply(ojp_fare_request1) else: - ojp_fare_result_xml = serializer.render(ojp_fare_result, ns_map=ns_map) - for fr1 in ojp_fare_result.fare_result: - for fr in fr1.trip_fare_result: - print("Legs: " + str(fr.from_leg_id_ref) + "-" + str(fr.to_leg_id_ref)) - print(fr.fare_product) + nova_response1 = None + if nova_response1: + ojp_fare_result1: OjpfareDelivery = test_nova_to_ojp(nova_response1) + ojp_fare_result_xml1 = serializer.render(ojp_fare_result1, ns_map=ns_map) + if ojp_fare_result1 is None: + raise OJPError("ERR100: No OJP Fare result obtained.") + for fr in ojp_fare_result1.fare_result: + for fr1 in fr.trip_fare_result: + print("Legs: " + str(fr1.from_trip_leg_id_ref) + "-" + str(fr1.to_trip_leg_id_ref)) + print(fr1.fare_product) print("\n") - xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml) - - - else: - # We work on a OJP 1.0 request - status,r = call_ojp_2000(ojp_trip_request_xml) - if status != 200: - message = f"call returned a wrong status {status}:\n{r}" - raise IOError(message) - ojp_trip_result1 = parse_ojp(r) - ojp_trip_result_xml1 = serializer.render(ojp_trip_result1, ns_map=ns_map) - xml_logger.log_serialized('ojp_trip_reply.xml', ojp_trip_result_xml1) - ojp_fare_request1 = map_ojp_trip_result_to_ojp_fare_request(ojp_trip_result1) - ojp_fare_request_xml1 = serializer.render(ojp_fare_request1, ns_map=ns_map) - xml_logger.log_serialized('ojp_fare_request.xml', ojp_fare_request_xml1) - - if ojp_fare_request1 : - nova_response1 = test_nova_request_reply(ojp_fare_request1) - else: - nova_response1 = None - if nova_response1: - ojp_fare_result1 : OjpfareDelivery = test_nova_to_ojp(nova_response1) - ojp_fare_result_xml1 = serializer.render(ojp_fare_result1, ns_map=ns_map) - if ojp_fare_result1 is None: - raise OJPError("ERR100: No OJP Fare result obtained.") - for fr in ojp_fare_result1.fare_result: - for fr1 in fr.trip_fare_result: - print("Legs: " + str(fr1.from_trip_leg_id_ref) + "-" + str(fr1.to_trip_leg_id_ref)) - print(fr1.fare_product) - print("\n") - xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml1) - - - except Exception as e: - # not yet really sophisticated handling of all other errors during the work (should be regular OJPDeliveries with OtherError set - logger.exception(e) - xml_logger.log_serialized('error_file.xml', str(e)) - traceback.print_exc() + xml_logger.log_serialized('ojp_fare_result.xml', ojp_fare_result_xml1) + if asserttext: + if not (asserttext in ojp_fare_result_xml1): + print(f"Assertion from test casefailed! {asserttext} not found.") + + + except Exception as e: + # not yet really sophisticated handling of all other errors during the work (should be regular OJPDeliveries with OtherError set + logger.exception(e) + xml_logger.log_serialized('error_file.xml', str(e)) + traceback.print_exc() + + +if __name__ == '__main__': + exit_code = main() + sys.exit(exit_code) \ No newline at end of file diff --git a/xslt_transform.py b/xslt_transform.py index 8d9db7f..e1640c3 100644 --- a/xslt_transform.py +++ b/xslt_transform.py @@ -10,7 +10,9 @@ def is_version_2_0(xml_string:str) -> bool: # Check if there are at least two lines if len(lines) < 2: return False - + #check the first line for the version (when the xml header was omitted) + if 'version="2.0"' in lines[0]: + return True # Check the second line for the version second_line = lines[1] if 'version="2.0"' in second_line: