diff --git a/app.py b/app.py index d424ed4..854913e 100644 --- a/app.py +++ b/app.py @@ -12,13 +12,22 @@ locale.setlocale(locale.LC_ALL, 'de_DE.UTF-8') @app.route('/') def main_page(): + """ + Show the main page, currently with the search form + """ search_form = SearchForm() return render_template("index.html", form=search_form) @app.route("/search", methods=["POST"]) def search_for_doctors_with_params(): + """ + Use the search form with its data to parse and process it, show the results to the user. + """ + + # Get the relevant data from the search form search_form = SearchForm(request.form) + # Validate the form and its data as well as getting the relevant data if search_form.validate(): location = search_form.location.data distance = search_form.therapy_distance.data @@ -34,22 +43,33 @@ def search_for_doctors_with_params(): print(f"Therapy Setting: {therapy_setting}") print(f"Therapy Phone Types: {amount_of_weeks}") + # If errors in data -> redirect to index else: print(search_form.errors) return redirect("/") + # Create an APIHandler to process the given data by gathering the desired phone times api_handler = APIHandler() + # Get a list of locations locations = api_handler.get_lat_lon_location_list(location) + # Location not found at Arztsuche API if not locations: return render_template("no_result.html") + # Use first location match TODO: find better solution than just using first best result lol location_match = locations[0] + # Calculate the required base64 value why so ever base64_value = api_handler.calculate_req_value_base64(float(location_match["lat"]), float(location_match["lon"])) - api_handler.get_list_of_doctors(float(location_match['lat']), float(location_match['lon']), base64_value, + # Get the general list of doctors + api_handler.get_list_of_doctors(float(location_match["lat"]), float(location_match["lon"]), base64_value, therapy_types, therapy_age_range, therapy_setting) + # Get their information api_handler.get_general_doctor_information() + # Filter for the distance api_handler.filter_doctor_information_for_distance(distance*1000) + # Get the sorted phone times for showing them at the web page sorted_doctor_phone_times = api_handler.get_doctor_phone_times_sorted(amount_of_weeks) + # Return the resulting data return render_template("result.html", doctors=sorted_doctor_phone_times) diff --git a/arztapi/APIHandler.py b/arztapi/APIHandler.py index 8220caf..0796921 100644 --- a/arztapi/APIHandler.py +++ b/arztapi/APIHandler.py @@ -10,17 +10,34 @@ from arztapi.DoctorPhoneTime import DoctorPhoneTime class APIHandler: + """ + Class for accessing and handling the Arztsuche API -> Get necessary data and filter it for theraPy + """ + def __init__(self): + # Base URL as given by the base website self.base_api_url = "https://arztsuche.116117.de/api/" + # Containers for phone times, general doctor information and processed phone times of doctors self.phone_times = [] self.general_information = [] self.processed_doctor_phone_times = [] def get_lat_lon_location_list(self, location): + """ + Use a given location input string and search for the location with the Arztsuche API -> input string can contain + more or less everything to search for, but should not contain spaces (since the original API is not able to + handle spaces properly) + :param location: given as input string/plz with more or less validation, directly given to the Arztsuche API + :return: location matches in JSON format or catching raised JSONDecodeError (resulting in None) + """ + + # API path as given by original website api_path = self.base_api_url + "location" + # Headers copied by manual cURL headers = { "Accept": "application/json, text/plain, */*", "Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + # Authorization header gathered by initial cURLing "Authorization": "Basic YmRwczpma3I0OTNtdmdfZg==", "Connection": "keep-alive", } @@ -31,23 +48,42 @@ class APIHandler: response = requests.get(api_path, params=params, headers=headers) + # Try to return the response in JSON try: + # Possibly multiple results, further processing by caller return response.json() + # JSONDecodeError for empty response object -> None, no location match except JSONDecodeError: - print(response.text) + return None def get_list_of_doctors(self, lat, lon, req_val_base64, therapy_types, therapy_age, therapy_setting) -> ArztPraxisDatas: + """ + + :param lat: Latitude as given by location API + :param lon: Longitude as given by location API + :param req_val_base64: base64 value required for API access (is this a token?) + :param therapy_types: Therapy types of interest + :param therapy_age: Therapy age range of interest + :param therapy_setting: Therapy setting of interest + :return: Relevant doctor/therapist data + """ + + # API path for doctor data api_path = self.base_api_url + "data" headers = { "Accept": "application/json", "Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", + # Authorization header gathered by initial cURLing "Authorization": "Basic YmRwczpma3I0OTNtdmdfZg==", "Connection": "keep-alive", + # Calculated base64 value based on latitude and longitude "req-val": req_val_base64, } + # Data object as built by the original website, some fields might not be plausible or known (since there is no + # API documentation itself available json_data = { # TODO: Find out what r means "r": 900, @@ -87,34 +123,60 @@ class APIHandler: } response = requests.post(api_path, headers=headers, json=json_data) + # Check for HTTP errors response.raise_for_status() + # Convert phone times to data format as input validation, save as class variable for further processing self.phone_times = ArztPraxisDatas(**response.json()) + # Return result for processing by caller return self.phone_times def get_general_doctor_information(self) -> List[DoctorInformation]: + """ + Transform and filter data to more usable format: Check for phone times and collect general doctor information + data of interest + Function should be called after initial API call, but doesn't create an error if not, just returning empty list + in case of + :return: General doctor information + """ + + # Create container for saving information general_doctor_information = [] + # Iterate over phone times -> API should have been accessed before for data in self.phone_times.arztPraxisDatas: # Remove empty phone number fields if data.tel == "": continue + # Get times of doctors in a day (maybe tsz = tageszeit?) doctor_day_times = data.tsz phone_times = [] + # Get phone times on every given day for day in doctor_day_times: + # If available -> phone time(s) detected if day.tszDesTyps: + # Collect every phone time for contact_times in day.tszDesTyps: + # Check if phone time is actually phone time and not only opening time if contact_times.typ == "Telefonische Erreichbarkeit": + # Get as call time available for speaking with therapist phone_times_day = contact_times.sprechzeiten + # Process phone time properly for phone_time_day in phone_times_day: + # String magic since the actual phone time is given as string such as 9:00-10:00 start_time_str, end_time_str = phone_time_day.zeit.split("-") + # Parse both to datetime object start_date_time = self.parse_date_string(f"{day.d} {start_time_str}") end_date_time = self.parse_date_string(f"{day.d} {end_time_str}") + # Create a dict out of it current_phone_time_dict = { "start": start_date_time, "end": end_date_time } + # Dict to actual PhoneTime object -> input validation current_phone_time = PhoneTime(**current_phone_time_dict) + # Add result to overall list phone_times.append(current_phone_time) + # Collect relevant information of doctor/therapist doctor_information_dict = { "name": data.name, "tel": data.tel, @@ -128,63 +190,113 @@ class APIHandler: "ort": data.ort, "telefonzeiten": phone_times } + # Convert to actual DoctorInformation object -> input validation doctor_information = DoctorInformation(**doctor_information_dict) general_doctor_information.append(doctor_information) + # Save result to class for further processing and return for caller self.general_information = general_doctor_information return self.general_information def filter_doctor_information_for_distance(self, distance): """ - :param distance: in meters - :return: + Filter the given list of doctors based on the distance to them calculated by the given location as a result of + the original API call + :param distance: Distance to location given in meters for doctors within given radius + :return: No return, only filter class variable """ + + # Keep every doctor information within the given distance/radius self.general_information = [doctor_information for doctor_information in self.general_information if doctor_information.distance <= distance] def get_doctor_phone_times_sorted(self, therapy_phone_weeks): + """ + Sort the current list of doctor phone times by start date and return the result + :param therapy_phone_weeks: Amount of weeks for showing phone times + :return: + """ + + # Process doctor information to desired format with relevant information for phone times for doctor_information in self.general_information: + # Perspective: phone time as data object of choice for phone_time in doctor_information.telefonzeiten: + # Relevant information doctor_phone_time_dict = { "phone_time": phone_time, # workaround until properly assigned in sort "doctor_nr": 0, "doctor_name": doctor_information.name, + # Address in readable format "doctor_address": f"{doctor_information.plz} {doctor_information.ort} " f"{doctor_information.strasse} {doctor_information.hausnummer}", "doctor_phone_number": doctor_information.tel } + # Convert to actual DoctorPhoneTime object -> input validation doctor_phone_time = DoctorPhoneTime(**doctor_phone_time_dict) self.processed_doctor_phone_times.append(doctor_phone_time) + # Apply filter for desired amount of weeks for phone times self.filter_for_relevant_weeks(therapy_phone_weeks) + # Sort the times by the starting time self.processed_doctor_phone_times.sort(key=lambda dpt: dpt.phone_time.start) + # Throw out already filtered times self.filter_for_already_passed_times_today() + # Assign the numbers for showing them properly in the web interface self.assign_numbers_to_doctor_phone_times() return self.processed_doctor_phone_times def filter_for_relevant_weeks(self, therapy_phone_weeks): + """ + Get the desired amount of weeks and filter for them + :param therapy_phone_weeks: Desired amount of weeks for showing phone times + :return: None, class variable affected + """ + + # Get the current date for calculation and determining the next weeks current_date = datetime.now() + # Calculate the end date by a timedelta based on the given amount of weeks end_date = current_date + timedelta(weeks=therapy_phone_weeks) + # Filter for the relevant weeks self.processed_doctor_phone_times = [dpt for dpt in self.processed_doctor_phone_times if current_date <= dpt.phone_time.start <= end_date] def filter_for_already_passed_times_today(self): + """ + Get the phone times of the current day and filter already passed phone times since they are considered not + relevant anymore + :return: None, class variable affected + """ + + # Get the current date with time for calculations current_datetime = datetime.now() + # Check if a phone time on the same day has already passed based on the end time or if the phone time is on + # another date (and keep them) self.processed_doctor_phone_times = [dpt for dpt in self.processed_doctor_phone_times if (dpt.phone_time.start.date() == current_datetime.date() and dpt.phone_time.end > current_datetime) or dpt.phone_time.start.date() != current_datetime.date()] def assign_numbers_to_doctor_phone_times(self): + """ + Assign numbers to the doctors in phone time objects to distinct them from each other visually, since a doctor + can have multiple phone times in the next week(s) + :return: + """ + + # Store known doctors and their number known_doctor_names_with_nr = {} + # Users prefer starting with 1 instead of 0 doctor_count = 1 + # For every phone time, process the doctor for doctor_phone_time in self.processed_doctor_phone_times: + # New count for new doctor if doctor_phone_time.doctor_name not in known_doctor_names_with_nr: doctor_phone_time.doctor_nr = doctor_count known_doctor_names_with_nr[doctor_phone_time.doctor_name] = doctor_count doctor_count += 1 + # Add known count to phone time with known doctor else: known_doctor_count = known_doctor_names_with_nr[doctor_phone_time.doctor_name] doctor_phone_time.doctor_nr = known_doctor_count @@ -194,8 +306,8 @@ class APIHandler: """ This function is based on the initial Javascript code found in app.js. It is rewritten in Python to calculate the HTTP header req_val for proper requests with the correct location. - :param lat: - :param lon: + :param lat: Latitude given by the Arztsuche API + :param lon: Longitude given by the Arztsuche API :return: """ # Adjust lat and lon values slightly @@ -234,10 +346,20 @@ class APIHandler: @staticmethod def parse_date_string(date_string): + """ + Parse a date string given by the Arztsuche API and return an actual datetime object + :param date_string: Date string to parse + :return: parsed datetime as actual object or exception for failed parsing (we like input validation) + """ + + # String as given as result by the API (based on known values) format_string = "%d.%m. %H:%M" + # Get the current year since the year is not given as part of the date string to prevent the use of a wrong year current_year = datetime.now().year + # Try block to prevent invalid dates try: + # 24:00 can be part of a date string returned by the API - even though it doesn't make lots of sense if "24:00" in date_string: # Sometimes the API returns 24:00 as time, so filtering for those cases and replacing it with a minute # less to work with proper input diff --git a/arztapi/DoctorInformation.py b/arztapi/DoctorInformation.py index 6bedbe4..64decb1 100644 --- a/arztapi/DoctorInformation.py +++ b/arztapi/DoctorInformation.py @@ -5,16 +5,25 @@ from pydantic import BaseModel class PhoneTime(BaseModel): + """ + Mapping for PhoneTime/Telefonzeit in object for pydantic + """ start: datetime.datetime end: datetime.datetime class DoctorInformation(BaseModel): + """ + Mapping for information about doctor's (therapists) with for theraPy relevant information + """ + # Used for indexes later nr: Optional[int] = 0 name: str tel: str + # Fax is optionally available as contact information fax: Optional[str] = None anrede: str + # Email is also optionally available email: Optional[str] = None distance: int strasse: str diff --git a/arztapi/DoctorPhoneTime.py b/arztapi/DoctorPhoneTime.py index 2b1c2d1..611b235 100644 --- a/arztapi/DoctorPhoneTime.py +++ b/arztapi/DoctorPhoneTime.py @@ -3,6 +3,9 @@ from arztapi.DoctorInformation import PhoneTime class DoctorPhoneTime(BaseModel): + """ + Relevant phone time to show at the web interface + """ phone_time: PhoneTime doctor_nr: int doctor_name: str diff --git a/forms/SearchForm.py b/forms/SearchForm.py index 3f770b3..b36e691 100644 --- a/forms/SearchForm.py +++ b/forms/SearchForm.py @@ -5,45 +5,76 @@ from wtforms.widgets.core import CheckboxInput, ListWidget, Input def validate_therapy_types(form, field): + """ + Validate form input for given therapy types (mapping in input field) + :param form: required by calling method + :param field: data field for validation (therapy types) + :return: + """ + + # Given choices by the data source valid_choices = {"A", "S", "T", "V"} + # All of them in arbitrary sets are allowed if not all(choice in valid_choices for choice in field.data): raise validators.ValidationError("Invalid value in therapy types selection.") def validate_therapy_phone_times(form, field): + """ + Validate number of weeks for therapy phone times search. + :param form: required by calling method + :param field: data field for validation (therapy phone times in weeks) + :return: + """ + # Weeks from 1 to 4 valid_choices = {1, 2, 3, 4} + # Check for given data at all -> at least 1 week necessary if not field.data: raise validators.ValidationError("Please select at least one option.") + # All of them in arbitrary sets are allowed if not all(choice in valid_choices for choice in field.data): raise validators.ValidationError("Invalid value in therapy phone types selection.") class RangeInput(Input): + """ + Define a range as input type to use HTML element form-range + """ input_type = "range" class SearchForm(Form): + """ + Search Form to search with several parameters and constraints for doctors/therapists with phone times + """ + + # Match location as string or plz with arbitrary length location = StringField("Standort/PLZ", validators=[validators.InputRequired(), validators.Length(min=2, max=50), validators.Regexp("^[a-zA-ZäöüßÄÖÜẞ0-9]+$")]) + # Define a distance as range input to limit the search area therapy_distance = IntegerField( "Distanz (in km)", widget=RangeInput(), default=25, validators=[validators.InputRequired(), validators.NumberRange(min=0, max=50)] ) + # Define several therapy types (all of them possible to search for) therapy_type = SelectMultipleField("Therapieverfahren", choices=[ ("A", "Analytische Psychotherapie"), ("S", "Systemische Therapie"), ("T", "Tiefenpsychologisch fundierte Psychotherapie"), ("V", "Verhaltenstherapie")], option_widget=CheckboxInput(), widget=ListWidget(prefix_label=False), validators=[validate_therapy_types]) + # Distinction between adults/children & youth as radio button therapy_age_range = RadioField("Altersgruppe", choices=[ ("E", "Erwachsene"), ("K", "Kinder und Jugendliche")], validators=[validators.InputRequired(), validators.any_of(["E", "K"])]) + # Distinction between available therapy types therapy_setting = RadioField("Therapiesetting", choices=[ ("E", "Einzeltherapie"), ("G", "Gruppentherapie")], validators=[validators.InputRequired(), validators.any_of(["E", "G"])]) # TODO: find a better name for label + # Choose for how many weeks to show phone times amount_of_weeks = IntegerField( "Telefonzeiten für die nächsten Wochen", widget=RangeInput(), diff --git a/templates/result.html b/templates/result.html index 7f99873..e8f2446 100644 --- a/templates/result.html +++ b/templates/result.html @@ -5,6 +5,7 @@

Suchergebnisse

+ {% if doctors is defined and doctors|length %} @@ -37,6 +38,12 @@ {% endfor %}
+ {% else %} +
+ Keine Telefonzeiten mit gegebenen Daten gefunden + Zurück zur Suche, um es erneut zu versuchen +
+ {% endif %}