From 760555b41cf9f02d1c06c7dd700e9425314ec86a Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Mon, 11 Mar 2024 18:04:45 -0600 Subject: [PATCH 01/30] Error on apps with duplicate YTS + PDC Resolves #51 --- config_sample.json | 5 +++-- ps_core.py | 31 ++++++++++++++++++++++++++++++- ps_format.py | 2 ++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/config_sample.json b/config_sample.json index 25d6397..4790227 100644 --- a/config_sample.json +++ b/config_sample.json @@ -99,8 +99,8 @@ }, "teams": { "enabled": false, - "webHookURL" : "[YOUR TEAMS WEBHOOK GOES HERE]", - "title" : "PowerSlate [Production] Alert" + "webHookURL": "[YOUR TEAMS WEBHOOK GOES HERE]", + "title": "PowerSlate [Production] Alert" }, "scheduled_actions": { "enabled": false, @@ -138,6 +138,7 @@ "error_academic_row_not_found": "Record not found by Year/Term/Session/Program/Degree/Curriculum.", "error_invalid_college_attend": "College Attend is set to {} in PowerCampus, which is not valid for applicants.", "error_api_missing_database": "The PowerCampus Web API is not functioning properly. You may need to remove and reinstall the application.", + "error_duplicate_apps": "Person has multiple applications with the same YTS + PCD.", "sync_done": "Sync completed with no errors.", "sync_done_not_found": "Sync completed, but one or more applications had integration errors." }, diff --git a/ps_core.py b/ps_core.py index 26fbae6..f81af23 100644 --- a/ps_core.py +++ b/ps_core.py @@ -319,6 +319,25 @@ def main_sync(pid=None): CURRENT_RECORD = k apps[k] = format_app_generic(v, CONFIG["slate_upload_active"]) + # Set error flag if one pid has multiple applications with the same YTS + PCD + duplicates = [] + for k, v in apps.items(): + duplicates.extend( + [ + kk + for kk, vv in apps.items() + if vv["pid"] == v["pid"] + and vv["Program"] == v["Program"] + and vv["Degree"] == v["Degree"] + and vv["YearTerm"] == v["YearTerm"] + and k != kk + ] + ) + + for k in duplicates: + apps[k]["error_flag"] = True + apps[k]["error_message"] = MSG_STRINGS["error_duplicate_apps"] + if SETTINGS.powercampus.autoconfigure_mappings.enabled: verbose_print("Auto-configure ProgramOfStudy and recruiterMapping.xml") CURRENT_RECORD = None @@ -337,6 +356,8 @@ def main_sync(pid=None): verbose_print("Check each app's status flags/PCID in PowerCampus") for k, v in apps.items(): + if v["error_flag"] == True: + continue CURRENT_RECORD = k status_ra, status_app, status_calc, pcid = ps_powercampus.scan_status(v) apps[k].update( @@ -350,6 +371,8 @@ def main_sync(pid=None): verbose_print("Post new or repost unprocessed applications to PowerCampus API") for k, v in apps.items(): + if v["error_flag"] == True: + continue CURRENT_RECORD = k if (v["status_ra"] == None) or ( v["status_ra"] in (1, 2) and v["status_app"] is None @@ -377,7 +400,11 @@ def main_sync(pid=None): CURRENT_RECORD = None # Send list of app GUID's to Slate; get back checklist items actions_list = slate_get_actions( - [k for (k, v) in apps.items() if v["status_calc"] == "Active"] + [ + k + for (k, v) in apps.items() + if v["status_calc"] == "Active" and v["error_flag"] == False + ] ) if CONFIG["scheduled_actions"]["autolearn_action_codes"] == True: @@ -386,6 +413,8 @@ def main_sync(pid=None): verbose_print("Update existing applications in PowerCampus and extract information") edu_sync_results = [] for k, v in apps.items(): + if v["error_flag"] == False: + continue CURRENT_RECORD = k if v["status_calc"] == "Active": # Transform to PowerCampus format diff --git a/ps_format.py b/ps_format.py index 463822f..017e9d9 100644 --- a/ps_format.py +++ b/ps_format.py @@ -149,6 +149,8 @@ def format_app_generic(app, cfg_fields): """Supply missing fields and correct datatypes. Returns a flat dict.""" mapped = format_blank_to_null(app) + mapped["error_flag"] = False + mapped["error_message"] = None fields_null = [k for (k, v) in ps_models.fields.items() if v["supply_null"] == True] fields_bool = [k for (k, v) in ps_models.fields.items() if v["type"] == bool] From 63580ee2736883ef51e557916cf13b515e2736bf Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Thu, 21 Mar 2024 19:30:22 -0600 Subject: [PATCH 02/30] 9.2.3 API initial compatibility Very lightly tested. Swagger definition includes 3-valued Programs but does not actually work. Use the old 2-valued style for now. New features include: -Token-based auth -Auto-configure Program of Study Data Filters in Application Form -More use of settings class -Changes to Veteran fields -Modified error handling; API now returns 202 instead of 200 --- SQL/[custom].[PS_updProgramOfStudy].sql | 40 +++++++++++++--- config_sample.json | 6 ++- ps_core.py | 42 ++++++++++------- ps_format.py | 34 +++++++------ ps_models.py | 2 +- ps_powercampus.py | 63 +++++++++++++++---------- 6 files changed, 121 insertions(+), 66 deletions(-) diff --git a/SQL/[custom].[PS_updProgramOfStudy].sql b/SQL/[custom].[PS_updProgramOfStudy].sql index 93e99f1..272de7a 100644 --- a/SQL/[custom].[PS_updProgramOfStudy].sql +++ b/SQL/[custom].[PS_updProgramOfStudy].sql @@ -11,13 +11,16 @@ GO -- ============================================= -- Author: Wyatt Best -- Create date: 2021-05-17 --- Description: Inserts a PDC combination into ProgramOfStudy if it doesn't already exist. +-- Description: Inserts a PDC combination into ProgramOfStudy and ApplicationProgramSetting if it doesn't already exist. -- If @DegReqMinYear is not null, PDC combination will be valided against DEGREQ. +-- +-- 2024-03-21 Wyatt Best: Also insert into ApplicationProgramSetting. -- ============================================= CREATE PROCEDURE [custom].[PS_updProgramOfStudy] @Program NVARCHAR(6) ,@Degree NVARCHAR(6) ,@Curriculum NVARCHAR(6) ,@DegReqMinYear NVARCHAR(4) = NULL + ,@AppFormSettingId INT AS BEGIN SET NOCOUNT ON; @@ -36,7 +39,7 @@ BEGIN SELECT CurriculumId FROM CODE_CURRICULUM WHERE CODE_VALUE_KEY = @Curriculum - ) + ); --Error checks IF @ProgramId IS NULL @@ -92,14 +95,17 @@ BEGIN RETURN END - --Check for existing ProgramOfStudy row - IF NOT EXISTS ( - SELECT * + --Get existing ProgramOfStudyId + DECLARE @ProgramOfStudyId INT = ( + SELECT ProgramOfStudyId FROM ProgramOfStudy WHERE Program = @ProgramId AND Degree = @DegreeId AND Curriculum = @CurriculumId - ) + ); + + --If ProgramOfStudyId is null, insert it + IF @ProgramOfStudyId IS NULL BEGIN --Optionally check against DEGREQ IF @DegReqMinYear IS NOT NULL @@ -135,6 +141,28 @@ BEGIN @ProgramId ,@DegreeId ,@CurriculumId + ); + + --Get the newly-inserted ProgramOfStudyId + SET @ProgramOfStudyId = SCOPE_IDENTITY(); + END + + --Check for existing ApplicationProgramSetting row + IF NOT EXISTS ( + SELECT * + FROM ApplicationProgramSetting + WHERE ApplicationFormSettingId = @AppFormSettingId + AND ProgramOfStudyId = @ProgramOfStudyId ) + BEGIN + --Insert new ProgramOfStudyId + INSERT INTO ApplicationProgramSetting ( + ApplicationFormSettingId + ,ProgramOfStudyId + ) + VALUES ( + @AppFormSettingId + ,@ProgramOfStudyId + ); END END diff --git a/config_sample.json b/config_sample.json index 4790227..676d0cd 100644 --- a/config_sample.json +++ b/config_sample.json @@ -2,8 +2,11 @@ "powercampus": { "api": { "url": "https://webapi.school.edu/", + "auth_method": "[token|basic]", "username": "username", - "password": "astrongpassword" + "password": "astrongpassword", + "token": "Bearer [YOUR TOKEN]", + "app_form_setting_id": 3 }, "autoconfigure_mappings": { "enabled": false, @@ -32,7 +35,6 @@ "READ", "blank" ], - "app_form_setting_id": 3, "campus_emailtype": "CAMPUS", "database_string": "Driver={ODBC Driver 17 for SQL Server};Server=servername;Database=campus6;Trusted_Connection=yes;ServerSPN=MSSQLSvc/servername.local.domain.edu;", "mapping_file_location": "\\\\servername\\PowerCampus Mapper\\recruiterMapping.xml", diff --git a/ps_core.py b/ps_core.py index f81af23..52a8e3c 100644 --- a/ps_core.py +++ b/ps_core.py @@ -20,6 +20,7 @@ def __init__(self, config): self.powercampus = self.PowerCampus(config["powercampus"]) self.console_verbose = config["console_verbose"] self.msg_strings = self.FlatDict(config["msg_strings"]) + self.defaults = self.FlatDict(config["defaults"]) class PowerCampus: def __init__(self, config): @@ -327,10 +328,12 @@ def main_sync(pid=None): kk for kk, vv in apps.items() if vv["pid"] == v["pid"] - and vv["Program"] == v["Program"] - and vv["Degree"] == v["Degree"] + and k != kk # Not self + and vv["program"] == v["program"] + and vv["degree"] == v["degree"] + # Needed if and when switching from two to three fields for 9.2.3 + and vv["curriculum"] == v["curriculum"] and vv["YearTerm"] == v["YearTerm"] - and k != kk ] ) @@ -344,14 +347,22 @@ def main_sync(pid=None): mfl = SETTINGS.powercampus.mapping_file_location vd = SETTINGS.powercampus.autoconfigure_mappings.validate_degreq mdy = SETTINGS.powercampus.autoconfigure_mappings.minimum_degreq_year - dp_list = [ - (apps[app]["Program"], apps[app]["Degree"]) - for app in apps - if "Degree" in apps[app] - ] + # Accept either two or three fields for 9.2.3 + if ({k for k in apps if 'degree' in apps[k]}): + program_list = [ + (apps[app]["program"], apps[app]["degree"], apps[app]["curriculum"]) + for app in apps + if "curriculum" in apps[app] + ] + else: + program_list = [ + (apps[app]["Program"], apps[app]["Degree"]) + for app in apps + if "Degree" in apps[app] + ] yt_list = [apps[app]["YearTerm"] for app in apps if "YearTerm" in apps[app]] - if ps_powercampus.autoconfigure_mappings(dp_list, yt_list, vd, mdy, mfl): + if ps_powercampus.autoconfigure_mappings(program_list, yt_list, vd, mdy, mfl): RM_MAPPING = ps_powercampus.get_recruiter_mapping(mfl) verbose_print("Check each app's status flags/PCID in PowerCampus") @@ -374,14 +385,13 @@ def main_sync(pid=None): if v["error_flag"] == True: continue CURRENT_RECORD = k - if (v["status_ra"] == None) or ( - v["status_ra"] in (1, 2) and v["status_app"] is None + if ( + (v["status_ra"] == None) + or (v["status_ra"] in (1, 2) and v["status_app"] is None) + or (v["status_ra"] == 0 and v["status_app"] == None) # 9.2.3 new bad status ): - pcid = ps_powercampus.post_api( - format_app_api(v, CONFIG["defaults"]), - MSG_STRINGS, - SETTINGS.powercampus.app_form_setting_id, - ) + app = format_app_api(v, SETTINGS.defaults) + pcid = ps_powercampus.post_api(app, SETTINGS.powercampus.api, MSG_STRINGS) apps[k]["PEOPLE_CODE_ID"] = pcid # Rescan status diff --git a/ps_format.py b/ps_format.py index 017e9d9..0ecb86c 100644 --- a/ps_format.py +++ b/ps_format.py @@ -235,7 +235,7 @@ def format_app_api(app, cfg_defaults): if "PostalCode" not in k: k["PostalCode"] = None if "County" not in k: - k["County"] = cfg_defaults["address_country"] + k["County"] = cfg_defaults.address_country if len([k for k in app if k[:5] == "Phone"]) > 0: has_phones = True @@ -263,30 +263,39 @@ def format_app_api(app, cfg_defaults): item["Number"] = format_phone_number(item["Number"]) if "Type" not in item: - item["Type"] = cfg_defaults["phone_type"] + item["Type"] = cfg_defaults.phone_type else: item["Type"] = int(item["Type"]) if "Country" not in item: - item["Country"] = cfg_defaults["phone_country"] + item["Country"] = cfg_defaults.phone_country else: # PowerCampus WebAPI requires Type -1 instead of a blank or null when not submitting any phones. mapped["PhoneNumbers"] = [{"Type": -1, "Country": None, "Number": None}] - # Veteran has funny logic, and API 8.8.3 is broken (passing in 1 will write 2 into [Application].[VeteranStatus]). - # Impact is low because custom SQL routines will fix Veteran field once person has passed Handle Applications. + # Veteran logic was updated in API 9.2.x if app["Veteran"] is None: - mapped["Veteran"] = 0 + mapped["Veteran"] = None mapped["VeteranStatus"] = False else: - mapped["Veteran"] = int(app["Veteran"]) + mapped["Veteran"] = app["Veteran"] mapped["VeteranStatus"] = True # Academic program - mapped["Programs"] = [ - {"Program": app["Program"], "Degree": app["Degree"], "Curriculum": None} - ] + # API 9.2.3 seems to accept either three or two fields + if "curriculum" in app: + mapped["Programs"] = [ + { + "program": app["program"], + "degree": app["degree"], + "curriculum": app["curriculum"], + } + ] + else: + mapped["Programs"] = [ + {"Program": app["Program"], "Degree": app["Degree"], "Curriculum": None} + ] # GUID's mapped["ApplicationNumber"] = app["aid"] @@ -358,11 +367,6 @@ def format_app_sql(app, mapping, config): else: mapped["VISA"] = None - if app["Veteran"] is not None: - mapped["VETERAN"] = mapping["Veteran"][str(app["Veteran"])] - else: - mapped["VETERAN"] = None - if app["SecondaryCitizenship"] is not None: mapped["SECONDARYCITIZENSHIP"] = mapping["CitizenshipStatus"][ app["SecondaryCitizenship"] diff --git a/ps_models.py b/ps_models.py index 33c848a..c859a45 100644 --- a/ps_models.py +++ b/ps_models.py @@ -301,7 +301,7 @@ }, "Veteran": { "api_verbatim": False, - "sql_verbatim": False, + "sql_verbatim": True, "supply_null": True, "type": str, }, diff --git a/ps_powercampus.py b/ps_powercampus.py index 0817e31..4120ba6 100644 --- a/ps_powercampus.py +++ b/ps_powercampus.py @@ -6,8 +6,6 @@ def init(config, verbose, msg_strings): - global PC_API_URL - global PC_API_CRED global CNXN global CURSOR global CONFIG @@ -18,23 +16,19 @@ def init(config, verbose, msg_strings): VERBOSE = verbose MSG_STRINGS = msg_strings - # PowerCampus Web API connection - PC_API_URL = config.api.url - PC_API_CRED = (config.api.username, config.api.password) - # Microsoft SQL Server connection. CNXN = pyodbc.connect(config.database_string) CURSOR = CNXN.cursor() # Print a test of connections - r = requests.get(PC_API_URL + "api/version", auth=PC_API_CRED) + r = requests.get(config.api.url + "api/version") verbose_print("PowerCampus API Status: " + str(r.status_code)) verbose_print(r.text) r.raise_for_status() verbose_print("Database:" + CNXN.getinfo(pyodbc.SQL_DATABASE_NAME)) # Enable ApplicationFormSetting's ProcessAutomatically in case program exited abnormally last time with setting toggled off. - update_app_form_autoprocess(config.app_form_setting_id, True) + update_app_form_autoprocess(config.api.app_form_setting_id, True) def de_init(): @@ -56,7 +50,7 @@ def verbose_print(x): def autoconfigure_mappings( - dp_list, yt_list, validate_degreq, minimum_degreq_year, mapping_file_location + program_list, yt_list, validate_degreq, minimum_degreq_year, mapping_file_location ): """ Automatically insert new Program/Degree/Curriculum combinations into ProgramOfStudy and recruiterMapping.xml @@ -65,24 +59,29 @@ def autoconfigure_mappings( and that YearTerm values are like YEAR/TERM/SESSION. Keyword aguments: - dp_list -- a list of tuples like [('PROGRAM','DEGREE/CURRICULUM'), (...)] + program_list -- list of tuples like [('PROGRAM','DEGREE/CURRICULUM'), (...)] or [('PROGRAM','DEGREE','CURRICULUM'), (...)] + yt_list -- list of strings like ['YEAR/TERM/SESSION', ...] validate_degreq -- bool. If True, check against DEGREQ for sanity using minimum_degreq_year. minimum_degreq_year -- str + mapping_file_location -- str. Path to recruiterMapping.xml Returns True if XML mapping changed. """ - dp_set = set(dp_list) + program_set = set(program_list) yt_set = set(yt_list) if validate_degreq == False: minimum_degreq_year = None # Create set of tuples like {('PROGRAM','DEGREE', 'CURRICULUM'), (...)} pdc_set = set() - for dp in dp_set: - pdc = [dp[0]] - for dc in dp[1].split("/"): - pdc.append(dc) - pdc_set.add(tuple(pdc)) + if len(program_list[0]) == 2: + for dp in program_set: + pdc = [dp[0]] + for dc in dp[1].split("/"): + pdc.append(dc) + pdc_set.add(tuple(pdc)) + elif len(program_list[0]) == 3: + pdc_set = program_set # Create a set like {'PROGRAM', 'PROGRAM'} p_set = set() @@ -255,7 +254,7 @@ def get_recruiter_mapping(mapping_file_location): return rm_mapping -def post_api(x, cfg_strings, app_form_setting_id): +def post_api(app, config, msg_strings): """Post an application to PowerCampus. Return PEOPLE_CODE_ID if application was automatically accepted or None for all other conditions. @@ -263,17 +262,26 @@ def post_api(x, cfg_strings, app_form_setting_id): x -- an application dict """ + creds = None + headers = None + if config.auth_method == "basic": + creds = (config.username, config.password) + elif config.auth_method == "token": + headers = {"Authorization": config.token} + # Check for duplicate person. If found, temporarily toggle auto-process off. dup_found = False - CURSOR.execute("EXEC [custom].[PS_selPersonDuplicate] ?", x["GovernmentId"]) + CURSOR.execute("EXEC [custom].[PS_selPersonDuplicate] ?", app["GovernmentId"]) row = CURSOR.fetchone() dup_found = row.DuplicateFound if dup_found: - update_app_form_autoprocess(app_form_setting_id, False) + update_app_form_autoprocess(config.app_form_setting_id, False) # Expose error text response from API, replace useless error message(s). try: - r = requests.post(PC_API_URL + "api/applications", json=x, auth=PC_API_CRED) + r = requests.post( + config.url + "api/applications", json=app, auth=creds, headers=headers + ) r.raise_for_status() # The API returns 202 for mapping errors. Technically 202 is appropriate, but it should bubble up to the user. if r.status_code == 202: @@ -283,26 +291,29 @@ def post_api(x, cfg_strings, app_form_setting_id): rtext = r.text.replace("\r\n", "\n") if dup_found: - update_app_form_autoprocess(app_form_setting_id, True) + update_app_form_autoprocess(config.app_form_setting_id, True) if ( "BadRequest Object reference not set to an instance of an object." in rtext and "ApplicationsController.cs:line 183" in rtext ): - raise ValueError(cfg_strings["error_no_phones"], rtext, e) + raise ValueError(msg_strings["error_no_phones"], rtext, e) elif ( "BadRequest Activation error occured while trying to get instance of type Database, key" in rtext and "ServiceLocatorImplBase.cs:line 53" in rtext ): - raise ValueError(cfg_strings["error_api_missing_database"], rtext, e) - elif r.status_code == 202 or r.status_code == 400: + raise ValueError(msg_strings["error_api_missing_database"], rtext, e) + elif ( + r.status_code == 202 + and "was created successfully in PowerCampus" not in r.text + ) or r.status_code == 400: raise ValueError(rtext) - else: + elif "was created successfully in PowerCampus" not in r.text: raise requests.HTTPError(rtext) if dup_found: - update_app_form_autoprocess(app_form_setting_id, True) + update_app_form_autoprocess(config.app_form_setting_id, True) if r.text[-25:-12] == "New People Id": try: From 85f5c4e139ba9c37faaa090be7ab5c8ab743f136 Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:07:04 -0600 Subject: [PATCH 03/30] Handle missing YearTerm error Breaks msg_strings into its own config file. Also fix missing parameter for PS_updProgramOfStudy Program, Degree, Curriculum should always be capitalized --- config_messages.json | 15 ++++++ config_sample.json | 10 ---- ps_core.py | 118 +++++++++++++++++++++++++------------------ ps_format.py | 19 ++++--- ps_powercampus.py | 26 ++++++---- 5 files changed, 112 insertions(+), 76 deletions(-) create mode 100644 config_messages.json diff --git a/config_messages.json b/config_messages.json new file mode 100644 index 0000000..e7b1d00 --- /dev/null +++ b/config_messages.json @@ -0,0 +1,15 @@ +{ + "error": { + "no_apps": "
No applications found. Perhaps the application(s) are not submitted or are missing required fields?
", + "no_phones": "Application must have at least one phone number.", + "academic_row_not_found": "Record not found by Year/Term/Session/Program/Degree/Curriculum.", + "invalid_college_attend": "College Attend is set to {} in PowerCampus, which is not valid for applicants.", + "api_missing_database": "The PowerCampus Web API is not functioning properly. You may need to remove and reinstall the application.", + "duplicate_apps": "Person has multiple applications with the same YTS + PCD.", + "missing_yt": "Year/Term/Session is missing from the application." + }, + "success": { + "done": "Sync completed with no errors.", + "done_not_found": "Sync completed, but one or more applications had integration errors." + } +} diff --git a/config_sample.json b/config_sample.json index 676d0cd..0a84833 100644 --- a/config_sample.json +++ b/config_sample.json @@ -134,16 +134,6 @@ "phone_country": "US", "phone_type": 1 }, - "msg_strings": { - "error_no_apps": "No applications found. Perhaps the application(s) are not submitted or are missing required fields?
", - "error_no_phones": "Application must have at least one phone number.", - "error_academic_row_not_found": "Record not found by Year/Term/Session/Program/Degree/Curriculum.", - "error_invalid_college_attend": "College Attend is set to {} in PowerCampus, which is not valid for applicants.", - "error_api_missing_database": "The PowerCampus Web API is not functioning properly. You may need to remove and reinstall the application.", - "error_duplicate_apps": "Person has multiple applications with the same YTS + PCD.", - "sync_done": "Sync completed with no errors.", - "sync_done_not_found": "Sync completed, but one or more applications had integration errors." - }, "http_port": null, "http_ip": null } diff --git a/ps_core.py b/ps_core.py index 52a8e3c..f06cfd5 100644 --- a/ps_core.py +++ b/ps_core.py @@ -15,12 +15,17 @@ # The Settings class should replace the CONFIG global in all new code. class Settings: + class FlatDict: + def __init__(self, contents): + for field in contents: + setattr(self, field, contents[field]) + def __init__(self, config): self.fa_awards = self.FlatDict(config["fa_awards"]) - self.powercampus = self.PowerCampus(config["powercampus"]) self.console_verbose = config["console_verbose"] - self.msg_strings = self.FlatDict(config["msg_strings"]) self.defaults = self.FlatDict(config["defaults"]) + self.PowerCampus = self.PowerCampus(config["powercampus"]) + self.Messages = self.Messages() class PowerCampus: def __init__(self, config): @@ -32,35 +37,34 @@ def __init__(self, config): for d in dicts: setattr(self, d, Settings.FlatDict(config[d])) - class FlatDict: - def __init__(self, contents): - for field in contents: - setattr(self, field, contents[field]) + class Messages: + def __init__(self): + with open("config_messages.json") as file: + messages = json.loads(file.read()) + self.error = Settings.FlatDict(messages["error"]) + self.success = Settings.FlatDict(messages["success"]) def init(config_path): """Reads config file to global CONFIG dict. Many frequently-used variables are copied to their own globals for convenince.""" global CONFIG global CONFIG_PATH - global FIELDS global RM_MAPPING global MSG_STRINGS - global SETTINGS # New global for Settings class + global Settings # New global for Settings class CONFIG_PATH = config_path with open(CONFIG_PATH) as file: CONFIG = json.loads(file.read()) - SETTINGS = Settings(CONFIG) + Settings = Settings(CONFIG) RM_MAPPING = ps_powercampus.get_recruiter_mapping( - SETTINGS.powercampus.mapping_file_location + Settings.PowerCampus.mapping_file_location ) MSG_STRINGS = CONFIG["msg_strings"] # Init PowerCampus API and SQL connections - ps_powercampus.init( - SETTINGS.powercampus, SETTINGS.console_verbose, SETTINGS.msg_strings - ) + ps_powercampus.init(Settings.PowerCampus, Settings.console_verbose) return CONFIG @@ -310,7 +314,7 @@ def main_sync(pid=None): apps = {k["aid"]: k for k in apps} if len(apps) == 0 and pid is not None: # Assuming we're running in interactive (HTTP) mode if pid param exists - raise EOFError(MSG_STRINGS["error_no_apps"]) + raise EOFError(Settings.Messages.error.no_apps) elif len(apps) == 0: # Don't raise an error for scheduled mode return None @@ -318,7 +322,9 @@ def main_sync(pid=None): verbose_print("Clean up app data from Slate (datatypes, supply nulls, etc.)") for k, v in apps.items(): CURRENT_RECORD = k - apps[k] = format_app_generic(v, CONFIG["slate_upload_active"]) + apps[k] = format_app_generic( + v, CONFIG["slate_upload_active"], Settings.Messages + ) # Set error flag if one pid has multiple applications with the same YTS + PCD duplicates = [] @@ -329,30 +335,31 @@ def main_sync(pid=None): for kk, vv in apps.items() if vv["pid"] == v["pid"] and k != kk # Not self - and vv["program"] == v["program"] - and vv["degree"] == v["degree"] + and vv["Program"] == v["Program"] + and vv["Degree"] == v["Degree"] # Needed if and when switching from two to three fields for 9.2.3 - and vv["curriculum"] == v["curriculum"] + and vv["Curriculum"] == v["Curriculum"] and vv["YearTerm"] == v["YearTerm"] ] ) for k in duplicates: apps[k]["error_flag"] = True - apps[k]["error_message"] = MSG_STRINGS["error_duplicate_apps"] + apps[k]["error_message"] = Settings.Messages.error.duplicate_apps - if SETTINGS.powercampus.autoconfigure_mappings.enabled: + if Settings.PowerCampus.autoconfigure_mappings.enabled: verbose_print("Auto-configure ProgramOfStudy and recruiterMapping.xml") CURRENT_RECORD = None - mfl = SETTINGS.powercampus.mapping_file_location - vd = SETTINGS.powercampus.autoconfigure_mappings.validate_degreq - mdy = SETTINGS.powercampus.autoconfigure_mappings.minimum_degreq_year + mfl = Settings.PowerCampus.mapping_file_location + vd = Settings.PowerCampus.autoconfigure_mappings.validate_degreq + mdy = Settings.PowerCampus.autoconfigure_mappings.minimum_degreq_year + afsi = Settings.PowerCampus.api.app_form_setting_id # Accept either two or three fields for 9.2.3 - if ({k for k in apps if 'degree' in apps[k]}): + if {k for k in apps if "Degree" in apps[k]}: program_list = [ - (apps[app]["program"], apps[app]["degree"], apps[app]["curriculum"]) + (apps[app]["Program"], apps[app]["Degree"], apps[app]["Curriculum"]) for app in apps - if "curriculum" in apps[app] + if "Curriculum" in apps[app] ] else: program_list = [ @@ -362,7 +369,14 @@ def main_sync(pid=None): ] yt_list = [apps[app]["YearTerm"] for app in apps if "YearTerm" in apps[app]] - if ps_powercampus.autoconfigure_mappings(program_list, yt_list, vd, mdy, mfl): + if ps_powercampus.autoconfigure_mappings( + program_list, + yt_list, + vd, + mdy, + mfl, + afsi, + ): RM_MAPPING = ps_powercampus.get_recruiter_mapping(mfl) verbose_print("Check each app's status flags/PCID in PowerCampus") @@ -390,20 +404,28 @@ def main_sync(pid=None): or (v["status_ra"] in (1, 2) and v["status_app"] is None) or (v["status_ra"] == 0 and v["status_app"] == None) # 9.2.3 new bad status ): - app = format_app_api(v, SETTINGS.defaults) - pcid = ps_powercampus.post_api(app, SETTINGS.powercampus.api, MSG_STRINGS) - apps[k]["PEOPLE_CODE_ID"] = pcid - - # Rescan status - status_ra, status_app, status_calc, pcid = ps_powercampus.scan_status(v) - apps[k].update( - { - "status_ra": status_ra, - "status_app": status_app, - "status_calc": status_calc, - } + app, error_flag, error_message = format_app_api( + v, Settings.defaults, Settings.Messages ) - apps[k]["PEOPLE_CODE_ID"] = pcid + if error_flag: + apps[k]["error_flag"] = error_flag + apps[k]["error_message"] = error_message + else: + pcid = ps_powercampus.post_api( + app, Settings.PowerCampus.api, Settings.Messages + ) + apps[k]["PEOPLE_CODE_ID"] = pcid + + # Rescan status + status_ra, status_app, status_calc, pcid = ps_powercampus.scan_status(v) + apps[k].update( + { + "status_ra": status_ra, + "status_app": status_app, + "status_calc": status_calc, + } + ) + apps[k]["PEOPLE_CODE_ID"] = pcid verbose_print("Get scheduled actions from Slate") if CONFIG["scheduled_actions"]["enabled"] == True: @@ -428,14 +450,14 @@ def main_sync(pid=None): CURRENT_RECORD = k if v["status_calc"] == "Active": # Transform to PowerCampus format - app_pc = format_app_sql(v, RM_MAPPING, SETTINGS.powercampus) + app_pc = format_app_sql(v, RM_MAPPING, Settings.PowerCampus) pcid = app_pc["PEOPLE_CODE_ID"] academic_year = app_pc["ACADEMIC_YEAR"] academic_term = app_pc["ACADEMIC_TERM"] academic_session = app_pc["ACADEMIC_SESSION"] # Single-row updates - if SETTINGS.powercampus.update_academic_key: + if Settings.PowerCampus.update_academic_key: ps_powercampus.update_academic_key(app_pc) ps_powercampus.update_demographics(app_pc) ps_powercampus.update_academic(app_pc) @@ -476,7 +498,7 @@ def main_sync(pid=None): ps_powercampus.update_test_scores(pcid, test) # Update any PowerCampus Notes defined in config - for note in SETTINGS.powercampus.notes: + for note in Settings.PowerCampus.notes: if ( note["slate_field"] in app_pc and len(app_pc[note["slate_field"]]) > 0 @@ -486,7 +508,7 @@ def main_sync(pid=None): ) # Update any PowerCampus User Defined fields defined in config - for udf in SETTINGS.powercampus.user_defined_fields: + for udf in Settings.PowerCampus.user_defined_fields: if udf["slate_field"] in app_pc and len(app_pc[udf["slate_field"]]) > 0: ps_powercampus.update_udf( app_pc, udf["slate_field"], udf["pc_field"] @@ -505,7 +527,7 @@ def main_sync(pid=None): ps_powercampus.update_scholarship( pcid, scholarship, - SETTINGS.powercampus.validate_scholarship_levels, + Settings.PowerCampus.validate_scholarship_levels, ) # Update PowerCampus Associations @@ -532,7 +554,7 @@ def main_sync(pid=None): custom_4, custom_5, ) = ps_powercampus.get_profile( - app_pc, SETTINGS.powercampus.campus_emailtype + app_pc, Settings.PowerCampus.campus_emailtype, Settings.Messages ) apps[k].update( { @@ -557,7 +579,7 @@ def main_sync(pid=None): sync_errors == True # Get PowerFAIDS awards and tracking status - if SETTINGS.fa_awards.enabled: + if Settings.fa_awards.enabled: fa_awards, fa_status = ps_powercampus.pf_get_awards( pcid, v["GovernmentId"], @@ -591,7 +613,7 @@ def main_sync(pid=None): CURRENT_RECORD = k if v["status_calc"] == "Active": # Transform to PowerCampus format - app_pc = format_app_sql(v, RM_MAPPING, SETTINGS.powercampus) + app_pc = format_app_sql(v, RM_MAPPING, Settings.PowerCampus) fa_checklists = ps_powercampus.pf_get_fachecklist( app_pc["PEOPLE_CODE_ID"], diff --git a/ps_format.py b/ps_format.py index 0ecb86c..f4c351f 100644 --- a/ps_format.py +++ b/ps_format.py @@ -145,7 +145,7 @@ def format_str_digits(s): return s.translate(non_digits) -def format_app_generic(app, cfg_fields): +def format_app_generic(app, cfg_fields, Messages): """Supply missing fields and correct datatypes. Returns a flat dict.""" mapped = format_blank_to_null(app) @@ -183,7 +183,7 @@ def format_app_generic(app, cfg_fields): return mapped -def format_app_api(app, cfg_defaults): +def format_app_api(app, cfg_defaults, Messages): """Remap application to Recruiter/Web API format. Keyword arguments: @@ -192,6 +192,11 @@ def format_app_api(app, cfg_defaults): mapped = {} + # Error checks + if "YearTerm" not in app: + error_message = Messages.error.missing_yt + error_flag = True + # Pass through fields fields_verbatim = [ k for (k, v) in ps_models.fields.items() if v["api_verbatim"] == True @@ -284,12 +289,12 @@ def format_app_api(app, cfg_defaults): # Academic program # API 9.2.3 seems to accept either three or two fields - if "curriculum" in app: + if "Curriculum" in app: mapped["Programs"] = [ { - "program": app["program"], - "degree": app["degree"], - "curriculum": app["curriculum"], + "Program": app["Program"], + "Degree": app["Degree"], + "Curriculum": app["Curriculum"], } ] else: @@ -301,7 +306,7 @@ def format_app_api(app, cfg_defaults): mapped["ApplicationNumber"] = app["aid"] mapped["ProspectId"] = app["pid"] - return mapped + return mapped, error_flag, error_message def format_app_sql(app, mapping, config): diff --git a/ps_powercampus.py b/ps_powercampus.py index 4120ba6..de4a612 100644 --- a/ps_powercampus.py +++ b/ps_powercampus.py @@ -5,16 +5,14 @@ import ps_models -def init(config, verbose, msg_strings): +def init(config, verbose): global CNXN global CURSOR global CONFIG global VERBOSE - global MSG_STRINGS CONFIG = config VERBOSE = verbose - MSG_STRINGS = msg_strings # Microsoft SQL Server connection. CNXN = pyodbc.connect(config.database_string) @@ -50,7 +48,12 @@ def verbose_print(x): def autoconfigure_mappings( - program_list, yt_list, validate_degreq, minimum_degreq_year, mapping_file_location + program_list, + yt_list, + validate_degreq, + minimum_degreq_year, + mapping_file_location, + app_form_setting_id, ): """ Automatically insert new Program/Degree/Curriculum combinations into ProgramOfStudy and recruiterMapping.xml @@ -110,11 +113,12 @@ def autoconfigure_mappings( # Update ProgramOfStudy table; optionally validate against DEGREQ table for pdc in pdc_set: CURSOR.execute( - "execute [custom].[PS_updProgramOfStudy] ?, ?, ?, ?", + "execute [custom].[PS_updProgramOfStudy] ?, ?, ?, ?, ?", pdc[0], pdc[1], pdc[2], minimum_degreq_year, + app_form_setting_id, ) CNXN.commit() @@ -254,7 +258,7 @@ def get_recruiter_mapping(mapping_file_location): return rm_mapping -def post_api(app, config, msg_strings): +def post_api(app, config, Messages): """Post an application to PowerCampus. Return PEOPLE_CODE_ID if application was automatically accepted or None for all other conditions. @@ -297,13 +301,13 @@ def post_api(app, config, msg_strings): "BadRequest Object reference not set to an instance of an object." in rtext and "ApplicationsController.cs:line 183" in rtext ): - raise ValueError(msg_strings["error_no_phones"], rtext, e) + raise ValueError(Messages.error.no_phones, rtext, e) elif ( "BadRequest Activation error occured while trying to get instance of type Database, key" in rtext and "ServiceLocatorImplBase.cs:line 53" in rtext ): - raise ValueError(msg_strings["error_api_missing_database"], rtext, e) + raise ValueError(Messages.error.api_missing_database, rtext, e) elif ( r.status_code == 202 and "was created successfully in PowerCampus" not in r.text @@ -396,7 +400,7 @@ def scan_status(x): return ra_status, apl_status, computed_status, pcid -def get_profile(app, campus_email_type): +def get_profile(app, campus_email_type, Messages): """Fetch ACADEMIC row data and email address from PowerCampus. Returns: @@ -410,7 +414,7 @@ def get_profile(app, campus_email_type): """ error_flag = True - error_message = MSG_STRINGS.error_academic_row_not_found + error_message = Messages.error.academic_row_not_found registered = False reg_date = None readmit = False @@ -464,7 +468,7 @@ def get_profile(app, campus_email_type): if college_attend not in CONFIG.valid_college_attend: error_flag = True - error_message = MSG_STRINGS.error_invalid_college_attend.format( + error_message = Settings.Messages.error.invalid_college_attend.format( college_attend ) From 34c7adb14c4d64931f6b314a71432889c0c3c477 Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:15:50 -0600 Subject: [PATCH 04/30] Improve final success message --- config_messages.json | 2 +- ps_core.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/config_messages.json b/config_messages.json index e7b1d00..318af80 100644 --- a/config_messages.json +++ b/config_messages.json @@ -10,6 +10,6 @@ }, "success": { "done": "Sync completed with no errors.", - "done_not_found": "Sync completed, but one or more applications had integration errors." + "done_with_errors": "Sync completed, but one or more applications had integration errors." } } diff --git a/ps_core.py b/ps_core.py index f06cfd5..65dbe62 100644 --- a/ps_core.py +++ b/ps_core.py @@ -50,7 +50,6 @@ def init(config_path): global CONFIG global CONFIG_PATH global RM_MAPPING - global MSG_STRINGS global Settings # New global for Settings class CONFIG_PATH = config_path @@ -61,7 +60,6 @@ def init(config_path): RM_MAPPING = ps_powercampus.get_recruiter_mapping( Settings.PowerCampus.mapping_file_location ) - MSG_STRINGS = CONFIG["msg_strings"] # Init PowerCampus API and SQL connections ps_powercampus.init(Settings.PowerCampus, Settings.console_verbose) @@ -629,10 +627,10 @@ def main_sync(pid=None): slate_post_fa_checklist(slate_upload_list) # Warn if any apps returned an error flag from ps_powercampus.get_profile() - if sync_errors == True: - output_msg = MSG_STRINGS["sync_done_not_found"] + if sync_errors == True or [k for k in apps if apps[k]["error_flag"] == True]: + output_msg = Settings.Messages.success.done_with_errors else: - output_msg = MSG_STRINGS["sync_done"] + output_msg = Settings.Messages.success.done verbose_print(output_msg) return output_msg From 6b9de21f32ea4d2b4fef99dc2bdee7ba7ffd7e8b Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:34:23 -0600 Subject: [PATCH 05/30] API compatibility tweaks -Transform PDC to PD for API -Fix uninitialized variables -Fix incorrect handling of good 202 status --- ps_format.py | 8 +++++--- ps_powercampus.py | 8 +++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ps_format.py b/ps_format.py index f4c351f..15a95ea 100644 --- a/ps_format.py +++ b/ps_format.py @@ -191,6 +191,8 @@ def format_app_api(app, cfg_defaults, Messages): """ mapped = {} + error_flag = False + error_message = None # Error checks if "YearTerm" not in app: @@ -288,13 +290,13 @@ def format_app_api(app, cfg_defaults, Messages): mapped["VeteranStatus"] = True # Academic program - # API 9.2.3 seems to accept either three or two fields + # API 9.2.3 still requires two fields for Program and Degree, even though the Swagger schema contains three fields. if "Curriculum" in app: mapped["Programs"] = [ { "Program": app["Program"], - "Degree": app["Degree"], - "Curriculum": app["Curriculum"], + "Degree": app["Degree"] + "/" + app["Curriculum"], + "Curriculum": None, } ] else: diff --git a/ps_powercampus.py b/ps_powercampus.py index de4a612..993c704 100644 --- a/ps_powercampus.py +++ b/ps_powercampus.py @@ -310,10 +310,10 @@ def post_api(app, config, Messages): raise ValueError(Messages.error.api_missing_database, rtext, e) elif ( r.status_code == 202 - and "was created successfully in PowerCampus" not in r.text + and "was created successfully in PowerCampus" in r.text == False ) or r.status_code == 400: raise ValueError(rtext) - elif "was created successfully in PowerCampus" not in r.text: + elif "was created successfully in PowerCampus" not in r.text == False: raise requests.HTTPError(rtext) if dup_found: @@ -468,9 +468,7 @@ def get_profile(app, campus_email_type, Messages): if college_attend not in CONFIG.valid_college_attend: error_flag = True - error_message = Settings.Messages.error.invalid_college_attend.format( - college_attend - ) + error_message = Messages.error.invalid_college_attend.format(college_attend) if row.Withdrawn == "Y": withdrawn = True From 3b6a34fbfdf2e22aa88a7a427d4c485330f6600c Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:39:38 -0600 Subject: [PATCH 06/30] Remove SQL logging feature Slow and rarely used. API 9.2.3 doesn't write most (any?) errors to RecruiterApplication table anymore, so this would need rewritten. --- .../Create Table PowerSlate_AppStatus_Log.sql | 33 ------------------- Tools/GRANT EXEC.sql | 3 -- Tools/Prune log table.sql | 20 ----------- Tools/Select Most Recent Status.sql | 28 ---------------- config_sample.json | 4 --- ps_powercampus.py | 25 -------------- 6 files changed, 113 deletions(-) delete mode 100644 Tools/Create Table PowerSlate_AppStatus_Log.sql delete mode 100644 Tools/Prune log table.sql delete mode 100644 Tools/Select Most Recent Status.sql diff --git a/Tools/Create Table PowerSlate_AppStatus_Log.sql b/Tools/Create Table PowerSlate_AppStatus_Log.sql deleted file mode 100644 index 10c3aec..0000000 --- a/Tools/Create Table PowerSlate_AppStatus_Log.sql +++ /dev/null @@ -1,33 +0,0 @@ -USE [PowerCampusMapper] -GO - -/****** Object: Table [dbo].[PowerSlate_AppStatus_Log_test] Script Date: 2/18/2021 3:50:29 PM ******/ -SET ANSI_NULLS ON -GO - -SET QUOTED_IDENTIFIER ON -GO - -CREATE TABLE [dbo].[PowerSlate_AppStatus_Log]( - [ApplicationNumber] [uniqueidentifier] NULL, - [ProspectId] [uniqueidentifier] NULL, - [FirstName] [nvarchar](50) NULL, - [LastName] [nvarchar](50) NULL, - [ComputedStatus] [nvarchar](50) NULL, - [Notes] [nvarchar](max) NULL, - [RecruiterApplicationStatus] [int] NULL, - [ApplicationStatus] [int] NULL, - [PEOPLE_CODE_ID] [nvarchar](10) NULL, - [UpdateTime] [datetime2](7) NULL, - [ID] [int] IDENTITY(1,1) NOT NULL, - [Ref] [nvarchar](16) NULL, -PRIMARY KEY CLUSTERED -( - [ID] ASC -)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] -) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] -GO - -ALTER TABLE [dbo].[PowerSlate_AppStatus_Log] ADD CONSTRAINT [PowerSlate_AppStatus_Log_UpdateTime] DEFAULT (getdate()) FOR [UpdateTime] -GO - diff --git a/Tools/GRANT EXEC.sql b/Tools/GRANT EXEC.sql index 54472ef..4903c0e 100644 --- a/Tools/GRANT EXEC.sql +++ b/Tools/GRANT EXEC.sql @@ -27,6 +27,3 @@ GRANT EXEC ON [custom].[PS_selPFAwardsXML] to [$(service_user)] GRANT EXEC ON [custom].[PS_selAcademicCalendar] to [$(service_user)] GRANT EXEC ON [custom].[PS_updScholarships] to [$(service_user)] GRANT EXEC ON [custom].[PS_updAssociation] to [$(service_user)] - -USE [PowerCampusMapper] -GRANT INSERT ON PowerSlate_AppStatus_Log TO [$(service_user)] diff --git a/Tools/Prune log table.sql b/Tools/Prune log table.sql deleted file mode 100644 index 49147f6..0000000 --- a/Tools/Prune log table.sql +++ /dev/null @@ -1,20 +0,0 @@ -USE powercampusmapper; - -WITH RankedIDs_CTE -AS ( - SELECT ID - ,RANK() OVER ( - PARTITION BY ApplicationNumber ORDER BY ID DESC - ) [RankDesc] - ,NULL AS [RankAsc] - ,ApplicationNumber - FROM [PowerSlate_AppStatus_Log_test] - ) -DELETE TOP (9000) -FROM RankedIDs_CTE -WHERE [RankDesc] >= 6 - AND ID <> ( - SELECT MIN(ID) - FROM [PowerSlate_AppStatus_Log_test] L2 - WHERE L2.ApplicationNumber = RankedIDs_CTE.ApplicationNumber - ); diff --git a/Tools/Select Most Recent Status.sql b/Tools/Select Most Recent Status.sql deleted file mode 100644 index 3f1765a..0000000 --- a/Tools/Select Most Recent Status.sql +++ /dev/null @@ -1,28 +0,0 @@ -USE [your_supplementary_database] - -SELECT Ref - ,ApplicationNumber - ,ProspectId - ,FirstName - ,LastName - ,ComputedStatus - ,Notes - ,RecruiterApplicationStatus - ,ApplicationStatus - ,PEOPLE_CODE_ID - ,UpdateTime -FROM - (SELECT Ref - ,ApplicationNumber - ,ProspectId - ,FirstName - ,LastName - ,ComputedStatus - ,Notes - ,RecruiterApplicationStatus - ,ApplicationStatus - ,PEOPLE_CODE_ID - ,UpdateTime - ,RANK() OVER (PARTITION BY ApplicationNumber ORDER BY ID DESC) N - FROM SlaPowInt_AppStatus_Log) E -WHERE N = 1 diff --git a/config_sample.json b/config_sample.json index 0a84833..24b5331 100644 --- a/config_sample.json +++ b/config_sample.json @@ -20,10 +20,6 @@ "note_type": "GENRL" } ], - "logging": { - "enabled": true, - "log_table": "[SomeDatabase].[dbo].[PowerSlate_AppStatus_Log]" - }, "user_defined_fields": [ { "slate_field": "FirstGeneration", diff --git a/ps_powercampus.py b/ps_powercampus.py index 993c704..ec03732 100644 --- a/ps_powercampus.py +++ b/ps_powercampus.py @@ -372,31 +372,6 @@ def scan_status(x): else: computed_status = "Unrecognized Status: " + str(row.ra_status) - if CONFIG.logging.enabled: - # Write errors to external database for end-user presentation via SSRS. - CURSOR.execute( - "INSERT INTO" - + CONFIG.logging.log_table - + """ - ([Ref],[ApplicationNumber],[ProspectId],[FirstName],[LastName], - [ComputedStatus],[Notes],[RecruiterApplicationStatus],[ApplicationStatus],[PEOPLE_CODE_ID]) - VALUES - (?,?,?,?,?,?,?,?,?,?)""", - [ - x["Ref"], - x["aid"], - x["pid"], - x["FirstName"], - x["LastName"], - computed_status, - row.ra_errormessage, - row.ra_status, - row.apl_status, - pcid, - ], - ) - CNXN.commit() - return ra_status, apl_status, computed_status, pcid From 4d3757c68a36cf30534c70796e2f5890daf04dc3 Mon Sep 17 00:00:00 2001 From: WyattBest <32881391+WyattBest@users.noreply.github.com> Date: Fri, 29 Mar 2024 12:08:57 -0600 Subject: [PATCH 07/30] Utilize new ACADEMIC Guid First draft; may contain bugs! --- SQL/[custom].[PS_selProfile].sql | 236 +++++++++++++++++---------- SQL/[custom].[PS_updAcademicKey].sql | 126 +++++--------- config_sample.json | 3 +- ps_core.py | 7 +- ps_models.py | 6 + ps_powercampus.py | 82 ++++++---- 6 files changed, 250 insertions(+), 210 deletions(-) diff --git a/SQL/[custom].[PS_selProfile].sql b/SQL/[custom].[PS_selProfile].sql index 3d3d190..7497323 100644 --- a/SQL/[custom].[PS_selProfile].sql +++ b/SQL/[custom].[PS_selProfile].sql @@ -26,6 +26,7 @@ GO -- 2021-12-01 Wyatt Best: Renamed MoodleOrientationComplete to custom_1 and added 4 more custom fields. -- 2022-02-16 Wyatt Best: Added @EmailType parameter. -- 2022-09-27 Wyatt Best: Return AD username (SSO ID) to Slate from PersonUser. +-- 2024-03-29 Wyatt Best: Return Guid column (new in 9.2.3) or error message about changed YTSPDC. -- ============================================= CREATE PROCEDURE [custom].[PS_selProfile] @PCID NVARCHAR(10) ,@Year NVARCHAR(4) @@ -35,10 +36,16 @@ CREATE PROCEDURE [custom].[PS_selProfile] @PCID NVARCHAR(10) ,@Degree NVARCHAR(6) ,@Curriculum NVARCHAR(6) ,@EmailType NVARCHAR(10) + ,@AcademicGuid UNIQUEIDENTIFIER AS BEGIN SET NOCOUNT ON; + --Writing a simple method for handling non-fatal errors because differentiating real + --errors in Python is hard and varies based on the ODBC driver used. + DECLARE @ErrorFlag BIT = 0 + ,@ErrorMessage NVARCHAR(max) + --Error check IF ( @EmailType IS NOT NULL @@ -59,10 +66,10 @@ BEGIN RETURN END - --Select credits from rollup to avoid duplicate hits to table + --Select credits from rollup DECLARE @Credits NUMERIC(6, 2) = ( SELECT CREDITS - FROM ACADEMIC A + FROM ACADEMIC WHERE PEOPLE_CODE_ID = @PCID AND ACADEMIC_YEAR = @Year AND ACADEMIC_TERM = @Term @@ -72,87 +79,146 @@ BEGIN AND CURRICULUM = @Curriculum ) - --If someone has multiple apps for one YTS with different PDC's but the same transcript sequence, you will not be able to - --separate the credits because TRANSCRIPTDETAIL doesn't have PDC. Custom logic is required to sort out things like-zero credit certificate - --dual enrollment with a for-credit program. - SELECT CASE - WHEN @Credits > 0 - THEN 'Y' - WHEN PROGRAM = 'CERT' - AND EXISTS ( - SELECT TD.PEOPLE_ID - FROM TRANSCRIPTDETAIL TD - INNER JOIN ACADEMIC A - ON A.PEOPLE_CODE_ID = TD.PEOPLE_CODE_ID - AND A.ACADEMIC_YEAR = TD.ACADEMIC_YEAR - AND A.ACADEMIC_TERM = TD.ACADEMIC_TERM - AND A.ACADEMIC_SESSION = TD.ACADEMIC_SESSION - AND A.PROGRAM = @Program - AND A.DEGREE = @Degree - AND A.CURRICULUM = @Curriculum - AND A.TRANSCRIPT_SEQ = TD.TRANSCRIPT_SEQ - --AND A.ACADEMIC_FLAG = 'Y' --Can mask some issues of registrations w/out acceptance, but needed for someone who applies for CERT and UNDER and only registers for undergrad. - AND A.APPLICATION_FLAG = 'Y' - WHERE TD.PEOPLE_CODE_ID = @PCID - AND TD.ACADEMIC_YEAR = @Year - AND TD.ACADEMIC_TERM = @Term - AND TD.ACADEMIC_SESSION = @Session - AND TD.ADD_DROP_WAIT = 'A' - ) - THEN 'Y' - ELSE 'N' - END AS 'Registered' - ,CAST(COALESCE(PREREG_VAL_DATE, REG_VAL_DATE) AS DATE) [REG_VAL_DATE] - ,cast(@Credits AS VARCHAR(6)) AS CREDITS - ,A.COLLEGE_ATTEND - ,( - SELECT REQUIRE_SEPDATE - FROM CODE_ENROLLMENT - WHERE CODE_VALUE_KEY = A.ENROLL_SEPARATION - ) AS Withdrawn - ,oE.Email AS CampusEmail - ,( - SELECT NonQualifiedUserName - FROM PersonUser - WHERE PersonId = dbo.fnGetPersonId(A.ADVISOR) - ) [AdvisorUsername] - ,( - SELECT NonQualifiedUserName - FROM PersonUser - WHERE PersonId = dbo.fnGetPersonId(A.PEOPLE_CODE_ID) - ) [Username] - ,CASE - WHEN EXISTS ( - SELECT * - FROM TESTSCORES T - WHERE TEST_ID = 'MOOD' - AND TEST_TYPE = 'STU' - AND ALPHA_SCORE_1 = 'P' - AND T.PEOPLE_CODE_ID = A.PEOPLE_CODE_ID - ) - THEN 'Y' - ELSE 'N' - END [custom_1] - ,NULL [custom_2] - ,NULL [custom_3] - ,NULL [custom_4] - ,NULL [custom_5] - FROM ACADEMIC A - OUTER APPLY ( - SELECT TOP 1 Email - FROM EmailAddress E - WHERE E.PeopleOrgCodeId = A.PEOPLE_CODE_ID - AND E.EmailType = @EmailType - AND E.IsActive = 1 - ORDER BY E.REVISION_DATE DESC - ,REVISION_TIME DESC - ) oE - WHERE PEOPLE_CODE_ID = @PCID - AND ACADEMIC_YEAR = @Year - AND ACADEMIC_TERM = @Term - AND ACADEMIC_SESSION = @Session - AND PROGRAM = @Program - AND DEGREE = @Degree - AND CURRICULUM = @Curriculum - AND APPLICATION_FLAG = 'Y' --Ought to be an application, or there's a problem somewhere. + --Search for @AcademicGuid if NULL + IF @AcademicGuid IS NULL + SELECT @AcademicGuid = [Guid] + FROM ACADEMIC + WHERE PEOPLE_CODE_ID = @PCID + AND ACADEMIC_YEAR = @Year + AND ACADEMIC_TERM = @Term + AND ACADEMIC_SESSION = @Session + AND PROGRAM = @Program + AND DEGREE = @Degree + AND CURRICULUM = @Curriculum + ELSE + BEGIN + --Verify that YTSPDC match existing @AcademicGuid + DECLARE @TempYear NVARCHAR(4) + ,@TempTerm NVARCHAR(10) + ,@TempSession NVARCHAR(10) + ,@TempProgram NVARCHAR(6) + ,@TempDegree NVARCHAR(6) + ,@TempCurriculum NVARCHAR(6) + + SELECT @TempYear = ACADEMIC_YEAR + ,@TempTerm = ACADEMIC_TERM + ,@TempSession = ACADEMIC_SESSION + ,@TempProgram = PROGRAM + ,@TempDegree = DEGREE + ,@TempCurriculum = CURRICULUM + FROM [ACADEMIC] + WHERE [Guid] = @AcademicGuid + + IF @TempYear <> @Year + OR @TempTerm <> @Term + OR @TempSession <> @Session + OR @TempProgram <> @Program + OR @TempDegree <> @Degree + OR @TempCurriculum <> @Curriculum + SELECT @ErrorFlag = 1 + ,@ErrorMessage = 'The Application in PowerCampus has a different YTS + PDC than the Slate application.sei*QxAGQWZ?p z93x|R!adv4)Ni_rb^$Mwo|p~D>7v}H89*xq7^z13N>LEHcC+D>$Ng=cMhU7FxLg}) z43;bFS34S+T+iNpdtx(!vWHogT>TGt^u5RDPr*Bz3k(&%-B}lK<*@qNEI)(WKr9M9 zdLMh~<4D~5{z}=m4PR`ry64MG8HFcOTjopkK@tHWhHMNnh0v%l*7Co7u~S>7WnTz0 z!sS$(yf0pN`i|(3OF@Z(SdwCH-S=y`E{Jus*yK|odB>jS=o%@``YV^BxnplAT9g9k7h(Sut)1FxD}MKdch=^dvTUEa1)E<^R6I~Jm<0$UUOPso3F^E!TY zXjmjG_ooC37%;F|3@|X%_s}IA9jMn_wO{2z@i8!b>-l}nlSCaC|GhA{aus9TO{r+f zLOa)vB%WH)72mz)ty5Ibm?{-ZDzbBF4u2wb%h6oyc)abYH2oL+JXURI`U>`fE9tT9 zzIaDRx^Ih=#jER#wf;eSLPK*pp*XM4{oVb>I(|~c6mDSydUo-uS)!i2JD|-u_Rv1= zmb-lxY%7MiDN!0>r(3FmI zrI>!tT*cJ*4<7rR%;_C%&Ck2U;;$A?m16J$!>wzWa=f)_f}=2-_T(*3>!jzU>&e z71;Fm#A>hEmuUA U%fZc8sNGNC zWBo5DT+jX{NNERr;;%e}cik%K5H@v0F8fi$8(k3rTzN$Mlf_w14J{MyRe5;#miDeK z`@HJ{rujw~RXW5z*}UpS9!+~PpQ}R!uxtj@3^qg`)t0ZUbz}w>DS&23D=vT8G}rZ1 zgC&&fMZ^IgDo!V6$gl~WLmq>Ia>`IMGF6xYScXXL*%ol(tv&OC7MNVK)v(%AFHLm> zBsb)-ifv5SETx&FUE{v{g3hgz#3V%7)}3m;n2!m|(R7(sIqHa$ehl#q>b3X>dsj>) zWQV2*2o;M 6o75R8+B?W?NB+(TU`6< #B%kiwLE|T%%bM%VHAv3mmoG?LpgTX(E|85oV164b-`ZP@l37 zhNrEU3Ld}tVa4p|fdJJGZVpmR-3y~t-?$n^)|pz%$`1wktw5>E@VtR}`I2oA1SItC zD|^06&TdZRwRew;N5dOw+mre;Bq&FPi&K%ZDQ8Yw{UT-@$@s;EmwW$;#%IefzG-KZ zRduvam5x;3khh} 8VXAX1)oG1Z8Pj3giB|4`DjWmlQ>)wizFhy z(GD>Vh-jXG0B*Y*`OFca|3H!0;}1}8YYUig8S`=+{~%~}N;ih9p>CLJMjeys$`LI| z-sH&~Bh?*p*Ni&nxa5~`YJ`@ Q!x2{_ z$R>J+meGzkPn^OfA=%t@hYA^^nMJ+?kqSutj&k=|1i~E~g9cDXB^bUav6WGq(THnE z^A90T;}>+EZDYz}V^LT#OyPV6uaJPrj`RZS$ziG7QHrNmG~KEJ8>eOBfThEbsvs6w zex7Qe#)TBDMQc}~w~b$ck3hj4XqMmP@gRgk(qo=G0pp++IfIJgM#XI+Cdar?BXO1C zTQHLT1%Nf6qlRi8PWU`^7)B6QZNhowepfevx`>B92RG@A9AvoaVP{)VGKCdrouMY- z=<|`03cH_P%4fozPFWU?@FEB$H?y=G$`Ms&L*E&OzvTapZpqi|5PnctobjHA3srYS z`#dEQhodW+2kN!yku-^gaWl^Gp+YP{&r3|C1g7G7OxHyn?~0T5BOE5?Uxx7wsnn`a zFRd#O1X?(V!1v7uwbw$#`L}Jro5-A^%kNH5)5pECD?GFc9TRZ<8RzCdGp$f!{jk^7 zcWL0iL}JCD4PG4&g=KWnK|5BUxR;9|#Bg{FJmMN~TNAD;z8%WN-8=K`z-_Q@p3D$_ z0(jnBi(0uddR;WFxemX=KF754GP}8m^FP%d2WBK%n;`{mr*X;wg}M%BV^&(W?kgNa zLWm078cl0`UNSIIt~f_E=l&MvX3X9=>87R$8RISzsV?y?c|Z9_4yp5~KH-`D9CmYz zRCF9@58gTBV!QfIFXM^w@Pz&kxXy5h z>tA{=kwv;%asOm|P&&WOYk^?}{TTYVAK2}foh7JzuKXO%v~Qk{85><@(jcJc!Q?eG zmK!`K1nF+VFdz0ZZPeKI)65Mz5h_ohBy4A<`-Im|u8p_h{QN`pMb-f2ZpARHEuCsX z$8nc%Y>+Po532`j5G%I6ZXL7^y?M=#eDXVND}2v4L~$P9@86#OCw8jyFfo9DS4*7a zqk{qia|CUg5dk kTI#QRFn!X7}bEuASU4 zxgkkN A-I`5jzb832tM}mI_>l+d=8k7>-G)SU zLy6hNI)E!X9D3W$Rddop@swdvS3{JPYv;!i90}?(){{Nh%`8Q;*@8%Um2G(A?qDpS zbFCeFxcmWSpFJ5NVt_W14w^Ch=K=S`o;u4~^ 5e)eAq2@4aL_ zkqukvd4koEav`o 4P?)w3SvUtZ#i8Yr1lHA+fMt*%-oxMmKbA05!*Nb4X`(Okg zY^D~wmuSKBP$HD5T8wtKoTyTtEZZa}=o7|wD>l?y?;fSV)p*E`$UUpV$6+n4Q>zE7 z`p_5PvUMR*9(iFRJrp*Ciy?LL6a8F?O#B}ssZXIU<)4++@u-_V{zN$JvYtH}{3eDs zTeYYSd>Qi>Xh_FXEADBD#izVdk%E!jAe^(;fX*@=#4n+ZIhiOeY}5PFf58=?;1yA* zn3IK7mE8)V%Ng+vz~B~&iA5WG=ByX_ &|GdJW0iujsDuKF;Mc^RU&P|Gn^$J@lt6`_;zZM>5c8B zi}EXzMC97dHge^U^y9_3H5H4~AF`jnBo`7BhE!&LNE`XK_!B2lB$6&(_pEPH=d;C9 z5U@_W7R}>a+D)S^RPYnYoVuI*P8YtapnvM7_(@hTf-c`8z5L@zStK@;TW`As#Uv`x zH<1&_ubH=$x%@R**1j?g( KUWDGXCvVwc`6g#@?j!rftns%nz5<2;1HZFjV&rt>E zG1V^x^&JU~Zb)tK5d99xGYb<={{joO6=ju~nLuUrH$wcPf`^7BR~U3k&am7ZyiX0$ zr5j}Pep~g~t@W0oq5yu>8u2%9?MUNnHJ!u-&Nt8`Bqlu7ua&(@ sCql tuAveNI}{RxQ~#?R_YmQxgGniDCx98#oXy48SDT#r*(6nK~$b-#!5g6M19 zHVi-)d1d*%FOi2Qu9lq2CcZ+Bbb$SLd2ffUDd5oSenEtm8E;1ar8fY=2+AK~>`mNY zRPdO}NBzkReHVGUIxE;+oUgr*t~T;o*1piaWPMKOcru;J6N|5dOlx?lqqvaTrcbP< zDOqX|W*d$|%Qux}#>0^TU?YUD`?cgVKnieh#Zj1V4D>jVcj811?PaEx3)>mU#)mo+ z$?5y(rLeQOF+krN3(aUm%FuY^;;BEgr?v6_QigVr8dl{tO;kbUl9 y3+Ivk$odg~t=!=}Bv-8~?bNa5z|10;XWi~xk( zSUk!`F?g|E`|Pzaru(^AwCciMI}1k^qa>bQx+c_^XLv >nYQA7ZZm65Jn-PmOfJVYf76+yff+A0vpTf6rtsO zRfUTLOS9{U)TVAEF ur_>6Db~;z!9OTG9hh{SGO75AgyplIX1|bX(jMLR0iZxaxem4& z$igsNFXD5#58*2U_9UZHYeUz%``+LN&~$gzGFA(1+iVXKCY=S~;iNs+aI#jNG7S9N z# 8%p_TWWQ#vPo~h3f~1m`#xa`Z82Tgzw4W-h}%q zT&nf$KG>w&3p#lCN;fB2fWJoIfrVw@I17(j_H9nc=+OcOVOEfZ*>%4xl5Jc?!78l? zX_iglMSZEL%J=V!LaWS2x!K42RsJy(% xJ>PQGnUOsQj#c)Nl+i@%uyau#&p^@Nakk!^ zhm4f;mw8P;H1K-2yEi^=-@WdHZe}`|rP1&XBL9v!;NI{sIZo{EdXY~C?p1nZ;-ILQ zGPS*rtUSN;nE&h}R*Ci44JR*5r8m+aR2+Ds^3igRm+-Z8r(rm!87-mO1_Epw2lD#@ z9m1RckZ;F?F2`=aNGMY-27lw8@4!x}u}wKk0;Z~g;-`K{COXk9_ZDqHRYi~D40#bq zQ|!CVBBi#A*b-i;3LRW rOPgczE>`c?S`qtD0Torlp?H^fVKb>@= z9ag}~WH&MHUfP391$4>*4??_hkIvPTX;X~JzWDU*7c#RrPBZEhCb@TEe-{Gk6b A0 zL7~T5Zpb5tY$@l~<*S5zrp#DQ79V0ltS=u{^hPzVjL?Bh3{EF+p;7kZr5)3@?g! -fDoeL{6HDSVf6D?2;EP3n=y4x6F=IS_tFnIkqRw$c-SdTxfYt$a8cd%)yV z=)A`KVg2cYKY#hPVNIp_liW{N57k=PB^%?R54OkGRu6pd$r(x9nzh1m>TfB=JV%V> z^sSWNymSE%K~S+&=GTNEGx->qbBhu7k?ZLA>Ytnh6p2|2 q zj`ws }!iNt5jaC0o9W&|b>_(!Wdigk a X(Z;#jM)KeS-{c>(^^GUS<+2=diDp z?9theqG@f+EWQF*TM^eo*_S@O(hNT}c5 zZKWimTJtD-h1OgNMrw%gnrfmD>ht`trgVdP@6F}bVa_V->A(Kn6}y>l?dPPyF2-Gk zoBK-@vDUP{lmxuY`b&2yE?T-OA~SIQZplJTRP{&cJgv0cJT)TTcC%CpNf=%%qOh(b z2NXdqh3E>4&S$vjWwbf95*OV@Q`&L)!%}|UoHE=qs04uuoOa_g>iUqg_X+RR7Eu20 zS{QX|OL3DK8vcQ0weMp|X{!H%|G%Q0TA)Dv@4Nm2MO&%UvNGsDz#C~;{e8{vt!3{J zuQ%UpTO##eFj8u*+c@_Rm};;y>R2oP7yO@D_+XI!?4!%Z!i(Sc@46oV*5Yk5K(9 gt0WJ z$3R1`rqb$8-7?>z)Zvm_?!GNY0o?aT+NgKqq*Vuw%ZAAmf4|mgl(ti#p1sgN!K>26 zRms{eG07GDGQeWh0o8K>t(@Ce_-BQ8>JttVg)Ri~Hx}cBG;6+Sdu2Y(qL$I-);4bg zbp_v6Qx5$vOT1`h05G{WRwn&}n=Kt(%={-M?CC$Z&5=D({kEwbzU $a}a>|rVfwbM6=oj+tW6Z<+(B`kVU zOz4u5qPGi?fjeWsHgvT5gX;Gi^=_$-&jaRd85mivQ%%{Ld1?3)xUD*C$n#A_H9hi4 zEr9DywX*f*8j%3jonZsj;~kne6mLfK(2f>E0E-!sa@!RL^Vx9DH@YF0PO=(bVL#M3 z9xkD^-MuS1OIwy6#n3cd>&J+S25l)x9XJ7gD_@UVxRE;mSz#gLD53~eZXQmDE0R{N zb6bz|xh>_qgeX^5+lO2kdLmx8B_3qEXD&ZefX)NY$`T=V)0p+g!G2quueh~K#>3LB z=lEqa6h@1HYu{b35RanVu9am5GI~wN!lLg-6DPY47K=igU3(UD0y>k=l)zWZ$97wG zrKxBKS+EI!aZeDlG)5MT@%GHhTZ4|a(2Y#;*)bXZgNDj-pQZkZ`VWXepCTg58mAe< z`8Ae~<*&CJ3ug@@*G5ezfuzY7%NJ^thDY~ sN4Hl~ JaU%t z!9^3nk#WpwFWbUuI2dMVru+oW8`LRNkbiZX_&k;52(h=|g-}ngDhR?xj7t5fztS5a zMU_ygxFwRaj?Uq&-0yBi|LEdnZBVyrMd9my2((wh!Nc;ctC|Cl|SAwlu!n8c}Hk z$V#yUd>Yd>b{R0{u8-R5DqY>TwonNjC2LhVFpFO5z*F;a@O*p`tm=Fjdb4|`g~}^f z3CFuSG1 pt(+|BITw=*8?d9>)7v)-c6|IEcV21#1$IPXRE&Nq- zY>gOC4o{TCq|y}6-}oNrO8vBP1J3(CU0wV;>ta8OzvRkd8t>Z$-BmJfbhc Pc z;h0>^q07+Ru*F#DTg!3-w>|_#q%k)wwBklgjH-!}gPH0wf-$98Wmoj-o}WH(C#SOE z5rLz4=*ZrpMQGET(%Y-HkBS@N0$pgS6s00XwR5~Hj}-L{rSY}OHB&3mRFm5Jgd@f= z{=1*3lbs6cwNqSpQ|w}J6TXIKTkS2Q``tliTlf)U#k}Br8~goE3QHyf`KB9A#z}=< z0~tqP@oG#^y=}g8p(`DIGvQJ*zRh}3&nujtm2i*hWiVb~zT7v<>7Qf_%_5J9s7>?L zkv~Dnor*GBb>9}v*9x%7T`n!1kjx?SR?GY_4*xc_x?{>;5g6hD2k&3s!lyB^U)XYk zV#eXk-_^MC+=~7IsH+R;xGmEy1Xwo8u39d&KjmEso;KnwN~tyd1SuGu1Xw8CR6TaK zDoNBVHJT*S{Fs;?Eyjxb>a{TG;yab++o|Th>!bT}o_%83E@PI29})y9FN$+j9UiXo z_H{lKDkv5`$`ADBdet<(=7b#z=(H*I2BE_di&olyr*we>put0Id6T(r4K#d$a1L-p zIBn7uUrffZ5NVpYAs{sxLlD+_bdgb!QAp+nOvo>Z;Wqt1ZfASjED_KwFj2xn5c}py z;JX$ns7TZ #oM0E_ zO6Qilo7(YukY-P?)!7L_2xmKcEvb2wJz~xu2i?#A>C77Efh7nIYEBNnDB v!PnPIuLr!pxl{Q3-YZF+cfP Jc0k`$JYP*JapmtZ)U8R&xp3LWa8ZpSY4r)#;%Q#$mP zRZetn(Rtj+;&gY}fD|>AMxc0SwlV)E(zw{*pNo{0f+XCR(S&QgaWNScM*$m8C1zHp z04=2gGz?)qG94eFrUlEcqG^@T`kLA|_fh(dxNdc#I)ig-qg3vcrlpy!bbYh6{KY6p z4k+-gZW-#ekLu?lK7;AjQR8M3>t-@5%|OD-H$Wt&qB0g{#EBYtVYDVu>w|-g{F-4? zlpBc#Q<|c{SljS3k#=Mi@H5@G0FG27;tCjmWc$*I@XmCWm;Q@f8gd^&mXe`MI0?6Z zyNqcc78myxQtu;{>!lS{BIPQCl%_Btmj6Nb9k$*I$fmX5jWD|t5NF-w?1IWDpQ>`)U`I$={_ zR>@iP+T=Od(!OZj6@0@;5_%Pd`)tU86t$Ceg3QU`=N~>10t;Wla2}K2nZjh*RNJW^ zVAiqKy$#lWGJUE+cE^pL77N(G(HSu#JC_F>cHs~31?@PhBo=T|$Ek?CB+dbgemx1q z12-}oHLHpGug(L59}$1?(k)1Q9u84dqnS;1HT^a@v#LkF)8u#wX|Q=0^P%!IVcnuz zo+MG}ZAwB56yU(#rHqEBfpGVwnrmB_0-PNdr%^awMjVx@u4$S0JNyOf#@>D@vdxy@ z?KFqs?T5d92F R-BSjCXOtwZ2}EM=Ry zh%P+6JXW8N%V+}?s^#asgtx=u!0L+&W>%p+b*p;0;qjdrJhf~zW3ljCXyp~0jXo>s zG&ePXM q;MVQ3N_}piH zIiJE!V-k$T(vWrTz-Xuw6#XS6cc=>`83HPPWkLEJgw`rA;r>zavWz}vDv~goWm5v( zDR%*~Sr_(1j|PZ|=j~`h5xNl{F_RQ`Z*U%mecC(yyL0#`1+cKV78*UQxs9u#LOXU9 z&p!g8jU^SXz8>+OsZdnr<)06$oP-n*^!1!W-Cy-N5q4`c(y!W95jFp)u9Z=n*Xe)W ze^}o!VvqH|r^HL=bIqJN`EeV80WYID%-FYUJ3d1%FoxrO)cE5kk^j7J;9GcI?1Mw6 zd^~+e3L3IsPxjFheJA+8t~v~nzx!^Tc>o1C&||;K{ILUhNf4)%(>b1F9722`*7(p! z2eRL8GEJ7M*HkKYDSLS_rV(3V*)2^WCgx@qT`Ih-X#q jxombe{{py9c=DUE97-R`CeDcDnAC7uDm5-l#0ek_087`4dFvcMN zJIB|A?U(n #} zyCnpH+T5#RTQlw0>xb>M&nEPZ*2q$5oM4yRCK0P_S|twu%`W&$u+ksD)l(<7P&Fi{4o%{IBBxzOVQ#1fRJz-^sZLXe z+EU{M5U$2DkB(+joUxo6=_i@EFckT`pE1}gaLA9+>AIK|ONiHs_u6;L#<}W^V^D7H z=4^4m`l`70hl5&@V4o}(2~beTxw>Bqc|P8vNJV@d*fhdUD9oX^Xd=4899A}dKk%My z)`^;ogzyky+6{K9%O3f*W&c8~Np-R>ub@Ri`tgy0$E6;A$0lCzUmtg!S8xEtfBxkk zOqc@HcbWGHsazT#^fe3x|G)XY?*!m~in9DW+m{^w-^9qjESmo;DoUWOFf#mq_+=l! zz)=3PA52jdL>$fsVXOht52wQaZ#vF@4z$pJC+F}(w3&i7!v!H$%|Ij(wD|vIU5Wn^ zkN>maT7WDf$npQlu0(@@y(d}zqkOdi6-AKX|2qH#1AC_c{YO#s2EBS>fgU4J@c;R> zga5x~ihMzok>vlgO@S05F(E$tgUln}*ZcPy1P%ta`yN~V_j ; delta 28727 zcmV)mK%T#-n*y1b0 &r}y8z`t5`K)_vs^Wjr0m<7}FI z_v$ezUj6yq|NI|+dU!j`2J=ZWEgjXww0Qe48+`X_RF<>1S69VgluY8{%_JS _JW4#M2Y|bfNpsMNcM*gO@KpYHC`O3-?q$ ziw4k218k7&X)G3PxJ?^ZgQaxqcG5k?YSB(j72Qs{oLIQ04m3N{YM>aF!_h&lpvAmi z>1X3I9>v8Iqw5xv!#YKuntPnI=4f{RJVE$A&wu8#Wozm4T6@1#i4Pi_9MD!HP;DVE zp4XY1Q9M(cax!@P{(hR}@$FdkNQsA|INYfyp#LgH(I3@6lh4(4O>0N%m{;!YeM>CH=x&|0&=3f4b5i`fqh5Z?_G6P6A&2yn=mii&;ES!<^+wk>sC}SMQur zdO!MhoP0{gW<%Sa$`l0slqL^OcIVus$#_^e#p86444vDgd`Ob1bDcdTc^FS-^TJVZ z)VWbsA#rXW>l^jAbXq3&d1Wo$JaZT_%YU8Zb37>P?l{wUk`%9|w#q*s3OHua;2# *d zfv0=#hl^{~L|;8Uuq0M9a#;z-&wq+QZ@2R6aw^MM&*FZ+TE0QEfD&(KHdfff5!3r| zT8#E#eCTo)u}bT;CLNLxM$BDm$uDZse~M=_WpJFEY@QFcPkgJJ#>8)tj`w#?na9(j z!QD=r>$(4wjXx#B*UrNzQQ!5oeP6u(E#)YATxp^nU{PjyGW_#CdJPIL34b;88WVsc z>3*Ry |xt64^Z$&v1wIN +_Oe z#| 5%L?&$7E#r|ZX`o>KpA-mbrOl_v>&mvJ?sj`GBA5GtR- z?I=$vD3eP+qoq8XJ5}rJH06h1!XJ`Y$CI0>?JB#))U0KuZg)|{gz$Vr*8}dc0EeQ} zT?$wmQXSUa23#t!Eq|KN#m!v#Hl=QLtVWb5kT}e$ud__~j!^ZaQulf!&9f%=uj6si zTx%@FI)b*R>~1^^N3rJY=3gHkXR4{& 9i>G4{D}1Xbm-5=kJ;G3Vw@U zIe#BHfB%Q`_j#pBpPn>Y-0=D$(Nrpvb`M7CPCibjAGO#|2Y;hXP1x@rvVrG%gnPK+ zCS_=pB7D?Fv3kW8BG#asKorVal`ST>L%$yuFWoeqn$0RqSvc2mQ79e!i}Ev_yIh%& z+Kc@5U+&94c1(}5V(+JEnZ{%1MtP;%G-uQ4R{xe=czFAe-2RXbhsm_tq0MIGnCAB8 zkmtT=7k6Fz-+zl=g>>3P*Ic%Vzn<5hzUOZpi$92TTetCn mVTXyebg2kGie@R}J8cQ@1 F zWpEq0RDTTxnd=42#a#`>fQJwTvZJB)TG*&ZSNBo#`?mF$y+<*t{ aVNA!CXS* zLBBg3EF=#i+#L?^kuUTQJF4Z7jIdn`B+X*ZVi9{w?v9QeE^>F;VgG94o_M9x#fq=i zi)7 46&7k@mjAFYVpwuVH&1h3Nq%Xi=I`^tA_ z4Dflw-Ld+ypuCvswbGjI4Qo`5-j17(Mr+)TJM46=6MCwJ(uKLd9sgC`e9(s61(hu= zEBP6IZcMZK#gFQxixvR!t6$xvc~SnF=?C@Kaol`c-mJ5}TK5+h>MXPDhajHnKvDhe zFMsv7#c0|i_&!gE`k(jeQ?*fd7ZXV94t6e6h?^vk&Si+$y33wdwNbxs@tU$jG3IgR zFQvYgpQnqR0Di n<=ioCZCdN`@6c`riZ@W{=Rs-Tz`4d;?9re3!5IUKGEFhKG0BMVeAI=-}O@i zz;bfBQ`2sLe^$KwsnK)Mh-t<5jv=Lb>YB+ioQFWd07)a@fhA{Ah^yJylhIdfS#zg) zWdooatXK87n{2zZT>0AecRa2&;4<~GqY Ul~&k$(g0#%K!Jg0NPomB z;kCPQx=FKQ-#6p@oEed#{aB{57<;n}l+2B&XRLuJ02YNJ>N@F`WiFm&nvCCVKn~PN zth*cAfb!~O;v@J{LzcbSlK27Vu4k%$0wkuSn~r@bTPoi!>I9T5Tkgx2dvIWrl)RZg z^hf>M4qL7RrX=*ZbR#3Pga=*#C4YWmg1v!hP0^aVUAnd>rw6)LY-54xKL;>nkzzC# z8BuNS5*%{3o9?t(2|>Uas1xoRxw+1hyY%xBYV|OL$N_?khsHJ+K#1@e>83pF)%y)9 z^s5xLPQvnf>ltOq_Rv(z)R*t3Sy^p(e8|%>agxtzQR;mUI$$5ZIf7LVLw| cSHHXkzzZ zl(}FY;8i%IMKcrzg374MXtOjsjewrYC *oef5kqY88;=ok> zFob}JsCwm5Nad$=I8^jF3sfP@4+URH+f!J=4fTHXWtz&~L$L12Qh(kq52_% zrJkV5*GTL>pbC%@;>fI@5=Or8Nq3ph((E#z3U?z6rI8C@;|J%m%7AdFV+Gy$M-(s* zfg`kB!ydYWXG$i)zRN&WexWV$GIRxD-3%P-oqXvwnMGU%-mYk)CH5uRWXdHCz{>JC z0D-_pikzj{Mc8Bn34h0Ry*V2`IFd3pd~jl*f RZy%QQUDPN z Fw}qo84}OPM!*VWcnoTdeqiUVx6-haDemht5B=d#S?}b* zndRvu&Q~jHw+YbFs^z R zc$_Ek@Ug}tMSuS|gol{>z&AiA;fj$#l`v^}`FXtD2iypRr+jZi{~!Vaml><1&(DDT zn)T1SlfexI4k2d73I-6EBSD_hV{Ei!8)a;? l21f{??;^px)k=OHae%lJ#fFZv9DLZ5)6iGKzd EZ751 zpuqDXTiKf;0~!TrZ<^U~$@y>sRRYC5ZU!Y0Wq$<9Cq-|=CEF-t!zCvMPs|g{_sts6 z%m-4mOO1ATdJpjQz0N_HPLgTqypOIvy#M*?=H}<#p_IaWQt;|zkhZ_OE=kh*yZza* z!@viEh!W0?-6oVodL#6y@vkM?Nx%m{DDogS@_Yabd>9!gINBh^Wq^;!=a{dou6f9X zk$=Ahd{{esB0I}mDRE@jSsVo1jpz}ksbP=5q($7jS;c|tOCRjBAJDpomts~S +CNnW1amIgAl?9^uy{bwzf?WZhy!XqF*rO4mCn4lQgP9$l`Wg)3 #p2%b458EhX;}0iBCCY{a0e{THEsitf&7JC~)7GJ*jWn$}YkHjAwa%8N2rar= z{d7eT!)!h+70}7 Cxw>?7h1ADvF34lU3_Ix45`UNjYiI@m z4wzYP9rKWfF1av+{{C7-H;zn(Ngj`#FfNn(%vhi3!>j|B`-pH8^I#8#3?7kSX lqXU?Zn=Qb-x&4KNzyXPsV4H1WdXJ7;>L _~hMX8Xalxs}iGiJf0y5OC&IG$4>~xcy;pEdO!V#}FLfQC7A8NG^Y=eUc zL01@>m7u`KU~dPy>6w+dwbyd_WCsgJncr+A8q5LkMjM RT|)yxi8u@Vy>pY!W5$1q^R!g%@J$()^ZqMaAm2s4kL OqW}_9)w*Y5Y(ICi`o>%S2Y!efbD8x(SU_%v`W6mM=LK$IW z3p?~dh{MXr*igkz%755U#fdQv@c`r;tT3g|AoUJh!)WgGhH?Iqlt~^>JHq(Tt<>ma z>anfWtgo6@Hk7kI6MmvDmdvdtxHav_m!2P>eFM#{Bwp)`Ep^0#X%;iKy2ofkxv=Z^ zj{C-agV(dh`iF`nnQShwzGi#VZJvEhR(N>J8~%7m>$A8hntwxKp!1N9$4-4(3{qFh z%=r(0HXEmdxJ-?B!^7@kXj|U;pKtzSS8K&A8IN1d?Wmx( QMz{w-$Q0n%HwJCPcb5V7 z8REVR%mX|K(|-v}Rdq}oyE_pvj{*CL6INANY%3SyA|aai$dd=asV@ZH>kPsmVhGp4 z5es%t1a={%#Pykx1-Le9Wze)GM%_CV*lp2jr{Qb(tl2~TV?4c|$1Byxx7xA&^vi)8 zp*ayq>{SQm=$Wwrh7CGr!32(WjdWeu>> ;0410(){7!SOl+8w(AzgSa! z8dFW7ClK=uFQ0gfFzO$84sdg)zfIbe|J%E@#sL;65(cF1$yy)7q|a0$FUF*OT5HG^ z#0rIBtuc3b#KL_w|Gvl9e~m A#~2k|hOqyy(~ zNt|DONb>$oIthd)AhQvSz7$FyCLOVeQfL;JW5jc@xA& -ZJf`74%GB$#7Vw^)^7;s@$Nr8xj9@zU>w`NO1 zbLaFpXTSW`YHpJ0Fv*kQb$p)$c@lrD=SBxV)Ju|Klnl~hk9{b{7LKZs4Vx{#fp3vB ziDxst1*Xi*jWhe^=K8bQQM*gFb?sjr)vB`?^B;Ogg@8pULS|MDz#Q=!cI?GW0e{k^ zL3Y}tui4F9Pw!uoK{lCa2^enNzSR=fEiEZFE|SuToncKE=bQIa)%I+hl*!e#dV}Ki zKU4(|=S6Zj$8YM)(L!+8=&FvNw9`kbl>@t-PKz>$hi@GJQ 4l~h4QKDQ7Yv5 z<_I}Iu%?3_5(=PkTr+{bx(N5(NRz)FAb-kb5aC`sx@>Il^q84MTnf+J7|E&cAtt-( z?}>aX64SlYUu _u9Fc-%ip(+bQQmI!TH$p7ej%7M2)tg3Tn?Fn}899X44z)ypyL z`%LxnV(hyl_5RqUvrDvQkWhI^WfH0XYo0g~z^t~r=aH;+9~5p;y77(k+q9VHiGP!P zjtAxV(RmofrBmuE+`}v>`iC`)6M`f+U=8J-=VRH;RJ5$xUr_rn^b$o86Du)ogkiw^ zZWz?k?Cj_TGYWV^n >qa24~x5E9Z-Nl=^ zLR$lkfEF;QI(DQ2L(ViS9fgwS@mK?;EdO{0ctnUxa8-l09o ~!s_3Ec2Fa7CYl&M+Pj8o|})z06vP=7C4{YCw19M2Xn zo+?RiSXSL!SU2F-N~fa{t6%)6#<_SF5Z_QuPw4j`bUe-4s%DxsdTGIR?MeGSPlx)S z_v%x%QFpfjtW^g)mn+D&dbu*)tCzJ6czWOBH5($zZDwAZuYx>`pxnz*a{~e>t2uFl zqqg)#OjEFHzMp9do_}szN9|&!M$g4@j+zV+^4x}153z^_qwixTTU@=c>Geli4Iy1? z5LXOEE6sJw<5~|_2E+L|HsY}R!M_6&cv%QhohbMc^JtYr-@Np5RvyDehb97W3@|ss zCPZM)DXb`WEEhG|0hWtimWy;HKn0!GLCft=ZmLY2&GW%Ae1Cb^iJBhuD5OTD!Ya3m zW6M1_P`S@n$CR>b>TGujyEdBw17GfBmc3zaO=i0uv36=K%-t8Bg(Q+3@3X(kVDy%< z>fSMx)k3~n2_iZR;0WD(;BIgRA>pBZqOYOZVB}IX4_J6Vuz0VOzKi8PM%L2ttaLoA zpq;Kw?gl%eJb#vq{VehI32Xo*p>pw!{bsv?jkVz?vf&W=*ki{0BR@h?R_QONwBgRV z&fsLw2WYSOTQBP37|wg9x_B|p>(hD@A%OI&)RxWy9S9&;#$7BO`%(LPW$y@4JuC>q zB@`esPJ3e^3Y1Yejry?^-bmp-4LcT|lxSByEgP}eMt?3Fu{bf>QCKGkrMW2vV=qEp zb;3&zXy-fu3xGW?ywF(Wz$jAxx#T1xvmVk?GP{cO{auynSj=^pDjB|Z2J<{mrlq43 zWt_Co8<2-@j^Kd<7rLkfzN|8rte1Qy9_RxTMXrF@ENaLxl*FgYRUk{oFAftS354hF zI!eq2JCoHTAb(ZCKO642D7}ARHCsT#FsN!2wVh#)hL{=0wpcozT=AbH+yRlqk#a%0 z6s8)c3-1gl{p+T1uj?4VcO$fG6NC+SY$KNqcbppU1TJHGCtKH`Kookwt6td$-Z@Xe zgGavfr8ESNO3oM&rwQd)FKH>87a8!NT#5)*RA>ji6n_y#7}%g!FFeo#D$+-+MIkdv zMKKR?jDqeqPfNy2Kt;qQI9#sIZAL}R^ %>AgBQV^r- =7mEo#jM%zP-a9z=2Olb?j?fheja;VQ3fk zPs(|RnZg4hjTAm)Rn6))b5c)I7}(YQUg)^Ag@5N* X0QfTz&viDNkE;qB zy`vqD5Q1J{=+Ki6$~s6(q3kb|0SO{ebpWTO<3A 7P5Auy!at@e|!CKn_MC%xNR=)J>2 zorK`DaN?;G4+hDsjHiRdQL{cz-Z)FvbPAo5>n0(8l4f~AD5Z>C?dDqwUlw?26v*Pz zmd!SZPz>B1j)x7^Y$KNq)tng7K@xE66C)K0VJ<>c9bvSQ(Oc@KduIcp`zbE-bnv<| zoG$~_C?UwFz5&%x=prbq{j1L(tMv<)vbqOUt9in?8^`x6SgJ-?>B=ELD+lM^=kcU{ zB5%!qZiT+G&!fvDceix3-Xc`>E*`|gWTJXKh{wrv80Y`AQl!5|I-b|KV~Is1s#L!=g(E>>VC$#!P2pY-xN$@3k-*pkZbLeUkjsX2PK 6E)|Og|Iphz3nAYCZYhG0rJ>? z6r9sQ9tx-{y 2Y@b{k4d09GMK0JRUota*^({{IPfQ4g(4pSlMLS`>3t+)bQ(y zlKC)73%vw0iHC_ZpE=o`Q>cz !w_IN zB6efc3jqZtIK^I7C#f&VdSZ;43z{F-ymg(Ylh}yREEsP?LZkWHEv<)Xnf@c07Nd05 zJD5NS3PQUea{344H42%gdx zQ>QkDavIo!1;>~~HnZiQ6!tI@NWjfxZA;(FhdnJKQbs)A4Q#OFg^W3WkfDn(FpM1& zJ_{H=X}!_sud4hM=V=*_otrW)=SBZGAdmzz^QhJH>6c^p_nG?T#rU^Z9DoGj0XOUf zkzU}#z|yf7I-U~;0L2ZV?-~9GL^7m7b-Jge@8#nF00U1*Y~(NR#sN0cv80TRbetF* zv5-?pj!A7iKRCKcKKG7)h!_eGa?uX3ZauC`M?{PVflt^O_}bEOMNqFF%h6E )&?Va$(z;rg4gk3DfX7|^M4OJG3`tPs`&c^mXC2NF5(y!H1g>jXe*j@XC0}m2 zvGl#@kcbk{peDNwi5y888xlD&Bm&Z7fg9<@+rd_*{0%qk)@^-xNR*UG9#4BmMTp=q zWKom5WqqwL6%`TRrxcny{q-3qiXHY{NboWBmIaJ09WMwK5saV+eIp?VK|chjIv~x` z_p(qC;)I3ElQcGe=gA@gIq)m8*g(k(paMt)a;14{EdxFcx#4dZG;K*%_s)uBnf1bp zSu!54)*5fN-~1R)@8|Kod1BG}R+_ToeP9EMd{0nnY~&;uhXD$Va^c6$jP=Ae=$r); zI2uEyQ|CH+Nb)eA%;tsjQ#_j`XQ&rX{RrZa8!^PHFfb&4-58=}fG-eKAQ$)~goc1q zMI1qeQ7zum_e%m5nBXA5yS7!?_{%nO+4#$ekrt8^xS~2)qV1zf!KI+x$bH{P>xXQT zTnfx0z-1H$`iZuc6(9 Fh{0I~_yS=T2pOV& zb%<+Qz&Y}NiC^tru=Kqwn8hXV!)4HPGt45)MFHKFjAmmuJBZd2tOF5+C!kOy=*~Zq z`aCnxjolbDZAn)5&W7#0-)Enad^*u*%e{||P#6bBUVM*uAka^=HAV #U zW7aU=ipKbBIvzXaD9;|8B+s*4pZq*b@9vUZQ~#5HKD^n9$9WPDAD!fLT9j4w+7@u= zA2C2hTsNfq Z^AtgLu`xKQh7`>%#x_369rEruCK8}@Xyt)~TlHq)u3>|+^rrEUr zt vt!=EhqEvq?lENuU)r)WAaW+ky|DGrL zqsB*dVgHA;9I5+rg{FqDok2F4#NQT)CS9pmB%aO2=^)m8;(Y(x`{@7upZ{ApahGKt zmL&%Dj3Z_LB3UcX4HXL0>1=ME;9Pfi!vng1I}@FM)jyD H^nLg>DtV`H%-io^Lhm*7@G)i@%WL7$vqPc=D3XOb7#)(EN^J#-mih99+ zsBq@9p~f!#Lj^)ZF9_W}+1+x+CeAq1u|$khjD7xMMGC%Z8Xp4|fX_**4%3!uxYYGM z$n6Qw&mfl#2%Q)~K}bX52ZnD#Lx3U=FIS=0FnT*w(z&w%L5bnK@$1z9dIwQ3^nEwN zMxcwxP`J$RZiYE_T-N7W%H (o;s5_|LEKdMp-s?as|2ap+Z_Cb@L&~RU=uceo^X}VjW6SPh|PM zx&G!
6!_EEjmzp8*A;I# zW#-(D
#el^La z3gcwc@#8wIv0_O$;V1z6luh#x4WqRy4c$AIA9nD=dP+cn01M1Q6GWNxfRV0bT7OEM zRx+!fCDWnu7KYdHeG=qJ{IRwo?>=Nz1$M2cmn+gO*^MVkDy!Q-->-#xAIW1(pnwPa z1nn)odzCyCVi6H(*nAc#n~z}E^LIlaDsZquiS8XsiJg+SvSsQ%k0+~}u5J=D4P)(t z%~q?$V;hn*vO8y5BQ2+>k(Ul1D1R}I2ZL-fi>GOAVvBToKh_Dmvr%0C@{Qv!zc>%t zNUO(+QhjTp3J;@HS?Etm-hG8Lii_HMt2UQbd!2^Zh4c=K0|3Ya5t^quQRxzaY4@ND zi}cQCPtFt}06t=vSM%SdJ_M0) jPA!vGo%+sciBYf+n1)>5wa?6d>;VXE3E-bGUUqlKVL;Hy$FcXDtda zl3uO&*0=DOthP{dSUQ=F6TN9K8E$(;7((9#+g?$vFjd9xUn?l7KIiwN4ewMIL+1rm z`@{Bq>AU_hB&CAEC@^hJh<`l9v9?Q=@%uv&XF`hH1 K ziJ)0dln0?pyl#a0*zs7OvSGSaO>vU7qe3x?GsuQ@o=Wvi|E7N=fqw%Zv(Ph;gd(SA z9$hQY^3E5JBmj4P8m_EhA&ODtbz@GJ#+QI3A_jfAtOLCzE5#4J(5q~PjX51jE*o zF}z|JMCAXoceTB3+d%lQ2>Y;oX*-E;5;v>>C6a4_wh7u4!#)*?qd3A8E07!n?XMq2 z`9-2FN3ty^LHWssB!AN)@8R8ZNAeB=v&=ET!t?a@$4;O()J^+N2fg}~&yy3B-xeJE z8+oB13Y8 ^aTc~Z`P zo3~T@jL)Wka4`eGtkcGkj0kS8SX-#PP&Nf92w|i%`ZQ%MaeqiWU%#=H@yldW00#^S zU0}t=WuAaIAVw*3&C=_DDmMkw zsf#N|rb%{n0vZ7rM3FFT0|;=) ziC#4xS#RtE!2|WilR QNeeW!y`eNM7;n&z|7c@&Zmhg4UzX?6)vSFmtrms%8Ha6he6 zww|YLwfawy&gaRjI!n!y+k9HYcQ@&zbc#I7?(fFuO@BB9RD@>K#br`C>a}e?801#? zUBW*>$ps$($q_NuC~@ezp$FH(wDNr}oJocl^7a199qU(^vJjdckBzvTiXQmfm4I+# zsfPH0^a*Iks|}epW>))7*G`zpO`gR?d6S-CLkyzWBcVQ=*@Tq{TnQVi+k?BcxbP z*av$DDu0Y8!``OCIL3yU7DOJ*C{9BTU0Zz32I_Y^xZNZ!UUjs+k(GFG+r@2tW_)}5 zotcw2+kd;BY$sq}FyH|&cb`E&q>O>~5rchoh@EU*lkHSdD-xjya)V)G7=;k)oUK;I z&x?UY0dN~9(|6Ps4b`)rXRcV=sMP>**{Ic#X@6-I3s OW 91eDU4P^g8c^g@7}R88m!hus=s4$bSyme|iu|@! z+__8tR&OstB7C&azOT1x)9wHH>vaC7*>d0HI2*-i*5e_RdS8x){>`quIW;C$AW$ER z>3*5DL~x$P>FgrD%n~gH{lHS|yZ#oHpr81lUTHNU5i=&@igpg7rL(*_v1a*V7Jn|m z(d@zBMS4|zovKgOM*X^)De4z&UDix%^D+Q<#lqJO_E^Cu)F;bqiz{tNA|WE#YyaP( z(fZ%EtGG4YH&xtYBYU8VTiv$4(6bquhJ_xQ_!JWe^)lBEQ1$@&hNj=PB1TBWqo$@` zh%?n)NTafTReiN#(6;{NBR{W{qkmTG&L?FM_Fy^#56QYRpp^G1-8IHITIDb{ma4-* zd!R^LklrYQ5d=_#MzC9vDaHh9Z_%_UdWk6d7~kC~F1w8;?*3)RI@A|k6rmfJ+GOem zR wG-&Jq$FA+k4TF Um&ws_)`3LbW zI^*Kodp #c{4-ns!6gzAYoQg3Z!Wr5R%+OGj{Hp{;2Q=yF##8tZ2%OsgO z%H5tMI*c&CDRk k{SU4Qhb4?J!jOUEvf zku=H@9?vd*R0KSpjvw?zR02kIlAfl~cYOjWYVS=QMP>ffUKmfxNs-=Z?<>Dn-d5t2 z6$FwB0^_?;K1V_Z!5%>fEBj|*E)nz)^&|7By!Dt)WEpT6Q5?yt@#gp|S3v;SJ{)wQ zW%!&-({f%_Wf|QfV1FVd6u_*|>IWgktnYzn>n!bO5jvcuhxUe@=3IXJ#`%;aSJj>$ zMH_x3MgDrCV3O)%Ly`FEO!E7pO!i97GR&NK)O7(yk7%Plv*CXL00960>^)mk<2bs1 z#nlg|W~;ivvMpaYyHxo)Llx7L1 $y?|&!RNo>Ul0g|5X>F|)m zH%Z_9DieRup3YcZ A+NAhAw%lWkGT<@KrdH#ElR(X3!9!b?bhReSwm%GP{*^{-27FRCS3ELH} zKha+3KX7{f&wtYv7vfuf@~rui_~ER@-<80Pj~z3N7bv|NF>%szjX3t8G;_-a}#PBw7ecQ61MM< zVFfP!fa?I1X(23b<qPrqu1g@O{f#a`gbCM`WDN zOTK ?GI~{NEus(tTUEO~D37 zf`8jK(tk|6*fdN5)T?ad(H!i@93VY*EGrKC_(ujHI@0Ah2QUXw%E`fP0Wz&1h$Rdi z$PB|beVn|*&_$scgnCys@bs>3zZV=>k}(%g&1fyLX)c!9-g@-774y-$d8~((S@|hz z?_Ico=cB9mD*pNQcmMJ>@^9iE8^OndhK _U4%+hd4JtJj#%~Qr3@z^;3`7FC-4Qt+JG*{O>JaAm)FATfEPszy` zY)q~dm6p+JT-=`=+^Ik~N)q2T`to2V7JqWRcs~+;lm|r~_Ubq2MuOtlY0n;m>o?No z%_h?D%m}chq$71Bh%s`!B>~?fK&|%tqu`UArBBu2jiiNR2l=Wm3V@n{wI7&{kw(jS zYeq66tatASI~SOnd(Lf3V6^^lIle}5bC zze1Q9ItE5!RmU*%jU+pUnR~XOV4WP(w-lztUX( }0BhBS8F@|>r;K@&*rArf3XF`#&_vk9f;nf4pI*AhCf zWGQAup9pIL$Ah~7MyMmh#!BW@H~l1vy%n(Uf&UmZtmL;JGX60kd_tH;nO0G*ku;lV z4M()SuG&*tYvUzfgo01f3V(b8UeK!6xbO`QZ+R`#aIx>bptz4ba%jsdzV6Ba`ozd_ za3scki9SK-AbH~&efn1 lq0dWAi53J(3s#c1_Q6pXZt8~T1%cwy>6Y!|*#9;b zzL3>ncl~0lJ<~ZzX~i3@eO;!U2p7I4AhX#dt?t2Fhxz8D7N_O)kAID%OP{`5f(Koo z`570^e@*8s1Gk^DY09;VkX$}~M;7-r6Q#3RS!As?_|KfMXYHxr6M08ZEv#9g7HjfK z1832IzwC+3=oIL*v2kMCFz^}H%5b;pKwHqN!OwB= |LPcn8Z zO#(l(DxbHC+KJYRXilYxWrWXJ^(NVPE!sA*KpV=-YHO nB!q#=@8Sr07nithQ>bpO8ls-&T_8>VIZY_in6XRCt6p(d$Lo?6yEXOmw zUXmdxs %nW?EoVQ)tI?{|J z3Q-V={)29n$bZ0IY$)bQPY}{q8M6-3`d1{<+EX3Cd>}H&P%8M76=YeB@4!^lor{k} zs$Kv4@ ;#`X1Q?WcPRkW^E6QoD%$ zv6FzgI`Tov6j#O#f&@kVRO&l<1r!<(1E?}4DVH=Bu#rl{0-a7i2~5X7&?XQ{+!0c3 z*L@v<-hXVIfxGHj=%NXu^ZSYR3(N&F?{~eCbj~UGE)91IR+*;NT>Ku?mFErrj%iua zCr}t#!O3)`{c1sph2}fkfgIou2d1GGc!yo$#ISrdf3=xL{oA`VYSIvmgAQM 4O>zNCA$0cy?Vg%jtm48@hnY4LTQm3Nb*ruXwAhb}`T?l?= zMJ|5c!#9l4ELY}P qlk1-2gk3DzK;5TKl s_bBgMURbMEpM)y z0MI^Caf0fLXf_u)(55OO^iukivVUBACMlEYML1QkR!0r#p|!|4io-C(B74@0oL$RH zlq$qQjQbBv**n%~qG9VJsNPhVR`sQ#)>2W3{nS>dKZ #Yvw!;R^pulq zQUUxj`F!%H$YInEC*T#FPO?){eVW&&DdR;}lKRv@Hgd3zT$N>)Ywy}u?Ke?m>{Z&q zX<4rC$A9uQFK;yFFS#qeuvCW+S9Od{-BaHOr&~Ts0^=Rv?dq%i z(BZ0IY5)K0rN)RfWEF4foZz@L#6Qt)!00q}b@*;;<3e|9HH3)eLVF9Uwar+ +ScbKtC!(wT@be-iy9;vU&|1!w+oqFu{YC4ZQ*p4?I4D&4Vy zj5AR~hamE?$cGJp%2fu60u+SG`x6HfKS!?G6ITsW!UQ#yc&<|GIa1>5$hQ>ZrlYYU zTuZ0mXYo!~z=S@5G?%oxuSJuK>>v=$EbWaats;;O5_gO~5YP^DE+ibB+!+Iz{it2f zr&Gc{O3_b5=M^VxT7QcTR(V6g6YZj^dBGdkQk#M{5dD}kVMFs`{M__o{`*q6f#O{X zy;KgkfpWl?{XX$E)ZJ@DZWu ;V(E-n?2}M*k{O1HYbKJeD@ky$ z5wF^Kx{}bl(nvycsv`-rH+GOouSD%2vs(9Mz42>NwQ6|nCV#!FwO_%@Z%jmI3r2I6 zDPhj~c(NH{Sc?E&F*a<0r7rGp;3MSpUnu=5+;y6D7GO|_OeUg9tclm}=wrKiP};S7 z) -7=NKy$#a<)TaM!@?O^Xh&v5YD!czkr>E?uSTd!Pk>kIYDX>mX4=gPLP zLMSvkWxWMdT+7lnjC*j`;1b;3-GaNjyW0*P+}+&??oMzB9^Bm>f`6Rz-v3 q-M~yNiCY$zKMhvwlh$zMA;W+7#vMrw9T0B3F~+U^BY8m zErWGNWuk;mYgA$yc`mhXuMwV@_BwM~TIRVY{TwM6%KX)=_S3KUY*8Q^?aM_!v(MU; zVqxmx1*Dgv4PLq1kKXeWl;1`6(?uO&Bp{vL-X|-ezRe+i4)ArXl(Qu5pzY-_i@%F+ z4AJvNB^Kq-?`si=214lies{CLG4v|j>dV|v;2RCq27Xq$ZYws%W0LOTfV|bhkH!vu zeCQ41ZW2+q<&f}04Qc|ezp8ZMiLuAydAd!RBq!u@Dm`EaOrncMzabKBGR$w^T-o)k z)aEQMDl<2;3xm#>BHf5@dh3mFgvOPudG+bFS6RAKo-QQ9a#-cM@5!NBkztCSRmYFB zZbjm3`#Fz(&hkO?zLzHQvv*M%o)WGH$MIu0&O@AHyB%$7T6G21<%-3JgW >g=PY`FvD1*CmeAD%-4$@7--?&q<_dEhXtc1yd-y}`?Vp>d>x78J9K;R2~( zk@b`I$hVcn$QRB(?G)O50a$?DI%|0;re9Z{3 ~d`*!V4E>$x1J=U)2 z&D;HjJO&eY0ow>^JO*2fyM3>%95erMoHKNxZokMjEgVouW#x7uE18bwCN>xpM}zjq z5|0Y^)=$GVuu=QT{9ysGn&?t5SSs~q_up95Rk1O9K<*54JVfA?DafmoEN6&0r#f(H z<3(q1(IjL&(Ikwf-plHL=1K`YQ^BFL=3Lp|9pJlmk!Ps|R1tJlOjIrr)GU@Gj7$T$ zA8(@fCt A9`@uj!Cg4FpP(czH!_k4cWEuMcM&xf7 zU(Nu*RplE=sql0a+k$GZil%mf9yW^H?NA%Ec%a)DCYEj~d}`Kdlj+_k8)p)st8=Bk zZ%MJpxmUd!*lR`H6D8x?s9xRZ&Nh=-n1;H6zzUd6^3GlE)CS(&v _hM1x{|OjvNaB9~ayP>}Fb8_rSw-^yP*&E7?=r zyQt6P1*>z4BH@_G61}d;W7Rx0WvtlaTEpk{Ag3DBzbXm^>gL|&m)R%SZ+n>bi5{Xc zqV{soeC>bQX#t`oOoKt)3pR9yvAwT-3Bt~(I)KTIC1@`QG79`80E~1Qdy{tF}CU zU8R o~2md>X`<|G=Ob{2N_)`+;sX+`)T^olG*ObF#Nd}hVwtj*|smR@X>)=a~ zFs1v37wd+DE^@Vd7bnmY4TTc1>Ta`f>kx7mY4Pm?Isgl<8^Zu4F%%r ztHiX@#j&H3Ci5yj=gY#)OX#E7&0H8e>wu|Y2q}XaddPG^w+*I`;(Z=D#U{OMUs>MN zZ@vnJU12xy0MCc3`i8fi85pc8d&QD h~XV<89*E{2o$-jksg5A#0M zM?)91px-6$pJ~n4o@qrXiM(E+|1xj50k~t(o)60&f={qkBm_b(AfFHe&JoEyqmX$l zwu^`9B$uHLsFq1c=s#04*l*;iQ96NZa?L}_;5zXm0>2_%oV_>GnhTa0sgwOYF#Zr? zh*HSfq`c*9AQc2(;6#W8uuvb)2q^7rRCfXGOG7GT!dh0mf-q 3sNbsBZ?Lii=`V4B`{95#`|N2w*}g1T!w!N8|fu{gR3)tVH6t?ottk zh4S22b^$VCkO+2#`-^=A>m<>4+STG9YKwL1$xPl5W&mRaQ)*Z W6E|iAzV9;_2eBkFz=`8 z`MgMofsCN4Vf>{Li6SMZpeD7X#~ro3R)SH!-xD2j!QIa=*ge_xx96R6^<{7hFT?9+ zm GUA;jPLe5T;qO+@2Z^7RG$^jOO)sw%lQtu%>K`YG+-E {yA_xvcO zog5Y2Ab;61P?9#4H~wMETpz16^2c*$cY7xj1`AU|6H_N9dJkKh!ekleWk%$oi>xE} z%R~8HXzWV}W=~dIJK0&;$#B{lys;Tcu>R(Y(LX}lWX72hwrVdxyWUx~t7qduTD`G! z35ovai#{>Lv9j;8KJ|iMfpZC#T?>)xR1*?&cozczSC_6EXs695Q$s9uCcucNS1^i< zWc =32Fq7 zZ=8;7y4YpvVJ<@95pAv;D*ysvf9S2N;=oSxlf4|#f)@rt(N!|Ru+UwP9$);rz3_-p zX^I?}2#Mp~``HdjPc~-gNQX`d 0`OZ`;8sH&j3yWrYf>2 z YJ DFd8 z$W<;lLv?-HhsZTXqcm51;r_8`6-Ep@EtjWF!oyH1707d$fIl*^fV)+Ks=YKb^8=VB zT#nONbRNN}EYFkA&;jUDhk_(i6Enq;GIXe}GlfK_2oY6Ul+V9)3g<0qKMvm)t(`X- zn @qYI2#8#QpB^3%y~2s$ zt8eg5JbY7JYdVsWJKMKl8}Z9+TDEpFyozjaF`4*Ff)v*Ch0G&!8oc&+!=a4CJ_-_b zxJhZ3pSQMpJzid$5<8ajbJE8zMv4@%rBnKS{pz}4fgIO#4#*!Sr1blg(-RwAkFU)O zL#mVwA+~D>ROxoA2Us_qzt60I*3;|GQqKqJrhHI`FRBx4KILn5`^QgH^iG=1Iw=`- zG xovybcX)JJVY<`M3~@($n@0^mopm4~MuHcq2yFs< zAn!}I`bl1YfONk#%xhq^CpEkwiL1iijq^{c+v%DmY}UEhAbki`% rgRdT!e9)rCv_Jq^CwC(Wnhw$CvR_;+KTBP?4TDTkq1Og_Y(@9D zM(Y|JB$V{ 7d}a&0$p z?BF)oG5@f5z`QiEy-H2CW6BNc>nAU9TC<|EP!jq^7JOJbj?bLJeXKuGV#GPMPnVV! z m{*zxhXuq-(Q*kFlMl3vLHUJY