Add documentation
This commit is contained in:
parent
ab18c04d92
commit
092341e9ed
22
app.py
22
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)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(),
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user