Add documentation

This commit is contained in:
Lea 2024-09-07 21:12:55 +02:00
parent ab18c04d92
commit 092341e9ed
6 changed files with 198 additions and 6 deletions

22
app.py
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -5,6 +5,7 @@
<div class="row">
<div class="col-lg-12 col-md-11 col-sm-10">
<h2>Suchergebnisse</h2>
{% if doctors is defined and doctors|length %}
<table class="table table-hover">
<thead>
<tr>
@ -37,6 +38,12 @@
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-dismissible alert-primary">
<strong>Keine Telefonzeiten mit gegebenen Daten gefunden</strong>
<a href="/" class="alert-link">Zurück zur Suche</a>, um es erneut zu versuchen
</div>
{% endif %}
</div>
</div>
</div>