Spaces:
Sleeping
Sleeping
Add Cerner login feature.
Browse files- logo.jpg +0 -0
- pages/Cerner_LogIn.py +371 -0
- pages/logo.jpg +0 -0
logo.jpg
ADDED
![]() |
pages/Cerner_LogIn.py
ADDED
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
import requests
|
3 |
+
import base64
|
4 |
+
import uuid
|
5 |
+
import json
|
6 |
+
import os
|
7 |
+
from datetime import datetime
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
|
10 |
+
load_dotenv()
|
11 |
+
|
12 |
+
CERNER_CLIENT_ID = st.secrets["CERNER_CLIENT_ID"]
|
13 |
+
CERNER_CLIENT_SECRET = st.secrets["CERNER_CLIENT_SECRET"]
|
14 |
+
CERNER_TENANT_ID = st.secrets["CERNER_TENANT_ID"]
|
15 |
+
CERNER_AUTH_SERVER_URL = f"https://authorization.cerner.com/tenants/{CERNER_TENANT_ID}/protocols/oauth2/profiles/smart-v1/personas/provider/authorize"
|
16 |
+
CERNER_TOKEN_ENDPOINT = f"https://authorization.cerner.com/tenants/{CERNER_TENANT_ID}/protocols/oauth2/profiles/smart-v1/token"
|
17 |
+
CERNER_REDIRECT_URI = "https://huggingface.co/spaces/HengJay/snomed-ct-assistant" #"http://localhost:8501" #"https://huggingface.co/spaces/HengJay/snomed-ct-assistant"
|
18 |
+
CERNER_AUDIENCE_URL = f"https://fhir-ehr.cerner.com/r4/{CERNER_TENANT_ID}"
|
19 |
+
|
20 |
+
def get_fhir_url():
|
21 |
+
params = {
|
22 |
+
"response_type": "code",
|
23 |
+
"client_id": CERNER_CLIENT_ID,
|
24 |
+
"redirect_uri": CERNER_REDIRECT_URI,
|
25 |
+
"scope": "user/Practitioner.read user/Patient.read user/Observation.read openid profile",
|
26 |
+
"state": str(uuid.uuid4()),
|
27 |
+
"aud": CERNER_AUDIENCE_URL
|
28 |
+
}
|
29 |
+
oauth2_url = requests.Request('GET', CERNER_AUTH_SERVER_URL, params=params).prepare().url
|
30 |
+
return oauth2_url
|
31 |
+
|
32 |
+
def get_fhir_url_launch(launch):
|
33 |
+
params = {
|
34 |
+
"response_type": "code",
|
35 |
+
"client_id": CERNER_CLIENT_ID,
|
36 |
+
"redirect_uri": CERNER_REDIRECT_URI,
|
37 |
+
"scope": "user/Practitioner.read user/Patient.read user/Observation.read launch openid profile",
|
38 |
+
"state": str(uuid.uuid4()),
|
39 |
+
"aud": CERNER_AUDIENCE_URL,
|
40 |
+
"launch": launch
|
41 |
+
}
|
42 |
+
oauth2_url = requests.Request('GET', CERNER_AUTH_SERVER_URL, params=params).prepare().url
|
43 |
+
return oauth2_url
|
44 |
+
|
45 |
+
def get_fhir_token(auth_code):
|
46 |
+
auth_string = f"{CERNER_CLIENT_ID}:{CERNER_CLIENT_SECRET}".encode("ascii")
|
47 |
+
base64_bytes = base64.b64encode(auth_string)
|
48 |
+
base64_string = base64_bytes.decode("ascii")
|
49 |
+
|
50 |
+
auth_headers = {
|
51 |
+
"Authorization": f"Basic {base64_string}",
|
52 |
+
}
|
53 |
+
|
54 |
+
data = {
|
55 |
+
"grant_type": "authorization_code",
|
56 |
+
"code": auth_code,
|
57 |
+
"redirect_uri": CERNER_REDIRECT_URI,
|
58 |
+
"client_id": CERNER_CLIENT_ID,
|
59 |
+
"client_secret": CERNER_CLIENT_SECRET
|
60 |
+
}
|
61 |
+
|
62 |
+
response = requests.post(CERNER_TOKEN_ENDPOINT, headers=auth_headers, data=data)
|
63 |
+
return response.json()
|
64 |
+
|
65 |
+
def standalone_launch(url: str, text: str= None, color="#aa8ccc"):
|
66 |
+
st.markdown(
|
67 |
+
f"""
|
68 |
+
<a href="{url}" target="_self">
|
69 |
+
<div style="
|
70 |
+
display: inline-block;
|
71 |
+
padding: 0.5em 1em;
|
72 |
+
color: #FFFFFF;
|
73 |
+
background-color: {color};
|
74 |
+
border-radius: 3px;
|
75 |
+
text-decoration: none;">
|
76 |
+
{text}
|
77 |
+
</div>
|
78 |
+
</a>
|
79 |
+
""",
|
80 |
+
unsafe_allow_html=True
|
81 |
+
)
|
82 |
+
|
83 |
+
def get_practitioner(user_profile_url, access_token):
|
84 |
+
headers = {
|
85 |
+
"Accept": "application/fhir+json",
|
86 |
+
"Authorization": f"Bearer {access_token}"
|
87 |
+
}
|
88 |
+
|
89 |
+
response = requests.get(user_profile_url, headers=headers)
|
90 |
+
return response.json()
|
91 |
+
|
92 |
+
def get_patient(access_token, person_id):
|
93 |
+
base_url = f"https://fhir-ehr.cerner.com/r4/{CERNER_TENANT_ID}/Patient"
|
94 |
+
headers = {
|
95 |
+
"Accept": "application/fhir+json",
|
96 |
+
"Authorization": f"Bearer {access_token}"
|
97 |
+
}
|
98 |
+
query_params = {
|
99 |
+
"_id": person_id
|
100 |
+
}
|
101 |
+
|
102 |
+
response = requests.get(base_url, headers=headers, params=query_params)
|
103 |
+
return response.json()
|
104 |
+
|
105 |
+
def get_observation(access_token, person_id):
|
106 |
+
base_url = f"https://fhir-ehr.cerner.com/r4/{CERNER_TENANT_ID}/Observation"
|
107 |
+
headers = {
|
108 |
+
"Accept": "application/fhir+json",
|
109 |
+
"Authorization": f"Bearer {access_token}"
|
110 |
+
}
|
111 |
+
query_params = {
|
112 |
+
"patient": person_id
|
113 |
+
}
|
114 |
+
|
115 |
+
response = requests.get(base_url, headers=headers, params=query_params)
|
116 |
+
return response.json()
|
117 |
+
|
118 |
+
def fhir_params():
|
119 |
+
query_params = st.experimental_get_query_params()
|
120 |
+
auth_code = query_params.get("code")
|
121 |
+
iss_param = query_params.get("iss")
|
122 |
+
launch_param = query_params.get("launch")
|
123 |
+
print(f"fhir_params: {auth_code}, {iss_param}, {launch_param}")
|
124 |
+
return auth_code, iss_param, launch_param
|
125 |
+
|
126 |
+
def main():
|
127 |
+
logo, title = st.columns([1,1])
|
128 |
+
logo_image = 'logo.jpg'
|
129 |
+
|
130 |
+
with logo:
|
131 |
+
st.image(logo_image)
|
132 |
+
|
133 |
+
auth_code, iss_param, launch_param = fhir_params()
|
134 |
+
|
135 |
+
if auth_code is None and iss_param is None:
|
136 |
+
fhir_login_url = get_fhir_url()
|
137 |
+
st.subheader("Our App's Launch Capabilities")
|
138 |
+
st.markdown("""
|
139 |
+
1. EHR Launch: Seamlessly integrates with your EHR system.
|
140 |
+
2. Standalone Launch: No need for an EHR to start up. This app can independently access FHIR data as long as it's authorized and provided the relevant iss URL.
|
141 |
+
""")
|
142 |
+
st.subheader("How It Works")
|
143 |
+
st.markdown("""
|
144 |
+
* When the app gets a launch request, it seeks permission to access FHIR data.
|
145 |
+
* It does this by directing the browser to the EHR's authorization point.
|
146 |
+
* Depending on certain rules and potential user approval, the EHR authorization system either approves or denies the request.
|
147 |
+
* If approved, an authorization code is sent to the app, which is then swapped for an access token.
|
148 |
+
* This access token is your key to the EHR's resource data.
|
149 |
+
* Should a refresh token be provided with the access token, the app can utilize it to obtain a fresh access token once the original expires.
|
150 |
+
""")
|
151 |
+
|
152 |
+
standalone_launch(fhir_login_url, "Login")
|
153 |
+
|
154 |
+
if iss_param is not None:
|
155 |
+
fhir_login_url = get_fhir_url_launch(launch_param[0])
|
156 |
+
st.write(f"""
|
157 |
+
<meta http-equiv="refresh" content="0; URL={fhir_login_url}">
|
158 |
+
""", unsafe_allow_html=True)
|
159 |
+
|
160 |
+
if auth_code:
|
161 |
+
if 'token' in st.session_state and 'access_token' in st.session_state.token:
|
162 |
+
token = st.session_state.token
|
163 |
+
else:
|
164 |
+
token = get_fhir_token(auth_code[0])
|
165 |
+
if token.get('error') == 'invalid_grant':
|
166 |
+
fhir_login_url = get_fhir_url()
|
167 |
+
st.markdown("""
|
168 |
+
Session Expired
|
169 |
+
""")
|
170 |
+
standalone_launch(fhir_login_url, "Login")
|
171 |
+
return
|
172 |
+
else:
|
173 |
+
st.session_state.token = token
|
174 |
+
|
175 |
+
access_token = token.get('access_token')
|
176 |
+
st.session_state.access_token = access_token
|
177 |
+
|
178 |
+
header, payload, signature = token.get('id_token').split('.')
|
179 |
+
decoded_payload = base64.urlsafe_b64decode(payload + '==').decode('utf-8')
|
180 |
+
|
181 |
+
person_id = None
|
182 |
+
|
183 |
+
if 'person_id' not in st.session_state:
|
184 |
+
st.session_state.person_id = token.get('patient', "12724065") # If no person_id is provided, app defaults to WILMA SMART
|
185 |
+
|
186 |
+
person_id = st.session_state.person_id
|
187 |
+
|
188 |
+
if 'profile_data' not in st.session_state:
|
189 |
+
st.session_state.profile_data = json.loads(decoded_payload)
|
190 |
+
if 'practitioner_data' not in st.session_state:
|
191 |
+
st.session_state.practitioner_data = get_practitioner(st.session_state.profile_data.get('profile'), access_token)
|
192 |
+
if 'patient_data' not in st.session_state:
|
193 |
+
patient_data = get_patient(access_token, person_id)
|
194 |
+
if 'entry' in patient_data:
|
195 |
+
st.session_state.patient_data = patient_data
|
196 |
+
else:
|
197 |
+
st.session_state.patient_data = None
|
198 |
+
if 'observation_data' not in st.session_state:
|
199 |
+
observation_data = get_observation(access_token, person_id)
|
200 |
+
if 'entry' in observation_data:
|
201 |
+
st.session_state.observation_data = observation_data
|
202 |
+
else:
|
203 |
+
st.session_state.observation_data = None
|
204 |
+
|
205 |
+
resource_list = ['Profile', 'Practitioner', 'Patient', 'Observation']
|
206 |
+
resource = st.sidebar.selectbox("Resource:", resource_list, index=0)
|
207 |
+
|
208 |
+
if resource == 'Profile':
|
209 |
+
with title:
|
210 |
+
st.markdown('')
|
211 |
+
st.subheader('Cerner Profile')
|
212 |
+
profile_data = st.session_state.profile_data
|
213 |
+
profile_username = profile_data["sub"]
|
214 |
+
profile_full_name = profile_data["name"]
|
215 |
+
profile_token_iat = profile_data["iat"]
|
216 |
+
profile_token_exp = profile_data["exp"]
|
217 |
+
profile_token_iat = datetime.utcfromtimestamp(profile_token_iat).strftime('%Y-%m-%d %H:%M:%S UTC')
|
218 |
+
profile_token_exp = datetime.utcfromtimestamp(profile_token_exp).strftime('%Y-%m-%d %H:%M:%S UTC')
|
219 |
+
st.markdown("*This data shows profile details of the user currently signed in*")
|
220 |
+
st.markdown(f"""
|
221 |
+
* **Username:** {profile_username}
|
222 |
+
* **Name:** {profile_full_name}
|
223 |
+
* **Token Issued:** {profile_token_iat}
|
224 |
+
* **Token Expiration:** {profile_token_exp}
|
225 |
+
""")
|
226 |
+
if st.checkbox('Show JSON Response'):
|
227 |
+
st.json(st.session_state.profile_data)
|
228 |
+
if resource == 'Practitioner':
|
229 |
+
with title:
|
230 |
+
st.markdown('')
|
231 |
+
st.subheader('Practitioner Resource')
|
232 |
+
practitioner_data = st.session_state.practitioner_data
|
233 |
+
practitioner_name = practitioner_data["name"][0]["text"]
|
234 |
+
practitioner_npi = next(identifier["value"] for identifier in practitioner_data["identifier"] if identifier["type"]["coding"][0]["code"] == "NPI")
|
235 |
+
practitioner_email = next(telecom["value"] for telecom in practitioner_data["telecom"] if telecom["system"] == "email")
|
236 |
+
st.markdown("*This data shows details in the practitioner endpoint of the user currently signed in*")
|
237 |
+
st.markdown(f"""
|
238 |
+
* **Practitioner Name:** {practitioner_name}
|
239 |
+
* **NPI:** {practitioner_npi}
|
240 |
+
* **Email:** {practitioner_email}
|
241 |
+
""")
|
242 |
+
if st.checkbox('Show JSON Response'):
|
243 |
+
st.json(st.session_state.practitioner_data)
|
244 |
+
if resource == 'Patient':
|
245 |
+
with title:
|
246 |
+
st.markdown('')
|
247 |
+
st.subheader('Patient Resource')
|
248 |
+
if st.session_state.patient_data is None:
|
249 |
+
st.markdown("No patient data available.")
|
250 |
+
else:
|
251 |
+
patient_data = st.session_state.patient_data
|
252 |
+
patient_name = patient_data['entry'][0]['resource']['name'][0]['text']
|
253 |
+
patient_status = patient_data['entry'][0]['resource']['active']
|
254 |
+
if patient_status is True:
|
255 |
+
patient_status = 'active'
|
256 |
+
else:
|
257 |
+
patient_status = 'inactive'
|
258 |
+
patient_phone = next((telecom['value'] for telecom in patient_data['entry'][0]['resource']['telecom'] if telecom['system'] == 'phone'), None)
|
259 |
+
patient_address = patient_data['entry'][0]['resource']['address'][0]
|
260 |
+
patient_address = f"{patient_address['line'][0]}, {patient_address['city']}, {patient_address['state']} {patient_address['postalCode']}, {patient_address['country']}"
|
261 |
+
patient_email = next((telecom['value'] for telecom in patient_data['entry'][0]['resource']['telecom'] if telecom['system'] == 'email'), None)
|
262 |
+
patient_dob = patient_data['entry'][0]['resource']['birthDate']
|
263 |
+
patient_gender = patient_data['entry'][0]['resource']['gender']
|
264 |
+
patient_pref_lang = patient_data['entry'][0]['resource']['communication'][0]['language']['text']
|
265 |
+
patient_marital_status = patient_data['entry'][0]['resource']['maritalStatus']['text']
|
266 |
+
contact_person = patient_data['entry'][0]['resource']['contact'][0]
|
267 |
+
contact_person_name = contact_person['name']['text']
|
268 |
+
contact_person_phone = contact_person['telecom'][0]['value']
|
269 |
+
contact_person_relationship = contact_person['relationship'][0]['text']
|
270 |
+
st.markdown(f"*This data shows details in the patient endpoint of patient ID: {person_id}*")
|
271 |
+
st.markdown(f"""
|
272 |
+
* **Name:** {patient_name}
|
273 |
+
* **Status:** {patient_status}
|
274 |
+
* **Phone:** {patient_phone}
|
275 |
+
* **Address:** {patient_address}
|
276 |
+
* **Email:** {patient_email}
|
277 |
+
* **DOB:** {patient_dob}
|
278 |
+
* **Gender:** {patient_gender}
|
279 |
+
* **Preferred Language:** {patient_pref_lang}
|
280 |
+
* **Marital Status:** {patient_marital_status}
|
281 |
+
* **Contact Person Name:** {contact_person_name}
|
282 |
+
* **Contact Person Phone:** {contact_person_phone}
|
283 |
+
* **Contact Person Relationship:** {contact_person_relationship}
|
284 |
+
""")
|
285 |
+
if st.checkbox('Show JSON Response'):
|
286 |
+
st.json(st.session_state.patient_data)
|
287 |
+
if resource == 'Observation':
|
288 |
+
with title:
|
289 |
+
st.markdown('')
|
290 |
+
st.subheader('Observation Resource')
|
291 |
+
if st.session_state.observation_data is None:
|
292 |
+
st.markdown("No patient data available.")
|
293 |
+
else:
|
294 |
+
observation_data = st.session_state.observation_data
|
295 |
+
weight_with_date = []
|
296 |
+
bp_with_date = []
|
297 |
+
for i in observation_data.get('entry', []):
|
298 |
+
resource = i.get('resource', {})
|
299 |
+
status = resource.get('status', '')
|
300 |
+
if status == 'final':
|
301 |
+
category_list = resource.get('category', [])
|
302 |
+
for category in category_list:
|
303 |
+
category_coding = category.get('coding', [])
|
304 |
+
for coding in category_coding:
|
305 |
+
if coding.get('code', '') == 'vital-signs':
|
306 |
+
code_info = resource.get('code', {})
|
307 |
+
code_coding = code_info.get('coding', [])
|
308 |
+
for code in code_coding:
|
309 |
+
display = code.get('display', '')
|
310 |
+
if display.lower() in ['weight measured']:
|
311 |
+
value_quantity = resource.get('valueQuantity', {})
|
312 |
+
value = value_quantity.get('value', 'N/A')
|
313 |
+
unit = value_quantity.get('unit', '')
|
314 |
+
effective_date = resource.get('effectiveDateTime', 'N/A')
|
315 |
+
weight_with_date.append({
|
316 |
+
'Value': value,
|
317 |
+
'Unit': unit,
|
318 |
+
'Date': effective_date
|
319 |
+
})
|
320 |
+
code_data = resource.get('code', {})
|
321 |
+
if any(coding.get('code', '') == '85354-9' for coding in code_data.get('coding', [])):
|
322 |
+
effective_date_time = resource.get('effectiveDateTime', 'N/A')
|
323 |
+
|
324 |
+
systolic_pressure = None
|
325 |
+
diastolic_pressure = None
|
326 |
+
|
327 |
+
for component in resource.get('component', []):
|
328 |
+
code_comp_data = component.get('code', {})
|
329 |
+
|
330 |
+
# Systolic blood pressure
|
331 |
+
if any(coding.get('code', '') in ['8460-8', '8480-6'] for coding in code_comp_data.get('coding', [])):
|
332 |
+
value_quantity = component.get('valueQuantity', {})
|
333 |
+
systolic_pressure = {
|
334 |
+
'value': value_quantity.get('value', 'N/A'),
|
335 |
+
'unit': value_quantity.get('unit', '')
|
336 |
+
}
|
337 |
+
|
338 |
+
# Diastolic blood pressure
|
339 |
+
if any(coding.get('code', '') in ['8454-1', '8462-4'] for coding in code_comp_data.get('coding', [])):
|
340 |
+
value_quantity = component.get('valueQuantity', {})
|
341 |
+
diastolic_pressure = {
|
342 |
+
'value': value_quantity.get('value', 'N/A'),
|
343 |
+
'unit': value_quantity.get('unit', '')
|
344 |
+
}
|
345 |
+
|
346 |
+
if systolic_pressure and diastolic_pressure:
|
347 |
+
bp_with_date.append({
|
348 |
+
'Date': effective_date_time,
|
349 |
+
'Systolic': systolic_pressure,
|
350 |
+
'Diastolic': diastolic_pressure
|
351 |
+
})
|
352 |
+
st.markdown(f"*This data shows details in the observation endpoint of patient ID: {person_id}*")
|
353 |
+
if weight_with_date:
|
354 |
+
st.subheader("Weight")
|
355 |
+
for i in weight_with_date:
|
356 |
+
st.markdown(f"""
|
357 |
+
**Date:** {i['Date']}
|
358 |
+
* **Weight:** {i['Value']}{i['Unit']}
|
359 |
+
""")
|
360 |
+
if bp_with_date:
|
361 |
+
st.subheader("Blood Pressure")
|
362 |
+
for j in bp_with_date:
|
363 |
+
st.markdown(f"""
|
364 |
+
**Date:** {j['Date']}
|
365 |
+
* **Systolic:** {j['Systolic']['value']}{j['Systolic']['unit']}, **Diastolic:** {j['Diastolic']['value']}{j['Diastolic']['unit']}
|
366 |
+
""")
|
367 |
+
if st.checkbox('Show JSON Response'):
|
368 |
+
st.json(st.session_state.observation_data)
|
369 |
+
|
370 |
+
if __name__ == "__main__":
|
371 |
+
main()
|
pages/logo.jpg
ADDED
![]() |