From 29b4b1618a451e327710c871e512df6c9fadaf1b Mon Sep 17 00:00:00 2001 From: Nitzan Raz Date: Mon, 24 Apr 2023 14:50:40 +0300 Subject: [PATCH 01/27] Added importer app with empty models --- djang/djang/settings.py | 2 ++ djang/importer/__init__.py | 0 djang/importer/admin.py | 3 +++ djang/importer/apps.py | 6 ++++++ djang/importer/migrations/__init__.py | 0 djang/importer/models.py | 8 ++++++++ djang/importer/tests.py | 3 +++ djang/importer/views.py | 3 +++ 8 files changed, 25 insertions(+) create mode 100644 djang/importer/__init__.py create mode 100644 djang/importer/admin.py create mode 100644 djang/importer/apps.py create mode 100644 djang/importer/migrations/__init__.py create mode 100644 djang/importer/models.py create mode 100644 djang/importer/tests.py create mode 100644 djang/importer/views.py diff --git a/djang/djang/settings.py b/djang/djang/settings.py index b809e43..0490fe2 100644 --- a/djang/djang/settings.py +++ b/djang/djang/settings.py @@ -54,6 +54,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + # Mine + "importer", ] MIDDLEWARE = [ diff --git a/djang/importer/__init__.py b/djang/importer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djang/importer/admin.py b/djang/importer/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/djang/importer/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/djang/importer/apps.py b/djang/importer/apps.py new file mode 100644 index 0000000..ff9040f --- /dev/null +++ b/djang/importer/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ImporterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "importer" diff --git a/djang/importer/migrations/__init__.py b/djang/importer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djang/importer/models.py b/djang/importer/models.py new file mode 100644 index 0000000..bbb1f97 --- /dev/null +++ b/djang/importer/models.py @@ -0,0 +1,8 @@ +from django.db import models + +class Company(models.Model): + pass + +class Report(models.Model): + company = models.ForeignKey(Company, on_delete=models.CASCADE) + submission_date = models.DateField() diff --git a/djang/importer/tests.py b/djang/importer/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/djang/importer/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/djang/importer/views.py b/djang/importer/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/djang/importer/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From 8f2f5600bc526c55d46ee0cd2dfc6cdd94a26e84 Mon Sep 17 00:00:00 2001 From: Nitzan Raz Date: Mon, 24 Apr 2023 14:50:40 +0300 Subject: [PATCH 02/27] created rudimentary service --- djang/importer/models.py | 2 +- djang/importer/services/downloader.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 djang/importer/services/downloader.py diff --git a/djang/importer/models.py b/djang/importer/models.py index bbb1f97..290c2b6 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -1,7 +1,7 @@ from django.db import models class Company(models.Model): - pass + name = models.CharField(maxlength=20) class Report(models.Model): company = models.ForeignKey(Company, on_delete=models.CASCADE) diff --git a/djang/importer/services/downloader.py b/djang/importer/services/downloader.py new file mode 100644 index 0000000..d491657 --- /dev/null +++ b/djang/importer/services/downloader.py @@ -0,0 +1,12 @@ +from .. import models + +def download(file_id): + """ + # Obviously change this + company, _ = models.Company.objects.get_or_create(name="aaaaaa") + models.Report( + company=company, + submission_date=timezone.now(), + ).save() + """ + pass From 7c667a7d09c16f78cfdaa18be4a65691dec9d72d Mon Sep 17 00:00:00 2001 From: Nitzan Raz Date: Mon, 8 May 2023 22:14:55 +0300 Subject: [PATCH 03/27] fixed typo --- djang/importer/migrations/0001_initial.py | 50 +++++++++++++++++++++++ djang/importer/models.py | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 djang/importer/migrations/0001_initial.py diff --git a/djang/importer/migrations/0001_initial.py b/djang/importer/migrations/0001_initial.py new file mode 100644 index 0000000..a6fe80c --- /dev/null +++ b/djang/importer/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2 on 2023-05-08 19:14 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Company", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=20)), + ], + ), + migrations.CreateModel( + name="Report", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("submission_date", models.DateField()), + ( + "company", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="importer.company", + ), + ), + ], + ), + ] diff --git a/djang/importer/models.py b/djang/importer/models.py index 290c2b6..ca664ab 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -1,7 +1,7 @@ from django.db import models class Company(models.Model): - name = models.CharField(maxlength=20) + name = models.CharField(max_length=20) class Report(models.Model): company = models.ForeignKey(Company, on_delete=models.CASCADE) From 1e230a26699014fdac82fa1c2534cde89554567f Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 8 May 2023 22:06:58 +0300 Subject: [PATCH 04/27] unfinished: model update --- djang/importer/models.py | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/djang/importer/models.py b/djang/importer/models.py index ca664ab..1c33a89 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -1,8 +1,47 @@ from django.db import models -class Company(models.Model): - name = models.CharField(max_length=20) +class Kupot(models.Model): + company = models.CharField(maxlength=255) + track = models.CharField(maxlength=255) + track_number = models.CharField(maxlength=255) + track_code = models.CharField(maxlength=255) + +class Reports(models.Model): + kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) + report_date = models.DateField() + category = models.CharField(maxlength=255) + fair_value = models.FloatField() + percent_of_total = models.FloatField() + + +class AssetDetails(model.Model): + report_id = models.ForeignKey(Reports, on_delete=models.CASCADE) + category = models.CharField(maxlength=255) + stock_name = models.CharField(maxlength=255) + stock_code = models.IntegerField() + issuer_code = models.IntegerField() + stock_exchange = models.CharField(maxlength=255) + rating = models.CharField(maxlength=10) + rater = models.CharField(maxlength=50) + purcahse_date = models.DateField() + average_life_span = models.FloatField() + currency = models.CharField(maxlength=50) + interest_rate = models.FloatField() + proceeds = models.FloatField() + value = models.FloatField() + exchange_rate = models.FloatField() + interest_dividend = models.FloatField() + market_value = models.FloatField() + percent_of_value = models.FloatField() + percent_of_asset_channel = models.FloatField() + percent_of_total_assets = models.FloatField() + info_provider = models.CharField(maxlength=100) + sector = models.CharField(maxlength=100) + base_asset = models.CharField(maxlength=100) + fair_value = models.FloatField() + consortium = model.BooleanField() + last_valuation_date = models.DateField() + roi_in_period = models.FloatField() + estimated_value = models.FloatValue() + address = models.CharField(maxlength=255) -class Report(models.Model): - company = models.ForeignKey(Company, on_delete=models.CASCADE) - submission_date = models.DateField() From e07389f546db30a9d5be35e4a64f295c085b8f8b Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 8 May 2023 22:45:49 +0300 Subject: [PATCH 05/27] model wip --- djang/importer/models.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/djang/importer/models.py b/djang/importer/models.py index 1c33a89..43d7167 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -1,31 +1,31 @@ from django.db import models class Kupot(models.Model): - company = models.CharField(maxlength=255) - track = models.CharField(maxlength=255) - track_number = models.CharField(maxlength=255) - track_code = models.CharField(maxlength=255) + company = models.CharField(max_length=255) + track = models.CharField(max_length=255) + track_number = models.CharField(max_length=255) + track_code = models.CharField(max_length=255) class Reports(models.Model): kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) report_date = models.DateField() - category = models.CharField(maxlength=255) + category = models.CharField(max_length=255) fair_value = models.FloatField() percent_of_total = models.FloatField() -class AssetDetails(model.Model): +class AssetDetails(models.Model): report_id = models.ForeignKey(Reports, on_delete=models.CASCADE) - category = models.CharField(maxlength=255) - stock_name = models.CharField(maxlength=255) + category = models.CharField(max_length=255) + stock_name = models.CharField(max_length=255) stock_code = models.IntegerField() issuer_code = models.IntegerField() - stock_exchange = models.CharField(maxlength=255) - rating = models.CharField(maxlength=10) - rater = models.CharField(maxlength=50) + stock_exchange = models.CharField(max_length=255) + rating = models.CharField(max_length=10) + rater = models.CharField(max_length=50) purcahse_date = models.DateField() average_life_span = models.FloatField() - currency = models.CharField(maxlength=50) + currency = models.CharField(max_length=50) interest_rate = models.FloatField() proceeds = models.FloatField() value = models.FloatField() @@ -35,13 +35,13 @@ class AssetDetails(model.Model): percent_of_value = models.FloatField() percent_of_asset_channel = models.FloatField() percent_of_total_assets = models.FloatField() - info_provider = models.CharField(maxlength=100) - sector = models.CharField(maxlength=100) - base_asset = models.CharField(maxlength=100) + info_provider = models.CharField(max_length=100) + sector = models.CharField(max_length=100) + base_asset = models.CharField(max_length=100) fair_value = models.FloatField() - consortium = model.BooleanField() + consortium = models.BooleanField() last_valuation_date = models.DateField() roi_in_period = models.FloatField() - estimated_value = models.FloatValue() - address = models.CharField(maxlength=255) + estimated_value = models.FloatField() + address = models.CharField(max_length=255) From 4b811660d83d1028ad362d25939b72031e86037f Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Sat, 13 May 2023 11:41:49 +0300 Subject: [PATCH 06/27] add generated file --- ..._reports_remove_report_company_and_more.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 djang/importer/migrations/0002_assetdetails_kupot_reports_remove_report_company_and_more.py diff --git a/djang/importer/migrations/0002_assetdetails_kupot_reports_remove_report_company_and_more.py b/djang/importer/migrations/0002_assetdetails_kupot_reports_remove_report_company_and_more.py new file mode 100644 index 0000000..199d5a1 --- /dev/null +++ b/djang/importer/migrations/0002_assetdetails_kupot_reports_remove_report_company_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2 on 2023-05-08 19:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AssetDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=255)), + ('stock_name', models.CharField(max_length=255)), + ('stock_code', models.IntegerField()), + ('issuer_code', models.IntegerField()), + ('stock_exchange', models.CharField(max_length=255)), + ('rating', models.CharField(max_length=10)), + ('rater', models.CharField(max_length=50)), + ('purcahse_date', models.DateField()), + ('average_life_span', models.FloatField()), + ('currency', models.CharField(max_length=50)), + ('interest_rate', models.FloatField()), + ('proceeds', models.FloatField()), + ('value', models.FloatField()), + ('exchange_rate', models.FloatField()), + ('interest_dividend', models.FloatField()), + ('market_value', models.FloatField()), + ('percent_of_value', models.FloatField()), + ('percent_of_asset_channel', models.FloatField()), + ('percent_of_total_assets', models.FloatField()), + ('info_provider', models.CharField(max_length=100)), + ('sector', models.CharField(max_length=100)), + ('base_asset', models.CharField(max_length=100)), + ('fair_value', models.FloatField()), + ('consortium', models.BooleanField()), + ('last_valuation_date', models.DateField()), + ('roi_in_period', models.FloatField()), + ('estimated_value', models.FloatField()), + ('address', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Kupot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('company', models.CharField(max_length=255)), + ('track', models.CharField(max_length=255)), + ('track_number', models.CharField(max_length=255)), + ('track_code', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Reports', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report_date', models.DateField()), + ('category', models.CharField(max_length=255)), + ('fair_value', models.FloatField()), + ('percent_of_total', models.FloatField()), + ('kupa', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.kupot')), + ], + ), + migrations.RemoveField( + model_name='report', + name='company', + ), + migrations.DeleteModel( + name='Company', + ), + migrations.DeleteModel( + name='Report', + ), + migrations.AddField( + model_name='assetdetails', + name='report_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.reports'), + ), + ] From a2750139ac54334467f45b82815da98569d42f4d Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 22 May 2023 20:42:02 +0300 Subject: [PATCH 07/27] add model --- .gitignore | 1 + djang/djang/settings.py | 1 - ...gory_remove_reports_fair_value_and_more.py | 81 +++++++++++++++++++ ...gory_remove_reports_fair_value_and_more.py | 37 +++++++++ djang/importer/models.py | 5 +- 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 djang/importer/migrations/0001_squashed_0003_remove_reports_category_remove_reports_fair_value_and_more.py create mode 100644 djang/importer/migrations/0003_remove_reports_category_remove_reports_fair_value_and_more.py diff --git a/.gitignore b/.gitignore index 9cc5fc1..5be477e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ *.sqlite3 venv/ .idea +/djang/.vscode diff --git a/djang/djang/settings.py b/djang/djang/settings.py index 0490fe2..02a287a 100644 --- a/djang/djang/settings.py +++ b/djang/djang/settings.py @@ -54,7 +54,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # Mine "importer", ] diff --git a/djang/importer/migrations/0001_squashed_0003_remove_reports_category_remove_reports_fair_value_and_more.py b/djang/importer/migrations/0001_squashed_0003_remove_reports_category_remove_reports_fair_value_and_more.py new file mode 100644 index 0000000..152ef29 --- /dev/null +++ b/djang/importer/migrations/0001_squashed_0003_remove_reports_category_remove_reports_fair_value_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2 on 2023-05-13 19:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('importer', '0001_initial'), ('importer', '0002_assetdetails_kupot_reports_remove_report_company_and_more'), ('importer', '0003_remove_reports_category_remove_reports_fair_value_and_more')] + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Kupot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('company', models.CharField(max_length=255)), + ('track', models.CharField(max_length=255)), + ('track_number', models.CharField(max_length=255)), + ('track_code', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Reports', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('report_date', models.DateField()), + ('kupa', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.kupot')), + ], + ), + migrations.CreateModel( + name='AssetDetails', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=255)), + ('stock_name', models.CharField(max_length=255)), + ('stock_code', models.IntegerField()), + ('issuer_code', models.IntegerField()), + ('stock_exchange', models.CharField(max_length=255)), + ('rating', models.CharField(max_length=10)), + ('rater', models.CharField(max_length=50)), + ('purcahse_date', models.DateField()), + ('average_life_span', models.FloatField()), + ('currency', models.CharField(max_length=50)), + ('interest_rate', models.FloatField()), + ('proceeds', models.FloatField()), + ('value', models.FloatField()), + ('exchange_rate', models.FloatField()), + ('interest_dividend', models.FloatField()), + ('market_value', models.FloatField()), + ('percent_of_value', models.FloatField()), + ('percent_of_asset_channel', models.FloatField()), + ('percent_of_total_assets', models.FloatField()), + ('info_provider', models.CharField(max_length=100)), + ('sector', models.CharField(max_length=100)), + ('base_asset', models.CharField(max_length=100)), + ('fair_value', models.FloatField()), + ('consortium', models.BooleanField()), + ('last_valuation_date', models.DateField()), + ('roi_in_period', models.FloatField()), + ('estimated_value', models.FloatField()), + ('address', models.CharField(max_length=255)), + ('report_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.reports')), + ], + ), + migrations.CreateModel( + name='Summary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=255)), + ('fair_value', models.FloatField()), + ('percent_of_total', models.FloatField()), + ('kupa', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.kupot')), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.reports')), + ], + ), + ] diff --git a/djang/importer/migrations/0003_remove_reports_category_remove_reports_fair_value_and_more.py b/djang/importer/migrations/0003_remove_reports_category_remove_reports_fair_value_and_more.py new file mode 100644 index 0000000..2ab071c --- /dev/null +++ b/djang/importer/migrations/0003_remove_reports_category_remove_reports_fair_value_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2 on 2023-05-13 19:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0002_assetdetails_kupot_reports_remove_report_company_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='reports', + name='category', + ), + migrations.RemoveField( + model_name='reports', + name='fair_value', + ), + migrations.RemoveField( + model_name='reports', + name='percent_of_total', + ), + migrations.CreateModel( + name='Summary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=255)), + ('fair_value', models.FloatField()), + ('percent_of_total', models.FloatField()), + ('kupa', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.kupot')), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='importer.reports')), + ], + ), + ] diff --git a/djang/importer/models.py b/djang/importer/models.py index 43d7167..e5c283b 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -9,11 +9,14 @@ class Kupot(models.Model): class Reports(models.Model): kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) report_date = models.DateField() + +class Summary(models.Model): + kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) + report= models.ForeignKey(Reports, on_delete=models.CASCADE) category = models.CharField(max_length=255) fair_value = models.FloatField() percent_of_total = models.FloatField() - class AssetDetails(models.Model): report_id = models.ForeignKey(Reports, on_delete=models.CASCADE) category = models.CharField(max_length=255) From feb5f405b7989a1da26c2425ecc3aecfc604f48e Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 22 May 2023 20:43:36 +0300 Subject: [PATCH 08/27] wip --- djang/importer/xsls_ingester.py | 230 ++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 djang/importer/xsls_ingester.py diff --git a/djang/importer/xsls_ingester.py b/djang/importer/xsls_ingester.py new file mode 100644 index 0000000..3c42790 --- /dev/null +++ b/djang/importer/xsls_ingester.py @@ -0,0 +1,230 @@ +#from django.db import models +#from django.core.management import settings +#from django.core.management import execute_from_command_line +#from pathlib import Path +#BASE_DIR = Path(__file__).resolve().parent.parent +#settings.configure(DEBUG=False, +#DATABASES={'default':{ +# 'ENGINE':'django.db.backends.sqlite3', +# 'NAME':BASE_DIR / "db.sqlite3", +#} +#}, +#INSTALLED_APPS=('importer',) +#) + + +#from importer.models import * +import openpyxl +from openpyxl import load_workbook +import os +import datetime +import traceback +import argparse +import json + +MAPPING_FILE = "/home/guyga/hasadna/penssion/open-pension-next-generation/open_pension/field_mapping.json" + +class xls_ingester(object): + file = None + wb = None + with open(MAPPING_FILE) as json_data: + mapping = json.load(json_data) + + def __init__(self): + super().__init__() + + + def find_sn(self, wb): + sheet_name_array = wb.sheetnames + for sn in sheet_name_array: + # ignore dynamic pick lists that exists in some of the reports + if sn.startswith("{PL}PickLst"): + sheet_name_array.remove(sn) + for tab in self.mapping: + sn = tab["tab_name"] + if sn != "any": + self.parse_first_tab(wb, sn, tab) + sheet_name_array.remove(sn) + ws = wb[sn] + fields = tab["fields"] + for field in fields: + if field["type"] == "generated": + continue + #print(field["column_title"]) + #print(tab) + return sheet_name_array, tab + + def find_title_row(self, ws, field_name): + #print(field_name) + for row in ws.iter_rows(): + for cell1 in row: + if cell1.value is not None: + if cell1.value in field_name: + #print("cell="+cell1.value) + return cell1.row + + def get_value_for(self, ws, field_name): + for row in ws.iter_rows(): + for cell in row: + if cell1.value is not None: + if cell.value == field_name: + if ws.cell(row=cell1.row+1,column=cell1.column).value is not None: + #print(ws.cell(row=cell1.row+1,column=cell1.column).value) + return ws.cell(row=cell1.row+1,column=cell1.column).value + + def parse_first_tab(self, wb, sn, tab): + # from first tab get report date, company, track name and trach code + # optinally get report summary as well + columns = values = "" + for field in tab["fields"]: + #print(field) + if field["type"] == 'generated': + continue + if field["type"] == 'extracted': + for row in wb[sn].iter_rows(min_row=1, max_row=4, max_col=4,values_only=False): + for cell in row: + found = False + if cell.value is not None: + #print("========"+str(cell.value)) + #print(titles) + if cell.value.startswith("{PL}PickLst"): + break + stripped = cell.value.replace('*','').replace(":","").strip() + #if stripped != cell.value : + #print("stripped="+stripped+"cell_value=~"+cell.value+"~") + for field in tab["fields"]: + #in some of the reports there are multiple * characters of column titles as pointers to comments + if stripped in field["column_title"]: + found = True + i = 1 + for i in range(1,4): + val = wb[sn].cell(row=cell.row,column=cell.column+i).value + if val is not None: + columns = columns+","+field["column_name"] + values = values+",\""+str(val)+"\"" + break + break + break + return +#here need to iterate cells of row to get the value + + + + def parse_spreadsheet(self, wb): + sheet_name_array , tab = self.find_sn(wb) + for sh in sheet_name_array: + titles = list() + column_idxs = list() + column_list = list() + columns = values = "" + title_row = 0 + #print(sh) + for field in tab["fields"]: + #print(field) + if field["type"] == 'generated': + continue + if field["type"] == 'extracted': + title_row = self.find_title_row(wb[sh], field["column_title"]) + if title_row is not None: + #print("title_row="+str(title_row)+","+"column_title="+str(field["column_title"])) + break + #columns = columns + field["column_name"]+"," + #values = values + str(get_value_for(wb[sh], field["column_title"]))+"," + #print(columns+',\n\r'+values) + for row in wb[sh].iter_rows(min_row=title_row, max_row=title_row,values_only=False): + for cell in row: + found = False + if cell.value is not None: + #print("========"+str(cell.value)) + #print(titles) + if cell.value.startswith("{PL}PickLst"): + break + stripped = cell.value.replace('*','').strip() + #if stripped != cell.value : + #print("stripped="+stripped+"cell_value=~"+cell.value+"~") + for field in tab["fields"]: + #in some of the reports there are multiple * characters of column titles as pointers to comments + if stripped in field["column_title"]: + found = True + titles.append(cell.value) + column_idxs.append(cell.column) + column_list.append(field["column_name"]) + break + #else: + #print("stripped="+stripped) + if not found: + # print("stripped="+stripped+", fileds="+str(tab["fields"])) + with open("notfound_list.txt", 'a') as fileOUT: + fileOUT.write(sh +"-"+str(cell.value)+"- not found in metadata"+"\n\r") + fileOUT.close() + #print(str(cell.value)+"- not found in metadata") + #print(column_idxs) + for c in column_list: + columns = columns + ","+c + #columns = str(column_list) + done = False + for row in wb[sh].iter_rows(min_row=title_row+1): + if row[column_idxs[0]-1].value is None: + #print(str(row)) + continue + for i in range(len(column_list)): + value = str(row[column_idxs[i]-1].value) + if '*' in value: + done = True + else: + if value == "None": + value = "Null" + else: + value = "\""+value+"\"" + values = values +","+value + if done: + break + with open("inserts.sql", 'a') as fileOUT: + fileOUT.write(str(len(column_list))+"\n\r") + fileOUT.write("insert into ASSET_DETAILS ("+columns[1:]+")"+"\n\r values ("+values[1:]+");\n\r") + fileOUT.close() + values = "" + if done: + break + # print("insert into ASSET_DETAILS ("+columns[1:len(columns)-1]+")") + # print("values ("+values[1:]+")") + values = "" + + def ingest(self, file): + try: + wb = load_workbook(file) + self.parse_spreadsheet(wb) + except Exception as e: + traceback.print_exc() + with open("failed_list.txt", 'a') as fileOUT: + fileOUT.write(file+"\n\r") + fileOUT.close() + +def xls_import(path, file): + if path is None: + ingester = xls_ingester() + ingester.ingest(file) + else: + files = os.listdir(path) + files = [os.path.join(path, f) for f in files if not f.startswith(".")] + for f in files: + ingester = xls_ingester() + ingester.ingest(f) + + +def main(): # Main + parser = argparse.ArgumentParser() + parser.add_argument('-f', '--full_file_name', type=str) + parser.add_argument('-d', '--path', type=str) + args = parser.parse_args() + if args.path is None and args.full_file_name is None: + print("specify either single file or directory") + return + if args.path is not None and args.full_file_name is not None: + print("specify either single file or directory") + return + xls_import(args.path, args.full_file_name) + + +if __name__ == '__main__': + main() From 01ae202a6485ff47e89c653fd071f962789f1f82 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 12 Jun 2023 20:25:44 +0300 Subject: [PATCH 09/27] WIP 1 --- djang/importer/field_mapping.json | 317 ++++++++++++++++++ ...004_alter_assetdetails_address_and_more.py | 153 +++++++++ ...05_rename_report_id_assetdetails_report.py | 18 + ...6_reports_file_name_reports_ingested_at.py | 23 ++ ...0007_rename_report_assetdetails_reports.py | 18 + .../0008_alter_assetdetails_stock_code.py | 18 + ...ilesnotingested_unmappedfields_and_more.py | 37 ++ .../0010_alter_assetdetails_consortium.py | 18 + .../0011_alter_assetdetails_issuer_code.py | 18 + djang/importer/models.py | 71 ++-- djang/importer/services/xsls_ingester.py | 270 +++++++++++++++ djang/importer/xsls_ingester.py | 230 ------------- 12 files changed, 932 insertions(+), 259 deletions(-) create mode 100644 djang/importer/field_mapping.json create mode 100644 djang/importer/migrations/0004_alter_assetdetails_address_and_more.py create mode 100644 djang/importer/migrations/0005_rename_report_id_assetdetails_report.py create mode 100644 djang/importer/migrations/0006_reports_file_name_reports_ingested_at.py create mode 100644 djang/importer/migrations/0007_rename_report_assetdetails_reports.py create mode 100644 djang/importer/migrations/0008_alter_assetdetails_stock_code.py create mode 100644 djang/importer/migrations/0009_filesnotingested_unmappedfields_and_more.py create mode 100644 djang/importer/migrations/0010_alter_assetdetails_consortium.py create mode 100644 djang/importer/migrations/0011_alter_assetdetails_issuer_code.py create mode 100644 djang/importer/services/xsls_ingester.py delete mode 100644 djang/importer/xsls_ingester.py diff --git a/djang/importer/field_mapping.json b/djang/importer/field_mapping.json new file mode 100644 index 0000000..a97b2f6 --- /dev/null +++ b/djang/importer/field_mapping.json @@ -0,0 +1,317 @@ +[{ + "tab_name": "סכום נכסי הקרן", + "fields": [{ + "class_name": "importer.models.Kupot", + "field_name": "ID", + "column_title": "ID", + "type": "generated" + }, + { + "class_name": "importer.models.Kupot", + "field_name": "company", + "column_title": "החברה המדווחת", + "type": "extracted", + "ref_name":"kupa" + }, + { + "class_name": "importer.models.Kupot", + "field_name": "track", + "column_title": "שם מסלול/קרן/קופה", + "type": "extracted", + "ref_name":"kupa" + }, + { + "class_name": "importer.models.Kupot", + "field_name": "track_number", + "column_title": "מספר מסלול/קרן/קופה", + "type": "extracted", + "ref_name":"kupa" + }, + { + "class_name": "importer.models.Kupot", + "field_name": "track_code", + "column_title": "קוד קופת הגמל", + "type": "extracted", + "ref_name":"kupa" + }, + { + "class_name": "importer.models.Reports", + "field_name": "ID", + "column_title": "ID", + "type": "generated" + }, + { + "class_name": "importer.models.Reports", + "field_name": "kupa", + "column_title": "מזהה קופה", + "type": "reference", + "ref_name":"reports" + }, + { + "class_name": "importer.models.Reports", + "field_name": "report_date", + "column_title": ["תאריך הדיווח","תאריך הדיווח:"], + "type": "extracted", + "ref_name":"reports" + }, + { + "class_name": "importer.models.Summary", + "field_name": "Kupa", + "column_title": "מזהה קופה", + "type": "reference", + "ref_name":"sumarry" + + }, + { + "class_name": "importer.models.Summary", + "field_name": "Reports", + "column_title": "מזהה דו\"ח", + "type": "reference", + "ref_name":"sumarry" + + }, + { + "class_name": "importer.models.Summary", + "field_name": "fair_value", + "column_title": "שווי הוגן", + "type": "extracted", + "ref_name":"sumarry" + }, + { + "class_name": "importer.models.Summary", + "field_name": "percent_of_total", + "column_title": "אחוז מסה\"כ", + "type": "extracted", + "ref_name":"sumarry" + }, + { + "class_name": "importer.models.Summary", + "field_name": "category", + "column_title": "קטגוריה", + "type": "extracted", + "ref_name":"sumarry" + } + ] + }, + { + "tab_name": "any", + "fields": [{ + "class_name": "importer.models.AssetDetails", + "field_name": "ID", + "column_title": ["ID"], + "type": "generated", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "reports", + "column_title": ["מזהה דו\"ח"], + "type": "reference", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "category", + "column_title": ["קטגוריה"], + "type": "tab_name", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "sub_category", + "column_title": ["קטגורית משנה"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "stock_name", + "column_title": ["שם ני\"ע","שם נ\"ע","שם המנפיק/שם נייר ערך"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "stock_code", + "column_title": ["מספר ני\"ע","מספר נ\"ע"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "issuer_code", + "column_title": ["מספר מנפיק"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "stock_exchange", + "column_title": ["זירת מסחר"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "rating", + "column_title": ["דירוג"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "rater", + "column_title": ["שם מדרג","שם המדרג"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "purchase_date", + "column_title": ["תאריך רכישה"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "average_life_span", + "column_title": ["מח\"מ"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "currency", + "column_title": ["סוג מטבע"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "interest_rate", + "column_title": ["שיעור ריבית","שעור הריבית","שעור ריבית"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "proceeds", + "column_title": ["תשואה לפדיון","תשואה לפידיון"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "value", + "column_title": ["ערך נקוב"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "exchange_rate", + "column_title": ["שער"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "interest_dividend", + "column_title": ["פדיון ריבית דיבידנד","פידיון/ריבית לקבל","פדיון/ריבית/דיבידנד לקבל","פדיון/ריבית לקבל","פדיון/ ריבית/ דיבידנד לקבל"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "market_value", + "column_title": ["שווי שוק"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "percent_of_value", + "column_title": ["שעור מערך נקוב","שעור מערך נקוב מונפק"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "percent_of_asset_channel", + "column_title": ["שעור מנכסי אפיק השקעה","שיעור מנכסי אפיק ההשקעה","שעור מנכסי אפיק ההשקעה"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "percent_of_total_assests", + "column_title": ["שעור מסך נכסי השקעה","שעור מנכסי השקעה"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "info_provider", + "column_title": ["ספק מידע","ספק המידע"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "sector", + "column_title": ["ענף מסחר","ענף משק"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "base_asset", + "column_title": ["נכס בסיס","נכס הבסיס"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "fair_value", + "column_title": ["שווי הוגן"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "consortium", + "column_title": ["קונסורציום כן/לא","קונסורציום"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "last_valuation_date", + "column_title": ["תאריך שערוך אחרון"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "roi_in_period", + "column_title": ["שיעור התשואה במהלך התקופה"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "estimated_value", + "column_title": ["שווי משוערך"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "address", + "column_title": ["כתובת הנכס"], + "type": "extracted", + "ref_name":"details" + } + ] + } +] diff --git a/djang/importer/migrations/0004_alter_assetdetails_address_and_more.py b/djang/importer/migrations/0004_alter_assetdetails_address_and_more.py new file mode 100644 index 0000000..36929f3 --- /dev/null +++ b/djang/importer/migrations/0004_alter_assetdetails_address_and_more.py @@ -0,0 +1,153 @@ +# Generated by Django 4.2 on 2023-05-27 08:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0001_squashed_0003_remove_reports_category_remove_reports_fair_value_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='assetdetails', + name='address', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='average_life_span', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='base_asset', + field=models.CharField(max_length=100, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='category', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='consortium', + field=models.BooleanField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='currency', + field=models.CharField(max_length=50, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='estimated_value', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='exchange_rate', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='fair_value', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='info_provider', + field=models.CharField(max_length=100, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='interest_dividend', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='interest_rate', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='issuer_code', + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='last_valuation_date', + field=models.DateField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='market_value', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='percent_of_asset_channel', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='percent_of_total_assets', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='percent_of_value', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='proceeds', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='purcahse_date', + field=models.DateField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='rater', + field=models.CharField(max_length=50, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='rating', + field=models.CharField(max_length=10, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='roi_in_period', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='sector', + field=models.CharField(max_length=100, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='stock_code', + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='stock_exchange', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='assetdetails', + name='value', + field=models.FloatField(null=True), + ), + migrations.AlterField( + model_name='kupot', + name='track_code', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/djang/importer/migrations/0005_rename_report_id_assetdetails_report.py b/djang/importer/migrations/0005_rename_report_id_assetdetails_report.py new file mode 100644 index 0000000..c114b6b --- /dev/null +++ b/djang/importer/migrations/0005_rename_report_id_assetdetails_report.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-06-04 16:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0004_alter_assetdetails_address_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='assetdetails', + old_name='report_id', + new_name='report', + ), + ] diff --git a/djang/importer/migrations/0006_reports_file_name_reports_ingested_at.py b/djang/importer/migrations/0006_reports_file_name_reports_ingested_at.py new file mode 100644 index 0000000..e632df6 --- /dev/null +++ b/djang/importer/migrations/0006_reports_file_name_reports_ingested_at.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2 on 2023-06-04 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0005_rename_report_id_assetdetails_report'), + ] + + operations = [ + migrations.AddField( + model_name='reports', + name='file_name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='reports', + name='ingested_at', + field=models.DateField(null=True), + ), + ] diff --git a/djang/importer/migrations/0007_rename_report_assetdetails_reports.py b/djang/importer/migrations/0007_rename_report_assetdetails_reports.py new file mode 100644 index 0000000..8a938db --- /dev/null +++ b/djang/importer/migrations/0007_rename_report_assetdetails_reports.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-06-04 18:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0006_reports_file_name_reports_ingested_at'), + ] + + operations = [ + migrations.RenameField( + model_name='assetdetails', + old_name='report', + new_name='reports', + ), + ] diff --git a/djang/importer/migrations/0008_alter_assetdetails_stock_code.py b/djang/importer/migrations/0008_alter_assetdetails_stock_code.py new file mode 100644 index 0000000..8ec39be --- /dev/null +++ b/djang/importer/migrations/0008_alter_assetdetails_stock_code.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-06-05 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0007_rename_report_assetdetails_reports'), + ] + + operations = [ + migrations.AlterField( + model_name='assetdetails', + name='stock_code', + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/djang/importer/migrations/0009_filesnotingested_unmappedfields_and_more.py b/djang/importer/migrations/0009_filesnotingested_unmappedfields_and_more.py new file mode 100644 index 0000000..d13d920 --- /dev/null +++ b/djang/importer/migrations/0009_filesnotingested_unmappedfields_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2 on 2023-06-09 08:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0008_alter_assetdetails_stock_code'), + ] + + operations = [ + migrations.CreateModel( + name='FilesNotIngested', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.CharField(max_length=255)), + ('date', models.DateTimeField(auto_now=True)), + ('info', models.CharField(max_length=1024)), + ], + ), + migrations.CreateModel( + name='UnmappedFields', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.CharField(max_length=255)), + ('tab_name', models.CharField(max_length=255)), + ('field', models.CharField(max_length=255)), + ('date', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AlterField( + model_name='reports', + name='ingested_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/djang/importer/migrations/0010_alter_assetdetails_consortium.py b/djang/importer/migrations/0010_alter_assetdetails_consortium.py new file mode 100644 index 0000000..61f50fe --- /dev/null +++ b/djang/importer/migrations/0010_alter_assetdetails_consortium.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-06-10 19:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0009_filesnotingested_unmappedfields_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='assetdetails', + name='consortium', + field=models.CharField(max_length=4, null=True), + ), + ] diff --git a/djang/importer/migrations/0011_alter_assetdetails_issuer_code.py b/djang/importer/migrations/0011_alter_assetdetails_issuer_code.py new file mode 100644 index 0000000..44b19ce --- /dev/null +++ b/djang/importer/migrations/0011_alter_assetdetails_issuer_code.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2023-06-10 21:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0010_alter_assetdetails_consortium'), + ] + + operations = [ + migrations.AlterField( + model_name='assetdetails', + name='issuer_code', + field=models.CharField(max_length=30, null=True), + ), + ] diff --git a/djang/importer/models.py b/djang/importer/models.py index e5c283b..18154c9 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -4,11 +4,14 @@ class Kupot(models.Model): company = models.CharField(max_length=255) track = models.CharField(max_length=255) track_number = models.CharField(max_length=255) - track_code = models.CharField(max_length=255) + track_code = models.CharField(max_length=255, null=True) class Reports(models.Model): kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) report_date = models.DateField() + file_name = models.CharField(max_length=255, null=True) + ingested_at = models.DateTimeField(auto_now=True) + class Summary(models.Model): kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) @@ -18,33 +21,43 @@ class Summary(models.Model): percent_of_total = models.FloatField() class AssetDetails(models.Model): - report_id = models.ForeignKey(Reports, on_delete=models.CASCADE) - category = models.CharField(max_length=255) + reports = models.ForeignKey(Reports, on_delete=models.CASCADE) + category = models.CharField(max_length=255,null=True) stock_name = models.CharField(max_length=255) - stock_code = models.IntegerField() - issuer_code = models.IntegerField() - stock_exchange = models.CharField(max_length=255) - rating = models.CharField(max_length=10) - rater = models.CharField(max_length=50) - purcahse_date = models.DateField() - average_life_span = models.FloatField() - currency = models.CharField(max_length=50) - interest_rate = models.FloatField() - proceeds = models.FloatField() - value = models.FloatField() - exchange_rate = models.FloatField() - interest_dividend = models.FloatField() - market_value = models.FloatField() - percent_of_value = models.FloatField() - percent_of_asset_channel = models.FloatField() - percent_of_total_assets = models.FloatField() - info_provider = models.CharField(max_length=100) - sector = models.CharField(max_length=100) - base_asset = models.CharField(max_length=100) - fair_value = models.FloatField() - consortium = models.BooleanField() - last_valuation_date = models.DateField() - roi_in_period = models.FloatField() - estimated_value = models.FloatField() - address = models.CharField(max_length=255) + stock_code = models.CharField(max_length=255,null=True) + issuer_code = models.CharField(max_length=30,null=True) + stock_exchange = models.CharField(max_length=255,null=True) + rating = models.CharField(max_length=10,null=True) + rater = models.CharField(max_length=50,null=True) + purcahse_date = models.DateField(null=True) + average_life_span = models.FloatField(null=True) + currency = models.CharField(max_length=50,null=True) + interest_rate = models.FloatField(null=True) + proceeds = models.FloatField(null=True) + value = models.FloatField(null=True) + exchange_rate = models.FloatField(null=True) + interest_dividend = models.FloatField(null=True) + market_value = models.FloatField(null=True) + percent_of_value = models.FloatField(null=True) + percent_of_asset_channel = models.FloatField(null=True) + percent_of_total_assets = models.FloatField(null=True) + info_provider = models.CharField(max_length=100,null=True) + sector = models.CharField(max_length=100,null=True) + base_asset = models.CharField(max_length=100,null=True) + fair_value = models.FloatField(null=True) + consortium = models.CharField(max_length=4,null=True) + last_valuation_date = models.DateField(null=True) + roi_in_period = models.FloatField(null=True) + estimated_value = models.FloatField(null=True) + address = models.CharField(max_length=255,null=True) + +class FilesNotIngested(models.Model): + file_name = models.CharField(max_length=255) + date = models.DateTimeField(auto_now=True) + info = models.CharField(max_length=1024) +class UnmappedFields(models.Model): + file_name = models.CharField(max_length=255) + tab_name = models.CharField(max_length=255) + field = models.CharField(max_length=255) + date = models.DateTimeField(auto_now=True) diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py new file mode 100644 index 0000000..0d49d72 --- /dev/null +++ b/djang/importer/services/xsls_ingester.py @@ -0,0 +1,270 @@ +import importer +from importer import models +import openpyxl +from openpyxl import load_workbook +import os +import datetime +import traceback +import argparse +import json +import datetime +from dateutil import parser + +MAPPING_FILE = "./importer/field_mapping.json" + +class xls_ingester(object): + file = None + wb = None + with open(MAPPING_FILE) as json_data: + mapping = json.load(json_data) + reference_objects = dict() + + def __init__(self): + super().__init__() + + + def find_sn(self, wb): + sheet_name_array = wb.sheetnames + for sn in sheet_name_array: + # ignore dynamic pick lists that exists in some of the reports + if sn.startswith("{PL}PickLst"): + sheet_name_array.remove(sn) + for tab in self.mapping: + sn = tab["tab_name"] + if sn != "any": + self.parse_first_tab(wb, sn, tab) + sheet_name_array.remove(sn) + ws = wb[sn] + fields = tab["fields"] + for field in fields: + if field["type"] == "generated": + continue + #print(field["column_title"]) + #print(tab) + return sheet_name_array, tab + + def find_title_row(self, ws, field_name): + #print(field_name) + for row in ws.iter_rows(): + for cell1 in row: + if cell1.value is not None: + if cell1.value in field_name: + #print("cell="+cell1.value) + return cell1.row + + def get_value_for(self, ws, field_name): + for row in ws.iter_rows(): + for cell in row: + if cell1.value is not None: + if cell.value == field_name: + if ws.cell(row=cell1.row+1,column=cell1.column).value is not None: + #print(ws.cell(row=cell1.row+1,column=cell1.column).value) + return ws.cell(row=cell1.row+1,column=cell1.column).value + + def put_header_fields(self, reference_objects): + # special treatment - on report put the kupa, filename and ingestion date + field = {"class_name": "importer.models.Kupot", + "field_name": "kupa", + "column_title": ["kupa"], + "type": "extracted", + "ref_name":"kupa"} + self.reference_objects = self.put_in_model(self.reference_objects, field, None) + field = {"class_name": "importer.models.Reports", + "field_name": "file_name", + "column_title": ["file_name"], + "type": "extracted", + "ref_name":"reports"} + val = self.file + self.reference_objects = self.put_in_model(self.reference_objects, field, val) + field = {"class_name": "importer.models.Reports", + "field_name": "ingested_at", + "column_title": ["ingested_at"], + "type": "extracted", + "ref_name":"reports"} + val = datetime.datetime.now() + self.reference_objects = self.put_in_model(self.reference_objects, field, val) + + def parse_first_tab(self, wb, sn, tab): + # from first tab get report date, company, track name and trach code + # optinally get report summary as well + self.put_header_fields(self.reference_objects) + for field in tab["fields"]: + if field["type"] == 'generated': + continue + else: + for row in wb[sn].iter_rows(min_row=1, max_row=4, max_col=4,values_only=False): + for cell in row: + found = False + if cell.value is not None: + if str(cell.value).startswith("{PL}PickLst"): + break + stripped = str(cell.value).replace('*','').replace(":","").strip() + for field in tab["fields"]: + #in some of the reports there are multiple * characters of column titles as pointers to comments + if stripped in field["column_title"]: + found = True + i = 1 + for i in range(1,4): + val = wb[sn].cell(row=cell.row,column=cell.column+i).value + if val is not None: + self.reference_objects = self.put_in_model(self.reference_objects, field, val) + break + break + elif field["type"] == "reference": + self.reference_objects = self.put_in_model(self.reference_objects, field, None) + break + for o in self.reference_objects.values(): + try: + o.save() + except Exception as e: + traceback.print_exc() + return + + def put_in_model(self, objects, field, value): + o = obj = None + try: + if field["ref_name"] in objects: + obj = objects[field["ref_name"]] + if obj is not None and str(type(obj)) == "": + o = obj + if o is None: + o = eval(field["class_name"]+"()") + objects[field["ref_name"]] = o + if field["type"] == "reference": + value = objects[field["field_name"]] + if value is not None and value != "None" and value != '': + if("date" in field["field_name"]): + value = parser.parse(value).date() + setattr(o,field["field_name"],value) + except Exception as e: + traceback.print_exc() + return objects + + + + def parse_spreadsheet(self, wb): + sheet_name_array , tab = self.find_sn(wb) + for sh in sheet_name_array: + titles = list() + column_idxs = list() + column_list = [] + title_row = 0 + # find title row in tab + for field in tab["fields"]: + if field["type"] == 'generated': + continue + if field["type"] == 'extracted': + if title_row == 0 : + tr = self.find_title_row(wb[sh], field["column_title"]) + if tr is not None: + title_row = tr + break + # find row of values + for row in wb[sh].iter_rows(min_row=title_row, max_row=title_row,values_only=False): + for cell in row: + found = 0 + if cell.value is not None: + if str(cell.value).startswith("{PL}PickLst"): + break + #in some of the reports there are multiple * characters of column titles as pointers to comments + stripped = str(cell.value).replace('*','').strip() + for field in tab["fields"]: + if field["type"] == 'reference': + found += 1 + column_idxs.append(found) + column_list.append(field) + if field["field_name"] == "category": + found += 1 + column_idxs.append(found) + column_list.append(field) + if stripped in field["column_title"]: + found += 1 + column_idxs.append(cell.column) + column_list.append(field) + if found == 3: + break + if found > 3: + # filed not found in mapping - log + uf = importer.models.UnmappedFields() + uf.file_name = self.file + uf.tab_name = sh + uf.field = str(cell.value) + uf.save() + done = False + for row in wb[sh].iter_rows(min_row=title_row+1): + skip = False + # clear details object as each row is a new details record + if "details" in self.reference_objects: + del self.reference_objects["details"] + # put data in details object + for i in range(len(column_idxs)): + field = column_list[i] + if field["type"] == 'reference': + value = None + elif field["field_name"] == "category": + value = sh + else: + value = str(row[column_idxs[i]-1].value) + # special treatment - stock_name must be populated or row is not a data row + if "stock_name" == field["field_name"] and (value is None or value == 'None' or value == ''): + skip = True + break + # special treatment - row starting with * signals end of data + if '*' in str(value): + done = True + else: + self.reference_objects = self.put_in_model(self.reference_objects, field, value) + if done: + break + if not skip: + for o in self.reference_objects.values(): + try: + o.save() + except Exception as e: + traceback.print_exc() + if done: + break + + def ingest(self, file): + try: + self.file = file + wb = load_workbook(file) + wb.calculation.calcOnLoad = True + self.parse_spreadsheet(wb) + self.reference_objects.clear() + except Exception as e: + # log failed files + traceback.print_exc() + fni = importer.models.FilesNotIngested() + fni.file_name = file + fni.info = str(e) + fni.save() + +def xls_import(path, file): + if path is None: + ingester = xls_ingester() + ingester.ingest(file) + else: + files = os.listdir(path) + files = [os.path.join(path, f) for f in files if not f.startswith(".")] + for f in files: + ingester = xls_ingester() + ingester.ingest(f) + + +def main(): # Main + parser = argparse.ArgumentParser() + parser.add_argument('-f', '--full_file_name', type=str) + parser.add_argument('-d', '--path', type=str) + args = parser.parse_args() + if args.path is None and args.full_file_name is None: + print("specify either single file or directory") + return + if args.path is not None and args.full_file_name is not None: + print("specify either single file or directory") + return + xls_import(args.path, args.full_file_name) + + +if __name__ == '__main__': + main() diff --git a/djang/importer/xsls_ingester.py b/djang/importer/xsls_ingester.py deleted file mode 100644 index 3c42790..0000000 --- a/djang/importer/xsls_ingester.py +++ /dev/null @@ -1,230 +0,0 @@ -#from django.db import models -#from django.core.management import settings -#from django.core.management import execute_from_command_line -#from pathlib import Path -#BASE_DIR = Path(__file__).resolve().parent.parent -#settings.configure(DEBUG=False, -#DATABASES={'default':{ -# 'ENGINE':'django.db.backends.sqlite3', -# 'NAME':BASE_DIR / "db.sqlite3", -#} -#}, -#INSTALLED_APPS=('importer',) -#) - - -#from importer.models import * -import openpyxl -from openpyxl import load_workbook -import os -import datetime -import traceback -import argparse -import json - -MAPPING_FILE = "/home/guyga/hasadna/penssion/open-pension-next-generation/open_pension/field_mapping.json" - -class xls_ingester(object): - file = None - wb = None - with open(MAPPING_FILE) as json_data: - mapping = json.load(json_data) - - def __init__(self): - super().__init__() - - - def find_sn(self, wb): - sheet_name_array = wb.sheetnames - for sn in sheet_name_array: - # ignore dynamic pick lists that exists in some of the reports - if sn.startswith("{PL}PickLst"): - sheet_name_array.remove(sn) - for tab in self.mapping: - sn = tab["tab_name"] - if sn != "any": - self.parse_first_tab(wb, sn, tab) - sheet_name_array.remove(sn) - ws = wb[sn] - fields = tab["fields"] - for field in fields: - if field["type"] == "generated": - continue - #print(field["column_title"]) - #print(tab) - return sheet_name_array, tab - - def find_title_row(self, ws, field_name): - #print(field_name) - for row in ws.iter_rows(): - for cell1 in row: - if cell1.value is not None: - if cell1.value in field_name: - #print("cell="+cell1.value) - return cell1.row - - def get_value_for(self, ws, field_name): - for row in ws.iter_rows(): - for cell in row: - if cell1.value is not None: - if cell.value == field_name: - if ws.cell(row=cell1.row+1,column=cell1.column).value is not None: - #print(ws.cell(row=cell1.row+1,column=cell1.column).value) - return ws.cell(row=cell1.row+1,column=cell1.column).value - - def parse_first_tab(self, wb, sn, tab): - # from first tab get report date, company, track name and trach code - # optinally get report summary as well - columns = values = "" - for field in tab["fields"]: - #print(field) - if field["type"] == 'generated': - continue - if field["type"] == 'extracted': - for row in wb[sn].iter_rows(min_row=1, max_row=4, max_col=4,values_only=False): - for cell in row: - found = False - if cell.value is not None: - #print("========"+str(cell.value)) - #print(titles) - if cell.value.startswith("{PL}PickLst"): - break - stripped = cell.value.replace('*','').replace(":","").strip() - #if stripped != cell.value : - #print("stripped="+stripped+"cell_value=~"+cell.value+"~") - for field in tab["fields"]: - #in some of the reports there are multiple * characters of column titles as pointers to comments - if stripped in field["column_title"]: - found = True - i = 1 - for i in range(1,4): - val = wb[sn].cell(row=cell.row,column=cell.column+i).value - if val is not None: - columns = columns+","+field["column_name"] - values = values+",\""+str(val)+"\"" - break - break - break - return -#here need to iterate cells of row to get the value - - - - def parse_spreadsheet(self, wb): - sheet_name_array , tab = self.find_sn(wb) - for sh in sheet_name_array: - titles = list() - column_idxs = list() - column_list = list() - columns = values = "" - title_row = 0 - #print(sh) - for field in tab["fields"]: - #print(field) - if field["type"] == 'generated': - continue - if field["type"] == 'extracted': - title_row = self.find_title_row(wb[sh], field["column_title"]) - if title_row is not None: - #print("title_row="+str(title_row)+","+"column_title="+str(field["column_title"])) - break - #columns = columns + field["column_name"]+"," - #values = values + str(get_value_for(wb[sh], field["column_title"]))+"," - #print(columns+',\n\r'+values) - for row in wb[sh].iter_rows(min_row=title_row, max_row=title_row,values_only=False): - for cell in row: - found = False - if cell.value is not None: - #print("========"+str(cell.value)) - #print(titles) - if cell.value.startswith("{PL}PickLst"): - break - stripped = cell.value.replace('*','').strip() - #if stripped != cell.value : - #print("stripped="+stripped+"cell_value=~"+cell.value+"~") - for field in tab["fields"]: - #in some of the reports there are multiple * characters of column titles as pointers to comments - if stripped in field["column_title"]: - found = True - titles.append(cell.value) - column_idxs.append(cell.column) - column_list.append(field["column_name"]) - break - #else: - #print("stripped="+stripped) - if not found: - # print("stripped="+stripped+", fileds="+str(tab["fields"])) - with open("notfound_list.txt", 'a') as fileOUT: - fileOUT.write(sh +"-"+str(cell.value)+"- not found in metadata"+"\n\r") - fileOUT.close() - #print(str(cell.value)+"- not found in metadata") - #print(column_idxs) - for c in column_list: - columns = columns + ","+c - #columns = str(column_list) - done = False - for row in wb[sh].iter_rows(min_row=title_row+1): - if row[column_idxs[0]-1].value is None: - #print(str(row)) - continue - for i in range(len(column_list)): - value = str(row[column_idxs[i]-1].value) - if '*' in value: - done = True - else: - if value == "None": - value = "Null" - else: - value = "\""+value+"\"" - values = values +","+value - if done: - break - with open("inserts.sql", 'a') as fileOUT: - fileOUT.write(str(len(column_list))+"\n\r") - fileOUT.write("insert into ASSET_DETAILS ("+columns[1:]+")"+"\n\r values ("+values[1:]+");\n\r") - fileOUT.close() - values = "" - if done: - break - # print("insert into ASSET_DETAILS ("+columns[1:len(columns)-1]+")") - # print("values ("+values[1:]+")") - values = "" - - def ingest(self, file): - try: - wb = load_workbook(file) - self.parse_spreadsheet(wb) - except Exception as e: - traceback.print_exc() - with open("failed_list.txt", 'a') as fileOUT: - fileOUT.write(file+"\n\r") - fileOUT.close() - -def xls_import(path, file): - if path is None: - ingester = xls_ingester() - ingester.ingest(file) - else: - files = os.listdir(path) - files = [os.path.join(path, f) for f in files if not f.startswith(".")] - for f in files: - ingester = xls_ingester() - ingester.ingest(f) - - -def main(): # Main - parser = argparse.ArgumentParser() - parser.add_argument('-f', '--full_file_name', type=str) - parser.add_argument('-d', '--path', type=str) - args = parser.parse_args() - if args.path is None and args.full_file_name is None: - print("specify either single file or directory") - return - if args.path is not None and args.full_file_name is not None: - print("specify either single file or directory") - return - xls_import(args.path, args.full_file_name) - - -if __name__ == '__main__': - main() From e7c6c8a893e885f5e90548bf4d3b1fca794f85ac Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 12 Jun 2023 20:38:39 +0300 Subject: [PATCH 10/27] WIP2 --- .../management/commands/import_from_folder.py | 25 +++++++++++++++++++ .../management/commands/import_single_file.py | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 djang/importer/management/commands/import_from_folder.py create mode 100644 djang/importer/management/commands/import_single_file.py diff --git a/djang/importer/management/commands/import_from_folder.py b/djang/importer/management/commands/import_from_folder.py new file mode 100644 index 0000000..e43d973 --- /dev/null +++ b/djang/importer/management/commands/import_from_folder.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand, CommandError +from importer import models# TODO remove +from importer.services import xsls_ingester + +class Command(BaseCommand): + help = "gggxxx" + # Take filename + # open using open() + # pass filestream to xls_ingester + # print / save:wq + + + def add_arguments(self, parser): + parser.add_argument("mode", type=str) + parser.add_argument("path", type=str) + + def handle(self, *args, **options): + p=options["path"] + m=options["mode"] + self.stdout.write("About to import files from %s" %options["path"]) + xsls_ingester.xls_import(p, None) + self.stdout.write( + self.style.SUCCESS('Successfully imported "%s"' %options ) + ) + diff --git a/djang/importer/management/commands/import_single_file.py b/djang/importer/management/commands/import_single_file.py new file mode 100644 index 0000000..4281060 --- /dev/null +++ b/djang/importer/management/commands/import_single_file.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand, CommandError +from importer import models# TODO remove +from importer import xsls_ingester + +class Command(BaseCommand): + help = "gggxxx" + # Take filename + # open using open() + # pass filestream to xls_ingester + # print / save:wq + + + def add_arguments(self, parser): + parser.add_argument("mode", type=str) + parser.add_argument("file", type=str) + + def handle(self, *args, **options): + f=options["file"] + m=options["mode"] + xsls_ingester.xls_import(None, f) + self.stdout.write( + self.style.SUCCESS('Successfully imported "%s"' %options ) + ) + From 0a7d0c4ba2bc08497174e16d750634d5e42c7e6f Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 12 Jun 2023 21:55:48 +0300 Subject: [PATCH 11/27] filestream --- .../management/commands/import_from_folder.py | 12 +++++++++++- .../management/commands/import_single_file.py | 4 +++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/djang/importer/management/commands/import_from_folder.py b/djang/importer/management/commands/import_from_folder.py index e43d973..c4d2efc 100644 --- a/djang/importer/management/commands/import_from_folder.py +++ b/djang/importer/management/commands/import_from_folder.py @@ -1,6 +1,8 @@ from django.core.management.base import BaseCommand, CommandError from importer import models# TODO remove from importer.services import xsls_ingester +import os +import io class Command(BaseCommand): help = "gggxxx" @@ -18,7 +20,15 @@ def handle(self, *args, **options): p=options["path"] m=options["mode"] self.stdout.write("About to import files from %s" %options["path"]) - xsls_ingester.xls_import(p, None) + files = os.listdir(p) + files = [os.path.join(p, f) for f in files if not f.startswith(".")] + for filename in files: + with io.open(filename, "rb") as file: + xls = io.BufferedReader(file) + ingester = xsls_ingester.xls_ingester() + self.stdout.write("ingest %s" %filename ) + ingester.ingest(filename, xls) + #xsls_ingester.xls_import(p, None) self.stdout.write( self.style.SUCCESS('Successfully imported "%s"' %options ) ) diff --git a/djang/importer/management/commands/import_single_file.py b/djang/importer/management/commands/import_single_file.py index 4281060..86bde8b 100644 --- a/djang/importer/management/commands/import_single_file.py +++ b/djang/importer/management/commands/import_single_file.py @@ -17,7 +17,9 @@ def add_arguments(self, parser): def handle(self, *args, **options): f=options["file"] m=options["mode"] - xsls_ingester.xls_import(None, f) + ingester = xsls_ingester.xls_ingester() + self.stdout.write("ingest %s" %f ) + ingester.ingest(f) self.stdout.write( self.style.SUCCESS('Successfully imported "%s"' %options ) ) From e253222a47ae7856d9e2f27c69fa01cf47bfe2af Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Tue, 13 Jun 2023 13:35:47 +0300 Subject: [PATCH 12/27] filestream --- djang/importer/management/__init__.py | 0 djang/importer/management/commands/__init__.py | 0 djang/importer/management/commands/import_single_file.py | 9 ++++++--- djang/importer/services/xsls_ingester.py | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 djang/importer/management/__init__.py create mode 100644 djang/importer/management/commands/__init__.py diff --git a/djang/importer/management/__init__.py b/djang/importer/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djang/importer/management/commands/__init__.py b/djang/importer/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/djang/importer/management/commands/import_single_file.py b/djang/importer/management/commands/import_single_file.py index 86bde8b..3fe05f5 100644 --- a/djang/importer/management/commands/import_single_file.py +++ b/djang/importer/management/commands/import_single_file.py @@ -15,11 +15,14 @@ def add_arguments(self, parser): parser.add_argument("file", type=str) def handle(self, *args, **options): - f=options["file"] + filename=options["file"] m=options["mode"] ingester = xsls_ingester.xls_ingester() - self.stdout.write("ingest %s" %f ) - ingester.ingest(f) + with io.open(filename, "rb") as file: + xls = io.BufferedReader(file) + ingester = xsls_ingester.xls_ingester() + self.stdout.write("ingest %s" %filename ) + ingester.ingest(filename, xls) self.stdout.write( self.style.SUCCESS('Successfully imported "%s"' %options ) ) diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py index 0d49d72..d252755 100644 --- a/djang/importer/services/xsls_ingester.py +++ b/djang/importer/services/xsls_ingester.py @@ -225,10 +225,10 @@ def parse_spreadsheet(self, wb): if done: break - def ingest(self, file): + def ingest(self, filename, file_stream): try: - self.file = file - wb = load_workbook(file) + self.file = filename + wb = load_workbook(file_stream) wb.calculation.calcOnLoad = True self.parse_spreadsheet(wb) self.reference_objects.clear() From 9d866b0ee1ef8c45b406ba720b4c41f8e68df71d Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Tue, 13 Jun 2023 13:41:09 +0300 Subject: [PATCH 13/27] filestream --- djang/importer/services/xsls_ingester.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py index d252755..b9d2888 100644 --- a/djang/importer/services/xsls_ingester.py +++ b/djang/importer/services/xsls_ingester.py @@ -236,7 +236,7 @@ def ingest(self, filename, file_stream): # log failed files traceback.print_exc() fni = importer.models.FilesNotIngested() - fni.file_name = file + fni.file_name = filename fni.info = str(e) fni.save() From 27190fb9ff7aa11cdf9a20e91c98bb893dc47718 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Tue, 13 Jun 2023 14:01:20 +0300 Subject: [PATCH 14/27] single file --- djang/importer/management/commands/import_single_file.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/djang/importer/management/commands/import_single_file.py b/djang/importer/management/commands/import_single_file.py index 3fe05f5..69503e5 100644 --- a/djang/importer/management/commands/import_single_file.py +++ b/djang/importer/management/commands/import_single_file.py @@ -1,6 +1,8 @@ from django.core.management.base import BaseCommand, CommandError from importer import models# TODO remove -from importer import xsls_ingester +from importer.services import xsls_ingester +import io +import os class Command(BaseCommand): help = "gggxxx" From c72ce770cee384181c59a84e19b89e11cf2907f7 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Sun, 18 Jun 2023 20:25:15 +0300 Subject: [PATCH 15/27] handle formulas and more --- .../management/commands/import_from_folder.py | 11 ++++-- .../management/commands/import_single_file.py | 2 +- djang/importer/services/xsls_ingester.py | 36 +++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/djang/importer/management/commands/import_from_folder.py b/djang/importer/management/commands/import_from_folder.py index c4d2efc..5d19fe4 100644 --- a/djang/importer/management/commands/import_from_folder.py +++ b/djang/importer/management/commands/import_from_folder.py @@ -3,6 +3,7 @@ from importer.services import xsls_ingester import os import io +import datetime class Command(BaseCommand): help = "gggxxx" @@ -19,7 +20,8 @@ def add_arguments(self, parser): def handle(self, *args, **options): p=options["path"] m=options["mode"] - self.stdout.write("About to import files from %s" %options["path"]) + count = failed = 0 + self.stdout.write(str(datetime.datetime.now())+" About to import files from %s" %options["path"]) files = os.listdir(p) files = [os.path.join(p, f) for f in files if not f.startswith(".")] for filename in files: @@ -27,9 +29,12 @@ def handle(self, *args, **options): xls = io.BufferedReader(file) ingester = xsls_ingester.xls_ingester() self.stdout.write("ingest %s" %filename ) - ingester.ingest(filename, xls) + if ingester.ingest(filename, xls): + count +=1 + else: + failed +=1 #xsls_ingester.xls_import(p, None) self.stdout.write( - self.style.SUCCESS('Successfully imported "%s"' %options ) + self.style.SUCCESS(star(datetime.datetime.now())+'Successfully imported {1} files, {2} files failed'.format(count, failed)) ) diff --git a/djang/importer/management/commands/import_single_file.py b/djang/importer/management/commands/import_single_file.py index 69503e5..8cb4cca 100644 --- a/djang/importer/management/commands/import_single_file.py +++ b/djang/importer/management/commands/import_single_file.py @@ -17,7 +17,7 @@ def add_arguments(self, parser): parser.add_argument("file", type=str) def handle(self, *args, **options): - filename=options["file"] + filename="/home/guyga/hasadna/penssion/xlsx-files/files/רשימת נכסים ברמת נכס בודד- Public - מסלול פנסיה-2020 רבעון 1-מגדל מקפת אישית למקבלי קצבה קיימים.xlsx"#options["file"] m=options["mode"] ingester = xsls_ingester.xls_ingester() with io.open(filename, "rb") as file: diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py index b9d2888..cc4f2ca 100644 --- a/djang/importer/services/xsls_ingester.py +++ b/djang/importer/services/xsls_ingester.py @@ -9,6 +9,8 @@ import json import datetime from dateutil import parser +from efc.interfaces.iopenpyxl import OpenpyxlInterface +from pycel import ExcelCompiler MAPPING_FILE = "./importer/field_mapping.json" @@ -132,7 +134,7 @@ def put_in_model(self, objects, field, value): objects[field["ref_name"]] = o if field["type"] == "reference": value = objects[field["field_name"]] - if value is not None and value != "None" and value != '': + if value is not None and value != "None" and str(value).strip() != '': if("date" in field["field_name"]): value = parser.parse(value).date() setattr(o,field["field_name"],value) @@ -204,7 +206,33 @@ def parse_spreadsheet(self, wb): elif field["field_name"] == "category": value = sh else: - value = str(row[column_idxs[i]-1].value) + cell1 = row[column_idxs[i]-1] + value = str(cell1.value) + if value.startswith("="): + try: + value = self.interface.calc_cell(cell1.coordinate,sh) + #value = self.excel.evaluate("'"+sh+"'!"+cell1.coordinate) + except: + print("++++++++++++++++++++\n\r \ + failed at "+sh+"-"+cell1.coordinate+ + "\n\r+++++++++++++++++++++") + traceback.print_exc() + fni = importer.models.FilesNotIngested() + fni.file_name = filename + fni.info = sh+"-"+cell1.coordinate+ \ + "\n\r+++"+str(e) + fni.save() + break + f_format = cell1.number_format + if f_format == "0.00%": + #match f_format: + # case "0.00%": + value = value*100 + # case "#,##0.00" : + # value = '{:.2}'.format(value) + #elif f_format == "#,##0.00" : + value = f"{value:.2f}" + print(self.file+","+sh+ "-"+cell1.coordinate+"-"+str(value) +" format ="+f_format ) # special treatment - stock_name must be populated or row is not a data row if "stock_name" == field["field_name"] and (value is None or value == 'None' or value == ''): skip = True @@ -229,9 +257,12 @@ def ingest(self, filename, file_stream): try: self.file = filename wb = load_workbook(file_stream) + self.interface = OpenpyxlInterface(wb=wb, use_cache=True) + #self.excel = ExcelCompiler(excel=wb) wb.calculation.calcOnLoad = True self.parse_spreadsheet(wb) self.reference_objects.clear() + return True except Exception as e: # log failed files traceback.print_exc() @@ -239,6 +270,7 @@ def ingest(self, filename, file_stream): fni.file_name = filename fni.info = str(e) fni.save() + return False def xls_import(path, file): if path is None: From 90698864a52e582b61c0360d436f5a61a6c4e218 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 4 Sep 2023 20:58:07 +0300 Subject: [PATCH 16/27] handle existing records and more --- .../management/commands/import_from_folder.py | 6 +- djang/importer/services/xsls_ingester.py | 317 +++++++++++------- 2 files changed, 195 insertions(+), 128 deletions(-) diff --git a/djang/importer/management/commands/import_from_folder.py b/djang/importer/management/commands/import_from_folder.py index 5d19fe4..09c08c8 100644 --- a/djang/importer/management/commands/import_from_folder.py +++ b/djang/importer/management/commands/import_from_folder.py @@ -16,10 +16,13 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("mode", type=str) parser.add_argument("path", type=str) + parser.add_argument("force_override",nargs='?', + help="Overwrite existing reports",) def handle(self, *args, **options): p=options["path"] m=options["mode"] + count = failed = 0 self.stdout.write(str(datetime.datetime.now())+" About to import files from %s" %options["path"]) files = os.listdir(p) @@ -28,6 +31,7 @@ def handle(self, *args, **options): with io.open(filename, "rb") as file: xls = io.BufferedReader(file) ingester = xsls_ingester.xls_ingester() + ingester.force = options["force_override"] is not None self.stdout.write("ingest %s" %filename ) if ingester.ingest(filename, xls): count +=1 @@ -35,6 +39,6 @@ def handle(self, *args, **options): failed +=1 #xsls_ingester.xls_import(p, None) self.stdout.write( - self.style.SUCCESS(star(datetime.datetime.now())+'Successfully imported {1} files, {2} files failed'.format(count, failed)) + self.style.SUCCESS(str(datetime.datetime.now())+'Successfully imported {1} files, {2} files failed'.format(count, failed)) ) diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py index cc4f2ca..3513275 100644 --- a/djang/importer/services/xsls_ingester.py +++ b/djang/importer/services/xsls_ingester.py @@ -14,23 +14,24 @@ MAPPING_FILE = "./importer/field_mapping.json" + class xls_ingester(object): + force = False file = None wb = None with open(MAPPING_FILE) as json_data: - mapping = json.load(json_data) - reference_objects = dict() + mapping = json.load(json_data) + reference_objects = dict() def __init__(self): super().__init__() - - + def find_sn(self, wb): sheet_name_array = wb.sheetnames for sn in sheet_name_array: # ignore dynamic pick lists that exists in some of the reports if sn.startswith("{PL}PickLst"): - sheet_name_array.remove(sn) + sheet_name_array.remove(sn) for tab in self.mapping: sn = tab["tab_name"] if sn != "any": @@ -39,139 +40,185 @@ def find_sn(self, wb): ws = wb[sn] fields = tab["fields"] for field in fields: - if field["type"] == "generated": + if field["type"] == "generated": continue - #print(field["column_title"]) - #print(tab) + # print(field["column_title"]) + # print(tab) return sheet_name_array, tab def find_title_row(self, ws, field_name): - #print(field_name) + # print(field_name) for row in ws.iter_rows(): for cell1 in row: if cell1.value is not None: if cell1.value in field_name: - #print("cell="+cell1.value) + # print("cell="+cell1.value) return cell1.row - - def get_value_for(self, ws, field_name): - for row in ws.iter_rows(): - for cell in row: - if cell1.value is not None: - if cell.value == field_name: - if ws.cell(row=cell1.row+1,column=cell1.column).value is not None: - #print(ws.cell(row=cell1.row+1,column=cell1.column).value) - return ws.cell(row=cell1.row+1,column=cell1.column).value def put_header_fields(self, reference_objects): # special treatment - on report put the kupa, filename and ingestion date field = {"class_name": "importer.models.Kupot", - "field_name": "kupa", - "column_title": ["kupa"], - "type": "extracted", - "ref_name":"kupa"} - self.reference_objects = self.put_in_model(self.reference_objects, field, None) + "field_name": "kupa", + "column_title": ["kupa"], + "type": "extracted", + "ref_name": "kupa"} + self.reference_objects = self.put_in_model( + self.reference_objects, field, None) field = {"class_name": "importer.models.Reports", - "field_name": "file_name", - "column_title": ["file_name"], - "type": "extracted", - "ref_name":"reports"} + "field_name": "file_name", + "column_title": ["file_name"], + "type": "extracted", + "ref_name": "reports"} val = self.file - self.reference_objects = self.put_in_model(self.reference_objects, field, val) + self.reference_objects = self.put_in_model( + self.reference_objects, field, val) field = {"class_name": "importer.models.Reports", - "field_name": "ingested_at", - "column_title": ["ingested_at"], - "type": "extracted", - "ref_name":"reports"} + "field_name": "ingested_at", + "column_title": ["ingested_at"], + "type": "extracted", + "ref_name": "reports"} val = datetime.datetime.now() - self.reference_objects = self.put_in_model(self.reference_objects, field, val) - + self.reference_objects = self.put_in_model( + self.reference_objects, field, val) + def parse_first_tab(self, wb, sn, tab): - # from first tab get report date, company, track name and trach code - # optinally get report summary as well - self.put_header_fields(self.reference_objects) + # from first tab get report date, company, track name and track code + # optinally get report summary as well + self.put_header_fields(self.reference_objects) for field in tab["fields"]: - if field["type"] == 'generated': + if field["type"] == 'generated': continue else: - for row in wb[sn].iter_rows(min_row=1, max_row=4, max_col=4,values_only=False): - for cell in row: - found = False - if cell.value is not None: - if str(cell.value).startswith("{PL}PickLst"): - break - stripped = str(cell.value).replace('*','').replace(":","").strip() - for field in tab["fields"]: - #in some of the reports there are multiple * characters of column titles as pointers to comments - if stripped in field["column_title"]: - found = True - i = 1 - for i in range(1,4): - val = wb[sn].cell(row=cell.row,column=cell.column+i).value - if val is not None: - self.reference_objects = self.put_in_model(self.reference_objects, field, val) - break + for row in wb[sn].iter_rows(min_row=1, max_row=4, max_col=4, values_only=False): + for cell in row: + found = False + if cell.value is not None: + if str(cell.value).startswith("{PL}PickLst"): break - elif field["type"] == "reference": - self.reference_objects = self.put_in_model(self.reference_objects, field, None) - break - for o in self.reference_objects.values(): - try: - o.save() - except Exception as e: - traceback.print_exc() + stripped = str(cell.value).replace( + '*', '').replace(":", "").strip() + for field1 in tab["fields"]: + # in some of the reports there are multiple * characters of column titles as pointers to comments + if stripped in field1["column_title"]: + found = True + i = 1 + for i in range(1, 4): + val = wb[sn].cell( + row=cell.row, column=cell.column+i).value + if val is not None: + self.reference_objects = self.put_in_model( + self.reference_objects, field1, val) + break + break + elif field1["type"] == "reference": + self.reference_objects = self.put_in_model( + self.reference_objects, field1, None) + break + self.save_first_tab() + return + def save_first_tab(self): + # if kupa exists + # compare to new + # warn if not equals + # use it + # else use new one + kupa = self.kupa_exists() + if kupa is not None: + if kupa.company != self.reference_objects["kupa"].company or kupa.track != self.reference_objects["kupa"].track: + ptint("Warning: kupa not equals") + self.reference_objects["kupa"] = kupa + report = self.report_exists() + if report is not None: + if self.force: + self.reference_objects["reports"] = report + print("Warning: overwriting report") + else: + raise ValueError("report already exists") + self.reference_objects["reports"].kupa = kupa + self.save_objects() + + + def kupa_exists(self): + dset = [] + track_no = self.reference_objects["kupa"].track_number + try: + dset = importer.models.Kupot.objects.filter(track_number=track_no) + except Exception as e: + traceback.print_exc() + #return len(dset) > 0 + if len(dset) > 0: + return dset[0] + else: + return None + + def report_exists(self): + dset = [] + company = self.reference_objects["kupa"].company + track_no = self.reference_objects["kupa"].track_number + rep_date = self.reference_objects["reports"].report_date + dset = importer.models.Reports.objects.filter(report_date=rep_date, + kupa__company=company, kupa__track_number=track_no) + if len(dset) > 0: + return dset[0] + else: + return None + def put_in_model(self, objects, field, value): o = obj = None try: + # get object from map if field["ref_name"] in objects: obj = objects[field["ref_name"]] if obj is not None and str(type(obj)) == "": o = obj - if o is None: + # if not instantiated do so + if o is None: o = eval(field["class_name"]+"()") objects[field["ref_name"]] = o + # if field type is reference then get the value from object map if field["type"] == "reference": value = objects[field["field_name"]] - if value is not None and value != "None" and str(value).strip() != '': - if("date" in field["field_name"]): - value = parser.parse(value).date() - setattr(o,field["field_name"],value) + if value is not None and value != "None" and str(value).strip() != '': + # special case assumes all date fields have "date" in field name + if ("date" in field["field_name"]): + value = parser.parse(value).date() + # set the value to the right member of object + setattr(o, field["field_name"], value) except Exception as e: - traceback.print_exc() + traceback.print_exc() return objects - - def parse_spreadsheet(self, wb): - sheet_name_array , tab = self.find_sn(wb) + sheet_name_array, tab = self.find_sn(wb) for sh in sheet_name_array: + # print("parsing {}".format(sh)) titles = list() column_idxs = list() - column_list = [] + column_list = [] title_row = 0 - # find title row in tab + # find title row in tab for field in tab["fields"]: - if field["type"] == 'generated': + if field["type"] == 'generated': continue - if field["type"] == 'extracted': - if title_row == 0 : + if field["type"] == 'extracted': + if title_row == 0: tr = self.find_title_row(wb[sh], field["column_title"]) if tr is not None: title_row = tr break - # find row of values - for row in wb[sh].iter_rows(min_row=title_row, max_row=title_row,values_only=False): + # find row of values + for row in wb[sh].iter_rows(min_row=title_row, max_row=title_row, values_only=False): for cell in row: found = 0 if cell.value is not None: if str(cell.value).startswith("{PL}PickLst"): break - #in some of the reports there are multiple * characters of column titles as pointers to comments - stripped = str(cell.value).replace('*','').strip() + # in some of the reports there are multiple * characters of column titles as pointers to comments + stripped = str(cell.value).replace('*', '').strip() for field in tab["fields"]: - if field["type"] == 'reference': + if field["type"] == 'reference': found += 1 column_idxs.append(found) column_list.append(field) @@ -183,25 +230,25 @@ def parse_spreadsheet(self, wb): found += 1 column_idxs.append(cell.column) column_list.append(field) - if found == 3: - break - if found > 3: - # filed not found in mapping - log + if found == 3: + break + if found < 3: + # field not found in mapping - log uf = importer.models.UnmappedFields() uf.file_name = self.file uf.tab_name = sh uf.field = str(cell.value) uf.save() - done = False + done = False for row in wb[sh].iter_rows(min_row=title_row+1): - skip = False + skip = False # clear details object as each row is a new details record if "details" in self.reference_objects: del self.reference_objects["details"] - # put data in details object + # put data in details object for i in range(len(column_idxs)): field = column_list[i] - if field["type"] == 'reference': + if field["type"] == 'reference': value = None elif field["field_name"] == "category": value = sh @@ -210,68 +257,84 @@ def parse_spreadsheet(self, wb): value = str(cell1.value) if value.startswith("="): try: - value = self.interface.calc_cell(cell1.coordinate,sh) - #value = self.excel.evaluate("'"+sh+"'!"+cell1.coordinate) - except: - print("++++++++++++++++++++\n\r \ - failed at "+sh+"-"+cell1.coordinate+ - "\n\r+++++++++++++++++++++") - traceback.print_exc() + # field is a formula field - calculate it + value = self.interface.calc_cell( + cell1.coordinate, sh) + # value = self.excel.evaluate("'"+sh+"'!"+cell1.coordinate) + except Exception as e: + traceback.print_exc() fni = importer.models.FilesNotIngested() - fni.file_name = filename - fni.info = sh+"-"+cell1.coordinate+ \ - "\n\r+++"+str(e) + fni.file_name = self.file + fni.info = sh+"-"+cell1.coordinate + \ + "\n\r+++"+str(e) fni.save() - break + break f_format = cell1.number_format if f_format == "0.00%": - #match f_format: - # case "0.00%": - value = value*100 + # match f_format: + # case "0.00%": + value = value*100 # case "#,##0.00" : - # value = '{:.2}'.format(value) - #elif f_format == "#,##0.00" : + # value = '{:.2}'.format(value) + # elif f_format == "#,##0.00" : + # round to 2 decimal points value = f"{value:.2f}" - print(self.file+","+sh+ "-"+cell1.coordinate+"-"+str(value) +" format ="+f_format ) - # special treatment - stock_name must be populated or row is not a data row + # print(self.file+","+sh+ "-"+cell1.coordinate+"-"+str(value) +" format ="+f_format ) + # special treatment - stock_name must be populated or row is not a data row if "stock_name" == field["field_name"] and (value is None or value == 'None' or value == ''): - skip = True - break - # special treatment - row starting with * signals end of data + skip = True + break + # special treatment - row starting with * signals end of data if '*' in str(value): - done = True - else: - self.reference_objects = self.put_in_model(self.reference_objects, field, value) + done = True + else: + self.reference_objects = self.put_in_model( + self.reference_objects, field, value) if done: break if not skip: - for o in self.reference_objects.values(): - try: - o.save() - except Exception as e: - traceback.print_exc() - if done: - break - + self.save_objects() + if done: + break + + def save_objects(self): + for o in self.reference_objects.values(): + try: + o.save() + except Exception as e: + traceback.print_exc() + fni = importer.models.FilesNotIngested() + fni.file_name = self.file + fni.info = sh+"-" + type(o).__name__ +\ + "\n\r+++"+str(e) + fni.save() + def ingest(self, filename, file_stream): try: self.file = filename wb = load_workbook(file_stream) + # OpenpyxlInterface allows calculating formulas self.interface = OpenpyxlInterface(wb=wb, use_cache=True) - #self.excel = ExcelCompiler(excel=wb) - wb.calculation.calcOnLoad = True + # self.excel = ExcelCompiler(excel=wb) + # wb.calculation.calcOnLoad = True self.parse_spreadsheet(wb) self.reference_objects.clear() return True + except ValueError: + #traceback.print_exc() + print("report already exists") + return False + except Exception as e: # log failed files traceback.print_exc() fni = importer.models.FilesNotIngested() fni.file_name = filename - fni.info = str(e) + fni.info = "Failed to read workbook\n\r"+str(e) fni.save() return False + def xls_import(path, file): if path is None: ingester = xls_ingester() @@ -299,4 +362,4 @@ def main(): # Main if __name__ == '__main__': - main() + main() From 99509d1adbe644cc1c58ea6bed0bd64af2c006ce Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Thu, 23 May 2024 16:18:55 +0300 Subject: [PATCH 17/27] Latest --- djang/importer/field_mapping.json | 51 +++++++++++++++++-- .../management/commands/import_from_folder.py | 2 +- ...0012_assetdetails_average_interest_rate.py | 18 +++++++ ...t_type_assetdetails_commitment_and_more.py | 38 ++++++++++++++ djang/importer/models.py | 6 +++ djang/importer/services/xsls_ingester.py | 4 +- 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 djang/importer/migrations/0012_assetdetails_average_interest_rate.py create mode 100644 djang/importer/migrations/0013_assetdetails_asset_type_assetdetails_commitment_and_more.py diff --git a/djang/importer/field_mapping.json b/djang/importer/field_mapping.json index a97b2f6..cb6d4cc 100644 --- a/djang/importer/field_mapping.json +++ b/djang/importer/field_mapping.json @@ -133,7 +133,7 @@ { "class_name": "importer.models.AssetDetails", "field_name": "stock_code", - "column_title": ["מספר ני\"ע","מספר נ\"ע"], + "column_title": ["מספר ני\"ע","מספר נ\"ע","מספר הנייר","מספר נע במערכת","מספר נייר"], "type": "extracted", "ref_name":"details" }, @@ -189,7 +189,7 @@ { "class_name": "importer.models.AssetDetails", "field_name": "interest_rate", - "column_title": ["שיעור ריבית","שעור הריבית","שעור ריבית"], + "column_title": ["תנאי ושיעור ריבית","שיעור ריבית","שעור הריבית","שעור ריבית"], "type": "extracted", "ref_name":"details" }, @@ -217,7 +217,7 @@ { "class_name": "importer.models.AssetDetails", "field_name": "interest_dividend", - "column_title": ["פדיון ריבית דיבידנד","פידיון/ריבית לקבל","פדיון/ריבית/דיבידנד לקבל","פדיון/ריבית לקבל","פדיון/ ריבית/ דיבידנד לקבל"], + "column_title": ["פדיון ריבית דיבידנד","פידיון/ריבית לקבל","פדיון/ריבית/דיבידנד לקבל","פדיון/ריבית לקבל","פדיון/ ריבית/ דיבידנד לקבל","פדיון/ ריבית לקבל","פדיון/ ריבית לקבל*****"], "type": "extracted", "ref_name":"details" }, @@ -294,7 +294,7 @@ { "class_name": "importer.models.AssetDetails", "field_name": "roi_in_period", - "column_title": ["שיעור התשואה במהלך התקופה"], + "column_title": ["שיעור התשואה במהלך התקופה","שעור תשואה במהלך התקופה"], "type": "extracted", "ref_name":"details" }, @@ -311,7 +311,50 @@ "column_title": ["כתובת הנכס"], "type": "extracted", "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "average_interest_rate", + "column_title": ["שיעור ריבית ממוצע"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "asset_type", + "column_title": ["אופי הנכס"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "commitment", + "column_title": ["סכום ההתחייבות"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "effective_interest", + "column_title": ["ריבית אפקטיבית"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "coordinated_cost", + "column_title": ["עלות מתואמת","עלות מותאמת"], + "type": "extracted", + "ref_name":"details" + }, + { + "class_name": "importer.models.AssetDetails", + "field_name": "commitment_end_date", + "column_title": ["תאריך סיום ההתחייבות","סיום התחייבות","תאריך סיום"], + "type": "extracted", + "ref_name":"details" } + ] } ] diff --git a/djang/importer/management/commands/import_from_folder.py b/djang/importer/management/commands/import_from_folder.py index 09c08c8..6ff653c 100644 --- a/djang/importer/management/commands/import_from_folder.py +++ b/djang/importer/management/commands/import_from_folder.py @@ -39,6 +39,6 @@ def handle(self, *args, **options): failed +=1 #xsls_ingester.xls_import(p, None) self.stdout.write( - self.style.SUCCESS(str(datetime.datetime.now())+'Successfully imported {1} files, {2} files failed'.format(count, failed)) + self.style.SUCCESS(str(datetime.datetime.now())+'Successfully imported {0} files, {1} files failed'.format(count, failed)) ) diff --git a/djang/importer/migrations/0012_assetdetails_average_interest_rate.py b/djang/importer/migrations/0012_assetdetails_average_interest_rate.py new file mode 100644 index 0000000..8a347ad --- /dev/null +++ b/djang/importer/migrations/0012_assetdetails_average_interest_rate.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2 on 2024-04-12 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0011_alter_assetdetails_issuer_code'), + ] + + operations = [ + migrations.AddField( + model_name='assetdetails', + name='average_interest_rate', + field=models.FloatField(null=True), + ), + ] diff --git a/djang/importer/migrations/0013_assetdetails_asset_type_assetdetails_commitment_and_more.py b/djang/importer/migrations/0013_assetdetails_asset_type_assetdetails_commitment_and_more.py new file mode 100644 index 0000000..327d2ab --- /dev/null +++ b/djang/importer/migrations/0013_assetdetails_asset_type_assetdetails_commitment_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2 on 2024-04-12 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('importer', '0012_assetdetails_average_interest_rate'), + ] + + operations = [ + migrations.AddField( + model_name='assetdetails', + name='asset_type', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='assetdetails', + name='commitment', + field=models.FloatField(null=True), + ), + migrations.AddField( + model_name='assetdetails', + name='commitment_end_date', + field=models.DateField(null=True), + ), + migrations.AddField( + model_name='assetdetails', + name='coordinated_cost', + field=models.FloatField(null=True), + ), + migrations.AddField( + model_name='assetdetails', + name='effective_interest', + field=models.FloatField(null=True), + ), + ] diff --git a/djang/importer/models.py b/djang/importer/models.py index 18154c9..54e791f 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -50,6 +50,12 @@ class AssetDetails(models.Model): roi_in_period = models.FloatField(null=True) estimated_value = models.FloatField(null=True) address = models.CharField(max_length=255,null=True) + average_interest_rate = models.FloatField(null=True) + asset_type = models.CharField(max_length=255,null=True) + commitment = models.FloatField(null=True) + effective_interest = models.FloatField(null=True) + coordinated_cost = models.FloatField(null=True) + commitment_end_date = models.DateField(null=True) class FilesNotIngested(models.Model): file_name = models.CharField(max_length=255) diff --git a/djang/importer/services/xsls_ingester.py b/djang/importer/services/xsls_ingester.py index 3513275..37f56f5 100644 --- a/djang/importer/services/xsls_ingester.py +++ b/djang/importer/services/xsls_ingester.py @@ -127,7 +127,7 @@ def save_first_tab(self): kupa = self.kupa_exists() if kupa is not None: if kupa.company != self.reference_objects["kupa"].company or kupa.track != self.reference_objects["kupa"].track: - ptint("Warning: kupa not equals") + print("Warning: kupa not equals") self.reference_objects["kupa"] = kupa report = self.report_exists() if report is not None: @@ -305,7 +305,7 @@ def save_objects(self): traceback.print_exc() fni = importer.models.FilesNotIngested() fni.file_name = self.file - fni.info = sh+"-" + type(o).__name__ +\ + fni.info = type(o).__name__ +\ "\n\r+++"+str(e) fni.save() From 5700721646a9f8b1cd0d717d01e198684aeb8d35 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Mon, 27 May 2024 16:38:04 +0300 Subject: [PATCH 18/27] update requirements.txt --- requirements.txt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8293bb5..3b4584f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ -asgiref==3.6.0 -Django==4.2 -sqlparse==0.4.4 -gunicorn==20.1.0 -uvicorn==0.20.0 -psycopg2-binary==2.9.5 +asgiref +Django +sqlparse +gunicorn +uvicorn +psycopg2-binary +openpyxl +pycel + From 067ac24135da25032102e724308e16695dc3a23a Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Mon, 27 May 2024 17:11:24 +0300 Subject: [PATCH 19/27] Update README.md --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9253c6..b276779 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,19 @@ -# Open Pension Next Generation +This is the xls ingester project, it reads data from excele files in a given directory into an sql database.
+קבצי המקור הם דוחות "הנכס הבודד" רבעוניים מהגופים הפנסוניים +# The database structure is as follows: +importer_kupot - רשימת כל החברות והמסלולים
+importer_reports - linked to kupot - contains the report date and file name
+importer_asset_details - linked to reports - contains the details of assets and values.
## Setup ``` make init ``` +# build the database +``` +make makemigrations +``` ## Running @@ -12,6 +21,11 @@ make init make serve ``` +``` +cd djang +../venv/bin/python3 manage.py import_from_folder path= +``` + ## Docker Compose development From c60c10af6a8bf51a9c4ea8e95e587640a8380425 Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Mon, 24 Jun 2024 19:25:16 +0300 Subject: [PATCH 20/27] Update README.md --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index b276779..c84d534 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,15 @@ Start the Q Cluster: ``` docker-compose up -d --build qcluster ``` +## Open issues +... +Some of the xlsx files do not open, an exception is thrown. +The list of problematic files is in the database in importer_filesnotingested table. +The error is "Failed to read workbook +.name should be but value is " +... +Another exception is trown with some files, seems to be caused by formula fields. +Error is: "תעודות התחייבות ממשלתיות-R25 ++++Code 300. The number of operands is more than available in stack for function "+". Formula: C13+C15++C16+C17+C18+C19+C20+C21". +... +השלב הבא מבחינתי הוא פיתוח ממשק משתמש לניהול הנתונים - זה לא הממשק העקרי לשימוש של המידע אלא ממשק ניהולי לבדיקת המידע. From 9244e08174f927bdc472b62966c14ecfa938ec7b Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Mon, 24 Jun 2024 19:26:35 +0300 Subject: [PATCH 21/27] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c84d534..975fca5 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,12 @@ Start the Q Cluster: docker-compose up -d --build qcluster ``` ## Open issues -... + Some of the xlsx files do not open, an exception is thrown. The list of problematic files is in the database in importer_filesnotingested table. The error is "Failed to read workbook .name should be but value is " -... +
Another exception is trown with some files, seems to be caused by formula fields. Error is: "תעודות התחייבות ממשלתיות-R25 +++Code 300. The number of operands is more than available in stack for function "+". Formula: C13+C15++C16+C17+C18+C19+C20+C21". From d9b917154d4af7615e95719bf2f651ea7fbc15e1 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Tue, 2 Jul 2024 10:39:44 +0300 Subject: [PATCH 22/27] add basic UI to view ingested data --- djang/djang/settings.py | 2 +- djang/djang/urls.py | 6 ++++++ djang/importer/admin.py | 8 ++++++++ djang/importer/views.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/djang/djang/settings.py b/djang/djang/settings.py index 02a287a..df73c29 100644 --- a/djang/djang/settings.py +++ b/djang/djang/settings.py @@ -54,7 +54,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "importer", + "importer.apps.ImporterConfig", ] MIDDLEWARE = [ diff --git a/djang/djang/urls.py b/djang/djang/urls.py index 0921013..2d96bf8 100644 --- a/djang/djang/urls.py +++ b/djang/djang/urls.py @@ -16,7 +16,13 @@ """ from django.contrib import admin from django.urls import path +from importer import views urlpatterns = [ path("admin/", admin.site.urls), + path("", views.companies, name="חברות"), + path("/", views.kupot, name="קופות"), + path("duchot//", views.duchot, name="דוחות"), + path("tabs//", views.tabs, name="טאבים"), + path("details//", views.details, name="") ] diff --git a/djang/importer/admin.py b/djang/importer/admin.py index 8c38f3f..bff7df9 100644 --- a/djang/importer/admin.py +++ b/djang/importer/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin # Register your models here. + +from .models import Kupot +from .models import Reports +from .models import AssetDetails + +admin.site.register(Kupot) +admin.site.register(Reports) +admin.site.register(AssetDetails) \ No newline at end of file diff --git a/djang/importer/views.py b/djang/importer/views.py index 91ea44a..3b4c0c0 100644 --- a/djang/importer/views.py +++ b/djang/importer/views.py @@ -1,3 +1,38 @@ from django.shortcuts import render +from importer import models +from django.http import HttpResponse +root="/" # Create your views here. +def companies(request): + output = "רשימת החברות:
" + return HttpResponse(output) + +def kupot(request, company_name): + output = "רשימת מסלולים ל"+company_name+":

" + return HttpResponse(output) + +def duchot(request, kupa_id, kupa): + output = "רשימת דוח\"ות ל"+str(kupa)+":

" + return HttpResponse(output) + +def tabs(request, report_id, report_date): + output = "רשימת טאבים:

" + return HttpResponse(output) \ No newline at end of file From 7ff93381161ef342fbfb54842d717c4e898951ea Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:53:44 +0300 Subject: [PATCH 23/27] erd-file --- importer-erd1.png | Bin 0 -> 116450 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 importer-erd1.png diff --git a/importer-erd1.png b/importer-erd1.png new file mode 100644 index 0000000000000000000000000000000000000000..8a2830d1a4cfa879f87eba5efea97dae19018c33 GIT binary patch literal 116450 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

(CFW<_)kmeQTeTyVI);V=?HFZ_gpEtbR3* zd;OXa=6`#pqe;*dTqcBZ?ig}N6)u_S!{}W~a~`-KujbNM9xQk&u610OaB?m%xEtc< zi@oeX#f%va`cE2JcJ za=ozCY*PMFv;>?%OWLCvM`>HfD-)LU+|b#Eye^qWj>m^Wd$txfx>^VOyd4I2rb$V+ zIYRGuys@}C+M^|8tL$pEW8ZOu5++_ITCGUw$SVG`OaGqo-(C7@_qDK#j^$%8(i=m4 z!t{M^66MGv|7ZC;_cX?apFbUYw(V;3_3I0q7)@Yz?)YD(YrkD^;oht+hq&b8z1gZgg)1w3gT;{)6<}oy?ullmlBxALKOe`#fR#sME z|3dn`>gwtyGr!lbU*A&8a)xh4s6>bdP>TX48p7r(3tu@GQij{wpHlFJ@G!*M*yEGRllKjcA`Rl4uq(TXbrRxUBOpJzGUap}?7vfESaRIuaSJ zBm65#BzqPwbJj%%I&SOX$LH2sNb4eD1A)=N5FYvhqxYDn&GAJ=k%%37bBUw_)81R` z$OgC)gv-;XPKmp@2?HU+TKi8N$ueI4O-)UT4P(x)8gY)4NJ@aCwr~B~?%KyQh^?%-k#UT+2P>t?iQjqW*)TTIXGD-72dC4=*D6 zs)AB;N=;WY@#hd|7uR1h{iE2qo{Do#`p(fFp#Z+37R!8OW~ zyi580OH0t{$}O_5QB_IHRNJj?wz$P>00qqbHs$&W;FhiRLv{i8M z>97$O9}*gTk7GvvXlP!8$${aIeKm*tZ_JagI~1~KOy#C{b5qb0gp1FraLyb!`yP;; zt0V9!H*DN6>CL&9flFFwtJ2Be?f&s;nqHWa(wM%Ko0@zhEAY`Gd-$}rIH0uF1f3pn zlgin2=t+0QLF87LS{kpGh>3P?VFW8BjN2m|0?-|qnrY4MfDt~y!~qa+Xiex-08Plm z8>eX(wi~+wIppNx0%mh46d=p;h6i3_V_SkV)@N^pVT3`~C6TaoxI9>}L?7wM>Yq*G z%#OGjHI5Vb*+KL}J;^rw1+EJ?W>myxR1^=c(5VH$T$Cpj_8pso>S|a_wQzUw#jEbU z%uWDGo+4hAmoS}$EpsepoGAStS_YvPFyTso>cGYZzqHzdStN217jA%2L_a{$H!?9n z6fWeX;L#0Y$Y>biNI~TO|DdHvr8{Egdyr8d;g#=aLPJBX!Cr|JV?um)yfDA53eJi+ zL&4#Nks5l^g>IYFXalSLurud6srH)_@lOXe^hWI}B4uGBF08dFo#bX8yO3a=;8jX|m#%|bL6&QI7d%NjZX1L42Tr7L-N?Phju7M7R z^E7i2q|XRKVxJm4-{>{@)jh0ASi@@n-vPlrKE?E8^sd{!$1r!SC4QdABZhQ4^N-$g z1u0!afjjdLPdgS~b`yKKJAv`+nPNCM&#xK~7bcV@G5{yDac)~cKT91kegcx|*!*aB z;qQO~(VlJ3@2r^#<1YT`1pu;7oAkx=EnBTHUc!am7wlqe_~Q#`D;d1Fu=~k2Fzlyv zPpRr)clpX7wPgV%?WK-Q^l{z2`@lLu6>9T`p3yH+<(QP@_^Z?850B`}Kj71JE#jcH z5}R9`tvOg^o~UGOmSQ3^7(BncZsxKtw*Dlq(t0$rS0UZ0d`SI!NZS+bxh1JC-ZVgl z_A;?aGtU*TX5{_+)#p7*@a+kWODkwB^MM27jpn*r8%b($a2N-lSi zc`-yPd0t;v_b|wERnKh&?^gUubOr!io&xQ!=_|IiKbtC~H??JR2P#)q%^c#AhgV1f zA9M|G(@5KStKAjC%2qNLeiK=%=Rj_H`Rt-}l#WL z{qLwI%>T<&lWR!tr>h%agoer(2qsvtt{|!_F91D)D+getnVG2Q0<>I6|~F!N1UE0S1W;!#N{o~j#e&f9CJF>h7h zP_Sw*KKx0idqfZO3ne(eCQit4e)8j7^(76|*PiI2TJ7DFnBiT7Nms zcy6Sh)P?z13jB<+xbkU>88o!Ff76AiwX7^}{6C|Wa9|$m--%Jt8#~ZY-d0cmtSlfG z#hWJcs6EsL0`cYxLd3fcNK6@wV_Yr5RY0x$0AUP94e}g&{{zh7G0E=H82m3nnLq0& zJ)P|*nBxPwlLF>E`zhunETm}^R5~-)v(Icf7yfLk4S^y!(_f?P|2Qak$H9!-z5d}(r9#bq}shYY&H=)whf$UsO& z{M8M3jDa^uCoubp4?IP57bykB4|p2E_Tdu(lE3VEVl$cuIp1qj9hK@3SNe{+#AaPV z8U9o{^>pw^m$T{`VS#>>a0$ zP6$?|4$)9C*5HtzHLvJeTM74B%!T%OVben2Df?G%Hw@5t^(Mq-XLG?26G>qSMlB0q zYgJQ|Jkt{%&wV#_P0dj-*#C^R1MaNAc?}sQ3a_C1Si5G2b5mSWGH`%I?6bqGkpoKo zK=Q@Bt-^#8jw(FI#B7PS9aV-2tEA)QbPTNrU?cz!9;i|PmfMt4U728Zn^DQFot@Pt zPr?W4I`nsS=!Z8Z1g@O3wV)Wl%{$?oBCCs&OP@NcyP*?3`@&CoV0{*?%_d#5VQVy) z{1CIN754gz;-`BC@O%lxHb1|xkgJSA(Xz3Tpzf-uJO_aQ8N3yg`Sju;H-%h`;5p=J z1t6D2y)EG!7lwwJ-vi5!$W)J3PbA9^1GA-2JpT055#WKVz>=OI4~LH*Bp{*2_Lbh( z@dp8lo}2srQZ%@c`JGo29C9c~+s2;JXZre1XYn;O5Tn>XY7so<4ym+PW>t`Pkdla? z5J~If5Wk*Ks%`Rrczg4BEZ4Se_(CBODiI-tMv5|rQYj(xJZ7j6k)a|XLrN%`kW6Ks zD)T&qs6-huQ;EpXAZ7fvL+gH?=Y5~&e!uto=Ucz^$6C0q>%7kMIQC=Tw{6?E-O+?^ z8A^PPy`t{6!TSpheY3qxYN8(I9rbEn&i&eI__Uy1YkOC&a_r?~0{WYKEehr1!3^DMxYpJG&_>4|uAXDJIR)~E z-gHTha}UYy6PjaTd*?p8VZ#P>P0cHjk(}LSo?N&COnHOD!+{V+Q7E9j<1|yU9eSjn z*VT=-ygR+O>GWQhKm@?Jsho0c_(>J3K6sJEA7^&i>m^wS_edH5`f+&At5F}8wHL7)NtiuDl}fj%*+h1 zhY8%;0ca(_WxHICxIqMh@{2dn)%dk5pSeBDtS;BCJsW-bE~AO^xlfS6fw4x@F)ai% zz9l95E}_xSz5GILUERl*yUP81eQD4Ma~&Fl&FHU1v7(-4$DaRi-$hYB78V6_sP;N7 zW!%07A|~@mNU#eC2#`eps=!=*_I0baOu)~Y_ZC~j>nD+0%{#5&q3BH{vqYH$sy$u# zL@Jq1(JlWB+o-YFMf*i%g)yGKXO;6E;WN~f7H5}mo%6ll&fNNOw&Uq?(s+Z-PG@Fj zhK}htaP!ixXU?42c<&{YdVN+N>(;F!9WlV{me^`BIVP#l;9w>z(e+Tq{`|$Zc5Ovx zC#PtHN-@W5hEe{{k=9*lY4ULCfRJFc1le{lAh7rF=%QQ2D(fam4O!4I&#!g4@;uB} zi~3bg2wdKtQdJi8cx$TmM;!?P8W;HqcL`&j@Ycz)`#XK3R;^kUke0Rs8PpTIc407I z76|6ZxekGZ16v&ks2*f34L~S=&6Jjiho8QFy@A9Gkp4A#N^kV(T}MByeKC~4_>|`myFEes06KXf zT%d!7sDd;mLskfIn&SQIn$@w#tGB2<^n)Fdg@pyWPeMWAZ4wb-b#rqg9N~D^g74|{ z=+8fE?ptsmVMkZdK!S(jig}*KwYu@CioVIOqTY-DTJeHE<@Kc%?YsxX%98Fhy6aDe zDXE4QK$LJ0JtLrqa)Pq@rsou5OG=DS$!?__J$CG^-FfsDA5~F#4x_K8PkR+0E<@u1 zJ%I39m`B|V3!`4SGDvXN^2Noq49jt*+`N6;51OPnlj6=>#aP|%Q7%JSMAdRU;^)-V zVO!f&H=o%ecMu|-Hen3@)KpYd5NuR8+}R=Z_IhA)FY}5`Ma<8wRy?;7VR`N`D{{W9 zK=VO?hG4I3*si?KY}eAxrydUe{H^S~x(xkbzTQFWscF}2k>B^#)Z@lxDj>|ejMw6x=YaP;`GDBzEVbM*2}ra2`X9MetS1Ra|K-QC?T4ds76b7^R5Zn6?J2p^Le zaxHgb91D6y(XhGsByq2Io8e zaNjmR7K_R&%d0v&iz+P%SqqIi^R4J;A;iV!;Dru@$IF?v16;9_bkj8#pFhjW%exE4 zK3><*?h*)Pyv=C0$3v{k6*4k1L?hxfJ#ONgS$XpZnx2JhyNZ?_v; zJn4A!+cueVp8{|4EA2Eb(eEyDRW>eiQ3Bo+gu^!wtpIkT(Q=-K`UVCofHpk=B1O8^ zZr#4k56pH`;_>JPkKjNaSEW=JXX>tVJW=JHjbQ||16?wumGW+RM1#D5C)cM-IEytZ_ zEq01PGt?BKf3c;pH}!dFep>dI^21xVY7IChuY5H)$6+6conrx3S|8FQG$IZR4Yhv~ zADhdQ=z7^yLRn@ZNvs7ApFG9*8=Nh?2a=p@l=JA|wjvZm;GF`sZ%HzWeZD2=<=Ew+}V1 zrG>X|XMn_@Ez{orlB1K;fph1cn#R*o zAdUBdLCt!g)aa&fqnYYxX;}+!$ottdXWtN+l=^P~^*JO_c~VqF9jhPmxYmN!)CGPY*X3 zgOZ6;p&oCDd6#WcF=uMZ56XV1q{?8KDcI${@Mq{7>VFA?ENB;gqks0|8dlbM<~90< zn3Knq_PN^0TP+$=P53!?&Kraq4_Ked%-9mnfB&UGw~kHZlpl}f?S~IDRTagqMn`ia zWw`i5J!&;q6O)b@wNH$t1Jt%u^5I-qOF%`JQxB_U2WUqw}mdj1VI?`g!v! z-Q-VcwmRNJpAAz*I=L9bjDTa0tFW@NEo!dQj&{p%j%e7Vl+E>uEc5n);~vp)WPiQ7 zE>u}bhp9|j??zk*_vFX1?XSM}UF-V_)L_9}#hg`^V&f{F_*2W`hgVRJb?=HH^*MIt z_xI~_>UXk^KUURE5u>)=&UEHOBZo@}Sa9EU?DMj+XJQf|GDJ_z588O~Iu*ea3cOsD zilGFL{^4EL@7>BcoMWCI3090P=VtF0>zo)0TZS&lIoM&aD8wcv(!#xUO-Tvo!a-kf zQhPaH9}Eszt>~s zGB$tz;ZV}i`-`H<2{^vfk+I*-q%8iLjaAfYZ)Y^r7ghTxZBl2D`yD}4AFF_ zr>C>5UHiy0RZGj0ftmRT1PG87<#<`^ZDpKwzt&d*TTNF3^kr+5H~QcWxaK)pMGaNU6cBi)Upt#QmTdz0rei2_-Kg zP+Ojcu5JJ{jVpmK`QcfjZVAO3KzWHGlykrRcUZt`On&=Ti7ZUR#3UGvQXG&n)Zi^# z*Kyh>tD(v9ctT)MP&JGaIp?esrO=Og9S;U37ONo=I0z*iikw*8v?-oA)t1!N0+d=Q zBPvSBr@q6dR!S*o3R|R{WtYskm!RySMf-*MCIS`GR&;Q5SD>Fl1Vjc@j3=Pk})NvS^N@HVVm$H0`lUO5@zZ@<#oCE_3yY*fxnWg74iGZvC?dHi; zp7DoAOE1wC9=^lT)Iq&!Om$$W>7m)y&}%Ss+q>fy`iZ=D3T6iyHjSlny4;ZQmFjiV zH{9i;BQbK!FgjTIajSmM{!PlLvRYGhP`IWa{{vUN6E-&2U<$XYt*wn>8#C^9kAXc_!T^|iIMVf>Z#^m#w9G#slse?Li4D{H>(Q!Pi7YuA&*lF8`@_KxOWUC{zdAWNsW^?igX?yMt%KPOfdCwG6_A-wtoFSx zope?bTeqn0J$SIVDz0?fK53`ajgWt7tU2Uj7FfSJu-?|iEIK`EkXppRV#vQsHSc`Z z@|El8mBMgj_#eNhqjP6B+8qd@D0315)+%7C=$NzXqHIL|;Z$Dv3R9r}rwvsRu%gs+N_Rm6b)- z*4YaA;oig}%A5VRW@-mXFq^QpkLw0ahqC1~c+llOOE9Y0Aug_qyfZh~HEl$~@^nR7 z@AOl!U?Ph_o>kjs%oOrQ{B~da6MEtMJKpE*i=}b%ooSiPfqY$!L1r&?J}hS5`!+=J zTE#LUw#ili)l0Jfg)I->$z9Ao-V>NUqqhCb4V0$6hMHjf)Kj$e^BuT>r?~!tT{Q~% zwe0NG-6g*sF!(E+z2yo&M@j`sB*)ob_V^{-NOUIVP~YGI9Z*$8MP-oSX$!^b;pFsm zzl4N^z`aL~95F6%^pRMXtF)TJwP!tyg+ihRZ7+01SR#kBGTv3)N9!8kpRz(lYG+Wg z0q*T?_c7B4cVx_)UNhdvqJZqYyZ{x!Y9v6= zvwx)>Z83YF_w?y%T(7j&1fVP0Sn*rdmXdAD6 zHTUqZb?5wkK2_8SuILwD_x`5VvO9|A_bojn4RU>H+8Z{BzIgk|<-r}P{*Iuoxa2?f zCJCmG+57d~m_}7i51hHVlDgRF%9j)9nQFVb0#Q2Ky&$1iT9--IgoPp=Jnr+j%*3%@ zbKAm)jue9-jje0x>FM`_?t>9y3b&1`JhaL8#q937c_3O+uPYuR3a;Ay35YSMnashuy9Go@RJjQ;Y}I7+dj)# z(*;v37M#~mSKRHkP#L!L-4c1>!gJnDf@!VK&)?;|pDWF!AK4g{5nMYt%9Y6R(=Oon z_hYj^auTVj1hzc+Qhu!DbL|vc)XY~HfLYxDa5ck?dt>MJJ+%>*F1xK>un@gX^!Z+& z@bEQ&L7dR{1HH8+B;4hm`}MZ6AfiDOhW^e0#CAkn8t9tSxKpNJY;A#a<^puKB5+tw z*4|DC7A#pn6nm4^pg?9(*ogB89xpdv4iZyMp5Yn7b|Vjl>B};NP+*w2SugA1a(>H8y*PoD@wM-s1H~z*1eJ;m~kKP z3|zdV)&IsD`tRJfH!m2eZaMv!oUFjX-GuIW5GwSDR+aFoK|{hd*nAGRi0#=^4L#$n zu&^sPZ?eFPfwb1>mt88%YlQLF&B#awzGbjt(}BfE9E>P`FU(F1eEDLd9#b2_9fZy* zi@}CF=&!(Y?xQj519S|0u4o=!Sf#6*tZl~ukHC1{W2-;N$$zCo`B0vRPhI+hM#jqxNMc_r-}O%f-_4t1j%NKSJa5Y^s?l zBD3$Fy?F99`0PO`+33K)WU&wQ5b?+B9uib?%-1oGD5sIm39oE44ha=KyOA%8BR|cS zW25_-42~YUE!=Y&+uoV&dU|kZ{JqvY8f25FVzD$0w`KaE^P%NfVb}J4hcxed(NZm( zKBzS=6rMezAK9=w_L>t2sJbFC9Kd9wVOnSP`Jaq3&gXMx&(j5_o)&_#49v$(`Mx9mzxc2&Rw zqXH8h-$2WDjYcuAVUeeoE-;_j&`wvAN%g*ER4iR1q`Ko*DXYX&I=P?D=HBFrMM413 zUTsTK;{_-v%5unS_X=-*swlAy>r#F{bz@HpCzW$ZL%g`dgJ*2D zPLJ4FSv8`n)zI#K$X;D0!q33eZTPKe$kxTo-_yIS@4HNjX7*|fTykQg!CxFYl)->X z1b=#8C9J<$$%nN^+Po_!`sS3K)GAZq!wqi)Bz4tK=FAfaj$i2tzY@53ho_g}WtO*q z`u|TYiW$~i5fs-}u|;(*Yglu269re-PUp~49+{TW?1@;>EB7FD+x%@jyV%k%)$=B4 zQCbzUs-TV<8O@Vty;ggRg5E-m;U5@~l)kF3MVF&Zg_)SFb#F;k(+^<#rzT zkG{53L!yHZct6*-$v)zImDJkB8XEDFqV}T7ejYaAI)G~~SBP2NY3*wg@Up zsr%FiF7*5y`gtE&N%D#fZ2J${8<;(&rriAVbO#nkzFM!hN79z;fv-Yyw?DVet}E_|_SJ-#@*gMxQux*TItc1a5KD1`!@>E!pBj7HEVlL#&!ke`sc6s<*l zN0E-ki-(YJwCPHO3jS4S`2DazBQropX8qeA6Ce5?YaUvtry8?bJua~|EpC~PC-=w3 ze$PJPPJ0wuXV}g?XFPvKSHArBhm{2VDUL=ZvSN)OvFg0EhWM;>?nWLM7>3e6N>@EEh*u@7N^1uD3fqG`>whX zx8JrJX9%9g)jV=k?yZQ#3;eX;5LGj(Fxia$NN zL&?X_I5Lq1_X(rHjJfY}zDhi)#L#e{N}f+%%5g|J*hC>fVI8m8*u)!h zO`GgV8X({Q@fz%)e|^dg&ZUc3`Vgy@rTe_}A)69vm)>USHS5KeR_edKtoW;dJVHow zsZd)lw(G#);MCP=m~;fb(BHkZ1b^PVA>j0U=^b-*&IK$*%qulpk~HGlD%y-gsw8@- zC~rr;c6te{h?JVob-gp%7+;{tH*W4`n1W;+y|UH&Z^)ZK;(f1$OP0>TU(n# z0gFI`{w%cYuL0HOqnUC@L4g|mm7%~n_#`FuyA&b1AvG3eDv(~F_vK#lcnh#x0RWpR zx%Fv~y;RoK(UBz%j9zZxw8Eo0ry9^5GVNN8_p%QeF>d z_m8D$Z-)Ur10c-SZ87p*ayx~DzF~?-CCC{H@$u;{Ay^!%bUjC|VKBY|BbPWMh0vM= zT7;tRK3)S_@(D0bNlD3m=w48Mt*301l;prP08)-UL5?LjYlReFDXjm~ryI9rgD$m)iq|4n=)LqCMB z+0=;-+7iYOMiTYpKe|haX5QQUL-BC+n9^#P!K7&E0u%X%&x{I8fjjFJAgZ=UY+KR! z`L=@xaux0EY?prz&>?yF0LKPmrSZ{fY7Fa$u)cT`e!>BImF9kG)jCcX>=lW zMPJys-H(c*p{!-_@7*+-mzPKJMsw`|b_l4- z;m+K&-ap*`!EnB?da5}4GZr|WI)HsteWjilGza?eikk;mh~KE>zadJ)h=La|2HpUh zb9^Y|H0Td9@iyOjkJigD^2H*TZ=T?4TH_&`Dc zvWMNkoW_13b1=8HWxy^3oQQ_jjWCKMcoxj)C@mo&M9v9*ifQB?)?sQ|nyaFn*x@jK zu<)D2#B_=&9zBJq&T_Su`Ln?8pQThOjKxe)=)K6ko{DqSfwt}?l!=%Hd{e(Dq*x?Fl$NJtU-*KT<`az^D#((})Y>ug?@tZ9t< zy^AMXB-|$4U{kkpRJx|!vIcF=_s^qkw4RV=PVfnhQCD#Pl9&HY&17?e-p!nNH_Rh# zy{UU8>Hvy&xQyVixk^U)bL z!f-;cfHgeWzxAj{aUE2-hD?t&>$%86OJ1>*?=^3BE>5LIjbR>=&waN|*}OHlC`|X% zM@<_s-M%fia?w@}E!+kG^v?lC&+MO9Piw^$)FKWb61{DB`LY^XsNIkkLCCNfCn8)N zrR)CJ~d>_lDi>M)t(&M|-9%fG282szpKJx1-&Lzsj#3Dp)A z1&6onY#ECjO#UGob5(n|cgr$e#{WmW2V|DS_h3$AaW}P8aY@(Ck$-t)` z^~qKR+X+VMM5t}w!rU$Lb;(ipWMUNV7IL@E9Fp*nHAfr*SjbD$FtCT)-sDK?T+6e!Xk&)h) zRaMk|mwqt81{YY1=%=e8!4MKC#5D;vjMyb39|;Wn`L9B$wVIm`Y}c+`GS1OsMeGzJ)nZSYs!sCh z$#LcS`0=AJXchwE0X&19dZAk%208lRP(TZl%}ao(XuZ2e+jZ>d(WZ`GG<2iP0Vz!f zZPd$`Q*8-o_IDqCe>7|vlrxagjokhV35H8BV;$V0`dfGPMPozG)jSJ7;7Z5H;f)?A zxI1x@xldq)XelID(SVs!IfMfcZO|^j0@?zf3aLYz-NC=d5_lET)7tAfZGu)QDl{!! z0YAa7q=05mV9ic2{06qZSww^tx+(ixqrq_Eg^jK^P9KA z^S7!{eDm(z8XU?P&1kfH0wmWP{xKRKG09=Hp$8K1wlx|r;?TrH%j3YU#K#6a} zCt>_i<@dptxvp}vU-!VYoDU~FPPtk%je-ApxbDm1zen3(02%m#682w;zr_qQD1VyX z3!&sd8dVLQ>YvZwE+gj0>t0_9D&)Ba{!dk%?K>DL7+U==sFY;|fe3i0q=??{P)iQj zqu8fU!+c1YI78$3e_xxC+l7+R_<3~l3PfsJpJ9k43zbDj zzkrFTG!XR*=<(eRr;bao&V=KjpRw>&JQ6f0*HW;!SY=QC-lB}x*s`vRd7$WTNMqzI z#n9(MH`(R88&%ZF<6ibAgGJ9kiBuaqT=^dH(A}QQYsIRnh2u{E8Cuo(9kdH=ByQBO z$n&Ld^0R3qOpoH2rjRkcOpovR+4}U>4!i>mWqS)}X%TTDhbn`Lf>it}zmjy;j21mt z_+N&2>z!lB;K(@wH!iz2G4dQyI=+A%GA0_BpEyCaWy==m;NbVGXtWDD7mQ5w2?|>I z>h1oo>0shI?T{1FU$E%I8{_3N*JB{eo0!a5&*`*Qu*pS|S5_S?r=O;oSG6iLl2clmaJz|D+lq3v-$*>3euq0y64WS+uJe9L^b>peOiGTD{Xk0&{ zd$idh4U7cH2_hPC`t#iy{%*F@crIV~v!U)levs7T0D%Dn08J>Yp@PM90lRjV;&X!y zn+wrq14<|W=gtis=Wsm=OL)yEHnAX#Z}nqAm#NtG1^AaK(8o=$l+NX0Qf0fXd+MNJ zG*jWVM_S`i7kJ994T)SBEnFw#g~9;EBotY6{t9tFBGH8p?J8&%CWkjTt{`$udIfzA zvX;&<@SX4_Tz((fl%}TU%{zDeQI=9u&_3l7GS(DFgA*%B3po-k4$@LlO6QN9pMO`b z_y(WDR_T#ZB}Ji7xgmY20*Zh=e?w?JQ*V^LvdMSb>E93OmHrr<_4@Uwj6#{(ujn#txmt%mW^U zuVRa`O1?877X^UQW}BrK#t&O4R@>aj6+1Uy(@=hpCDa{dbqPid{*Y=`pw1=!RCw6x z#>O|$>atm0o)_8KndU5CxW_{D`w@dl55T91a6=H?=8gRQs^a*nxtaljS>Bzj)G91D zMLv`&gknMf|*!2O%Bx`Y;#wRBq1!a2x zwlRh;2G0T9;zRHk%rq|C1VHyN3RF`2ANP=7oO?#k#KeYxxdy^vb^u;$fDf=ih4D_t z3l;DoG@c@WvYa39WYtS}!_~gmNz|hdMlcnfA z4g2*TcKFvJ8omZJng}67c;$uvQ4M>Z#W3w{-!QTK8o-=u&?8^ic?3`>CTh&e+@6;DBF0P384GaV*uoqypR@%>hKc!IqI;72f>k{-WyVh}N= zl2OCf1fEDV@5?-8h>j5wVoH(B+e;BYxA?Ja+P?jmQ67pN z{0c{KbIRzKFWYb~)!-DIni0dmhf=$n^;Qb>e0gU&;`4V&OB*5;Cg%Oo-!Vo&5v_Ri z5h`s~v&^$IFdz~1V*z}iX5=>5zy{f@69zb@SPoD1>{3K}$f`h(7Lin~UPS9sR(&BS zxr3HgFX(q3v{)8W@gb1?!#otKhwLL)Xk9p@7-a(e;6FN4#Z$95oWVg8_{(%jh&tc9 ztf281P70@is^tinwl7UD|FK5tGk{&uWcPniqjnqP%FE~1p*LZ;HLd+%?%BX14J+s) zo9+_fdQHu8tdn4#Q%9peo0=Vj1%)3JP^=J-l}~uQ*BsijS218Wzv7|SPX7gzAe88) zO`EGw(bd}iORjmI*P*h%Hqo{9PSpLq(LWkdF%CB;oLJ&L04ZL<@}gZMKqNNUZ#VE& zHt*Om>N9i3*7iEa?NxMi6jnM@yrFt*jFIn)Kl=ub(pO+py$;hD6d+2?Rg#SOnqM;o zBqsGAP5$m{5>|wz37qPpR(|kDEvH=yL zP21ijzulRSU`DK7^-ejsn%u3oN=`}fVWAG=KM?jvaSfmAiok!PnhYC0VUOAaYG#bh zWPtx|Aly=k0W-;kw^gc0O z3+r*BzK7U#b9pU-v;~^lVOv-U&7Ev2QQ;R7F9;#ZoI^sowdnnb(Gj*gC4 zX(1<1AcuRTOPaRbW_(I)Z32M11PHb0SFEu4{`;zIU-k232knPCk%8mS^_tio*U z>mX)`+d+ZLm(?f*h-e!!v-OwChz1i0PYwDdys>-+F%41_Izd4}RpHbf6oUfcm6G2DY7!-#;lu zTasjh-Sh(0u98XW^o$JHQF>!$(ne9yrv|Q(&Yr%a~}E<^?42A zRt{MAgr%h~ju_fUcb??sDFWPq{FdX!%)+#l8aPdTj^CcQp8VJ2IKGZCJcJahhgv!v zSCEV%tC+MAHllQI{Z5&N2v za$&$9ws(ejcimW|n*?%N#VdCMdPrOhg@SC!5lB$y=lMQWfsjB9HGBJYdv_wDm z;ju+~w6HB5hb}X=M`vBL!UFx&VSn9V;akc}7T+PAGhGa1I>-a@Q}DG}04@lG>x z!duoZVYV%si2Om%lRc?u05oOk4658)PNf?nB(^ikT}?^ZhV~Sf214ZksG~`aPQ8z5 zk=XqQqOeTK*rA0zJuM zF?b}b@pe5BEOW?;UtcdrGXoHe-I(ow(+2<{Nnt2(;jH7jjo@JTS8PD2N}+7w6ak6m z=+w{uQQ%7XcI9Vhd#eb(+qOqc>@b1@g#d)8W{0~ApY=YOdikGymB4eN+ej7{DTgEj(C|XB>N!XZK>Z`$me=p#$0(GmQ4-ndLa=-0k;jyUG81(0KCm z60jG%gc1`DJL`WWEfaFaOFnj3cqSX|oJacVK09*Hz{u#JU^p2r59cH|U;B=(E}CRj zeoX74NjBL!Pps^L+<<3>rahfDR4m6cLWLGP8Y}(F*xBT-myy<9!R#6?^i;pQ3)JJ9 z1RcHy3^9&gp;k*}2BL@f)CO zb7L?M|Hck+u=`R=Us{*C7fFk5a6@vk-Uqg|cD$z^Mo0Hp&7)($ejfcrqTYuNO_A}Y z81Gku^D_t`ViSl{(vF3v*jS`bMjB%14BT6!!DHaDop) zN>59EOORy})$kS+GBK5&k0^B(XUi9>0aWw)cF}p`aD#N); ztFUFxg?`1`UvK8R2-vKGpvGuZ1axhV`3|I*BF(KQkoIEG+Pk)Y^rE0%uVK7Z92PHp zx)d--Hg|;}vJ&*K@?qj3N=JVcL0{4}x_b2_+L37RrThQkJV{kW7-ohGl z{==LS*#oAytVE5Qn^3~|4Ib7fU_=6_!~59dD}W#IKQz#fM7sx1ErHm{UUcjM`;J{5 zc3Rj}xJ5%pPyBJr>ZnDdM?&{>PbK^Zmp2uv@h>8;S=l@q_<#A__m$qAM@o?{_U5aB zSRr60*v9RSncimb9%vRrLto@^Hq@&`^zI#{9DD7#j|i`9{QXJD;DJe?yndSEmT zCaL&&RMw`u39&*Q61AO%!hkRW%`zFidQ@u<_9jN1fDSP28Tp^vobp~?p379zn&zt9M{bksVOOyAd+*tQ1HVGof;SFOB~fI z1vlA#tqwNEQSO!#F)D=y9t^OVFd9Prj)&DBrG_`RpWefBaDR*FNLy*=pBsC6WzR7Y zsx7-WGTn^nnEBx6&+)Obl>K80)i}&4-Z1N>A-fj!3Wp0cU;Ck2D|jIdnbhK&-ub%2 zI-loxy+XYiZ&rjbY>za*9iNhQSeW{ZY}-JSk}ac5>*X3+564*_3CKauO7Wr+p_gu@#fa~Vm{;%ALpk3)bCW9 z0nUAqmAs1bnsxUuaXZ^Z zNo%bIr?gtP{KmP)_X_ImWJ>iH*Gesm%F66 z$o=l2IsSSt&5a7`Ods{dyNxoerjo9^TgnBfsJ+wF$9Avn=u8)vyL5w!LOCt``xBip z&DGlTTj(i{!6nnL*JsS1N-^bfm#M^?oEC&t6+U@ySyr-cvX}C_n(>hE0@twD6tWIKFcW-2ZoH2 zeJumxyQs-qFg^*CY1+@;tI9%goKNpGi#Mqee_WThg#(}angvhB=SGUgGO7aqTaU_| zqH|^TCTs2JVL!*UbX|$}73lGXr@dsf6Xn0o9p_WwU53le<5>C}eY#`Y+0I4X6CEGf zzZ-8CDWYgjMe*ip%ResPot#wuIuW#Dl9vxK*vpI2KOf6g~9qTi-<)HT}XVrt2~AbM}EgPJSXIFa|?fo~j)Y9Z`VI9qGu^E*FQ-aqj``J~WJ zb-Xf6TVu>I%?PN4ET@?5ZA{bL@vMW)d*hlbO3U7PmUi=pt_9Ks}US216)C08KaZ^Ye?`#!_K6 zV+U3Xr{}p8jCf1};r;^P8H(uZp`q>22%3xlq*MkdiA#wBx(tumG_(MYqSxaVM)qI? z$=9|8Ff^GMdYFR2nL$ja6;0CBRl*HER+8ao|8!UKey27B$*HwK2gt77UouTR_rcZ~&6U zq|``xuQF8hX~>WaGITNG=#&?RIP3+jdHBc?I}ABTZTc2TTQY`3r~z}sxjm#9i9f~U zxdU*JF2c`sG&JJyP+Z5m4FKU>4-R&16A~6~gCp0}*Jp1Us(q)cvt?Z~!`wU;V~o%S+0%KtHT4dPlaVu)5J$|wgY0%>~O$5fCayY2- zKqMx2quceolKJb0vPR%4+fmgIj*X3dwM6d#aStG3eAl+zuCaz!uUgPuZZh-prP+ce z9I+q+xNDg2Af)7uGg(JVD*^Ss<1MP!FVc#yTc78MY?Zjbv?G`hXca|;b~?&6odZypK5|9a7R;*Nthn{Q+!lIgC^=&;GCZxoZPAerlHu% zEDhbbh~WZTwiM@*)}FEMLM`AYqKpUOG}1&ysnzdLDuTd|jhlPylEbr(9DqN;n6MgA z62>1?6OZ%`M`uxOgVkGTOnPQ??mZ$GF3@_sbgpl4-Yl3yM1jmS-9!_4tu*Njr_&Md z#3zn2SewFESoJG(lFu@n=y86tQkQQ$f5iLcWmo7tbWBV#Zr;49oF@j~p;FXv;N2c# z2e7fQY`j_>hul*~Tif_tA8?J1F*hOjA^=}@>iFy^`N>fdi*FNhvW_$2%jt?ky-C9@ zI)5H`x=C)B5{&4~Kbr;mC@nFuR%&@uUtgcKP^PqfXHN5*%fI%NLLL_>ZjXOE9|7hw zBr^d%P)FXnGGXU+)t}CtL_$hS(6V(+M_rYY&xaMAeqQ8N=*8zz62+e~6S4K;$!O(s z(H9%0g#EB>zyJKH4HIO6E>O_^7yy!vY+LFjnf*gMCAM)R*-ubj#e*vx$yl>?Ek7J( zB3_@2mUODJpZ#M^-e7XF3F$}o2-2EY9i|8OO|@tl;=M2!j7$NAcj)+AkQ@aS-X}kd z`98L7eS7fQi3M`DP}pnMD#Wrj@*cR;QqyNGn_hGzv@Lh@ihU}XgZUQ=%f?DdOU2N& zone@J41;en%Vnz_jSiWF0ruz~arf}RqG%Xgb@}8o0}K~vc>P)j`F1N9h#fIIC)>c--$ZlIf*$XR?orNYHHZHC-bRMWe zmfZ#ob4E*xb}+AeZ{Z^BiL(G4kHlT&d5c6l3mzmW9Ct`czPC0>Cs_zSl2gxG-+R&~nHO;%*+#3(i1-^)I1^R+Q zrIq-Cg4x`*dv}w;Sug-I{l3gr90916NkrL76zzHTTBu($hv!Du1ruO;T)cs?BIAo+ zu3`B|tK#MfMt+ia6zmLlTG+=&89y85wX5OsLQdu9L`X0t^BtI}FltdpWhpg@|bwYh;`nci17XXtwmX->$7LSOF@`2Dn{&PB8q1umoWoZ&ZmDyJFq!2(W{z(} z(9S*EXA0(5`tn=>oNH2%C+O(vHdu+)P^z1L}j_7egipgXwSa=*8R#%r6j) zdOh+kDHPdH#bHL{gI;rU9yM;0TIIhkFRdz7oO$~<1K~m%KVF}fq7mlO{C$Rqa6qmb zW}Ev+yM0Xtp>!GxmjprN75v*|WQ;J`EK4@8o@_x0HK%xehlS z05^dz*$OTNDDwGcIb?1-a4$ggw_<~FZQx34O@(KMCfS0xg;S^k?SQf{tXR>EImi4; zXr{4k&x{7zx(g>ghInGIHFn%_Sc`w3l*~6hZf*S-NRk*PvOa$FXc#bc6K0;zvZ`Xxs7hG5;3`YBdq|plOPSSrW29c-U%U@>pl~318qd1^0af zbuBq&oNk6n_td`Ffu*#Udo}J_tH2{+6$}5;vgzv~u;eE5DKW4@_SbvM#_s?rq!zh3 zIADJE2xQ$oPo(h~U}5Y4?0x_B>n^_lKR-YRl{d1}4~RX`v9LY$;dldYZ_3bun%p0& zQqP&5iOWgya|6tVd1oRJC7o-AexSe|Et?$==O;ma25t=Lpp0HdR6qghsLTT+>7#&~ zG|8f5=J~o4lRb{9skNg)V5gFMzYkptoRe_JMvxcT_LY_Lr>I7WlLLjgO5wFHF81R5 zCHZmmkSy$=$W&}3*aAMwjcL@*>`UL`uHO>!H|i~D>r4{H(9|}SM&0vGC&q#>+`)Alfdo{UvkLrnrhgQ zHH?Nx?Gq>BkV`)3!VMk@t3aBfZebDsqY&ExIdUsz1fTkTENs(eyLLVdgwN(r!DV55 zTQfwb-HjgbD2tSKO@%D#)5nk1HH>?2Whw8Ns+&DnFuD=XI&y=ZIh9^qnT=1P|8k_a zH&3l>U#4BIL)UV)m8xg9?Q*{7NZ4UKRxPYpHcU6pb7=8L&Nztq;c*A)^6g2sjr5*m z&Trpp>geh1;OGDJbjT8!E7a0RHD!ws^10a7Ky!I-o42OD=F5}Mq+;GYkkTUd6qvdg zk2L-Z$`HrQE>s%>Pbcj2dpWZQSb498V(1< zf#JLCB29jx@{6X$YMq~2s}7M!+w=$}_+Cwu?<3KVggKv#n>lo?;+x@XO zy?40_)XcCyyQw;W(;N)xR?1BXLgHtvXoVCO(P;;yC-8f2{Eo$E1KA6B1m|%wFyo($ zv+QdjiD9^FnA@a>)q{x{?`j)&Tjd&}?ldl+`}}FIjm-a)e{PYw6q|DCY{uOKBsXyk zMrn819D(ZkwQGO}ZgslJkMtHCoQ=snVmWKd41*Clxm3|waK)) z>nt25hdb*D3IJ6b=>mosiQW8Y^! zf5bz6Dd({ZSdLYeOAl}_DsoZAVEb(K>NQKX9Bf?%c#YD&VZ~0$ zJy(pti+KH6Sq>9eR;+EhG577q#GTcxinlThW(hFW&SSYiW!z?RzDEFK&NosdbEl@X zxp?Ii@w@&R^QF{llf38jP4`@p9Zg-n=H%x^{c1^A`;}#~c6y)Z`9``^1uF6L3fF3q z+BKQ45$A61*nZXUejuPSN+$0TfJS?9C?;v@WYw6h{pXLQ_R8G#_val>-P=8a4{XGb z9D9&tQ7HGORC@CxSvm~I=1P`w;!GK&vy}d@ud5DLD;T9{?SS5zLiv3Hi`s!2D=QM| zjhMY9tSA{68A}E%MKH=5IlYqxrCp=D%WTKKCL`1I?o~)jdFfZ|>=$&D#rvtpnAEVj zZ`r0Y;M2SG6y!M^HZR*7Hu16;5SO|guNs@$_1pXr>(;d_?2Y(zAu<11ru8?6JMU8m zKXxdzZ7Zec@s6r>_L9%^pT3%$8-XjN^k2m-h-HNyJj~^3d}l;KswGElp;cQIHC`s5 z1W+!&D9i;|J~@sFu7m;;Ysy1FP;dktm?Yq%drRBEU|V=SKmL98r(2@Fq+}lx$L{?Y zu=ss?S`YijZM^$S8yMZ%qyu;q-j8()dVV5+tf4@b)%9d_9NW7 z%skF$jY;pt1LDM}DN+oh%?{!23&{j=j3?m?!p7=sHbtg1g5fVnZ(~vR`3D5x-W)`? zNb%_yjJCQH78VZ+FI#vVGF?|b*lBY*Mjp8Vuo&VQguuOIJ& zUDA<|4b6}Q4vv|0jf2)(yKY?~W?6s1n42_y8xZ%9Hs4#dK{D3c+uOa1IOF5uV%N|g zNRi?We%#bOG9l^E8uC$hFece?+w}MEnt*Wcz)f(e*`d@UpEFD^uj5Q-nP)L7*FlwK zN@0Nd5E~Iu1eCym<1xL1w}E@}BTa6gPEH?dF{n}*s787GC!M8-#Z=wYx43YA{_81u zXJlAk2p|Ko?C2#^9i7KQMtNJMq&hNQqxmwsVRrlC zoE&Y;cLcKL*wu!O1`^Q_zW_g|%SiAOl4TG_d>eV;H)f(qh zOL_mEr5`sU@2dKFf&o(#LB7Z>jvzdT#~>dFg{;OYU=iTtOf`@ptu_9>tQ2%=h+;Z@eIo^mm*1cFpHJ0PR@5^nY%n3@K;E!!cpqn zxqp9aT5D(Q{iTcTuo6u{t1f|EjDN|yj@-syn1rCEDhVoV0$)Sf?dagCZd!oRu|BlbS9hZTP^+=5|e(26297bYZFqAE}jWag_) zcRPn--6NU`6QCZY=h>#hrLQ4{6-RVvm*I^$in{=er3IdR;6*0fC!kbo<|MkppvMDD^{Qk4d)QCvlTnsp;=+<#?BnJl4lX_H2$^a6Q zY~q$c>Ok_}{5HVWBSo#Rlm12Q0X0-2VUM7~8u4Y8yLo~Gf#e2JUt)$dQXJJ%_volp z4mLIdKBb{uAyULLVD*|cWUGS^Hq9}E-HoP$Lnf+~Brp=u7{7Lb4ohKB7Gisi}|0jQvlLZU|0|->yd|+=tT6p4Gs<2ft)us-%ryj$#^(- ziti$l0dKCb`qMAQ8ofWy3I3Tjp&d2K=B7+c86C*2Zt|KNj1^z_xys-bKg2N<$B!pS z_VK!VV2@jA_*z9Yu3&fVB#FRYOSh|W%@gbm?ct%|)hGo{-)z__thpr}_n&8Xm}yylYx59JAxoWJ19QhL2LkVZA!rQUz%ymP7*RAdb>iigb|fnazH2b`#GL#8&wVb>IXNB!%*^-gZ|}9JXMZT-o#gV-xO3@Dx=pO@dHEpOkQ1JvG-$L zu!6BtG%E0?XqoeW=Lkg^bROz^%ZDR^`4O5zEIjIAX!FDAh&@;d#|H>(`VXc*yYzwN zI+Du=wyB`8Q4ddVO}b5l&O(IdJwcK{MT)r&w%>CXE|>x`Vat`^DFk(?fy&zY+eI@j zfy>pt#_FHI_#6bY2cl7@=Z&y2MibC0P$`FGpmloLy4Wn#RaGnI zKA4wDBt^NIRSXIW9FM0<%o)k`U1V8^f_GLD(KFnP#fVSHiEK+6$;fb z?$&-pgtAdQ96EFv0qt|K#lt`1h>%oo!({CN3Py%6tfM+HqQPy0mPYVMylmAnK*E@%A@vl}K)~sK{AyGuX@pDCM;5Ax6#v=XI*> z#)SX_)Kwc`D*qX;>yfenOxyA$OLE1h=Rr!B0`I*6jV#ib6OEBbw{T`ybWInMdy4q{ zcttH#!AlD?GwoyF01~T1D!wFGt%Yov8C|Ku%U7<<5BmU(mK1nY+k#P{&DOtOI&M~z zRM15q+AdNS_n6>P<>5&K^=xX#0HdW}fhn}$y5ObIoqhrf#{+7LtV1Ay_E&Y*&O=wSMp{}!xvMP{J&62p+pRA@PwW4D`d_L%E~HWd zi@Z?{z+?U$&|QnJpTT9KBUAnELAuQtMzBO)SnR5)0On)>lh?ij2YCo}USMr}Xd?zo zVpau9u^Nm{EEY+tO=f)@&`#ncfVgnle`F=WJDSRcb?9N(D-0gD6)F**PQn_v7F0dv zwYqu&k?XqXJ&prI*QPwxxM_Q`C=iz~A>1Ze8w!4&1>||;4yB>W7=5#n#CggNu zO;1V*q~rs+lV%yg_(y9FwO|L+rrjB?Q*0;Ho7JY^DC~r29(t-nFbcu&10c=1x-X&H zOgr&}XLow55Zxb*IUav7teADvvH@e>5Lt@{BJGs~d`|%9qu?wYZa?7GVedMl%K;Tf z#a#nM_Qu5Owf@B~ows0B6$4oFmh1fyIA;W;VfWM3pPpj~^T4-r0Nzcurhccqi_{W!225?-p9#yMdGb(Xj-2aN|-(a&x!hk;JHJeT!Pu1>t~x z@&8weiywsJ2WVu*tIfdpO<)Qme?O8SlfWLZKWkne7MJyBw5&B-m!H2=BWKtbh&W+i z@?#(R#5(nujGTpt6rbxS=Xe)s*)P|=$Luo!yO@jzPY(oq`z)pF!(CC&FG{am6pb4A zCC@>8n9lvw$v6%@twtY3hl-7ITA|Hqy|ufu#@IzjgxgztPi!OwhQm?_yhETRH^%PG z5(Z6zRx|zQM>txrJkz!eK%2mCKiJAXg)rwr>=C0G^3=phuN3PMu&{#1JN!Ji4^h3CLJ7 zI%J%`%t2l3(3LoI9z1*q06a+9$mlk@pd=K&L@5I1V`1d&TA&8o7H%>lc(WhIHg&4L zNoqsr9w1-~)tvPYk8fNJ0t)Nx$pP2i+4YXCfskzk;vA`H<95MH&4v1K0HcxHX{{hB z=oXN}{m2Os>pUhUDY8{1L3Vm96kh8Jo*jEV7Z=K6E-DY#BcDY62nAGRVKh$MYL)P| zF!o_(WcCG2`*{}H5o(vF=apj1l015U@@G@0nu6TzsO+p$eefv)`pwM|h-TPS^I`(a_-{O@)!XSL5NCoQya;?28==t=PnRmdZGzG zb!c=+eG*^mK4H=oSS_Kc0d~$P+44Tdwa>p1QB!;Iku~Pb4p|LBn{9aEHeN-XI>+b9 zrP{y=#Y0NHJ(<(1Qjf@5W#fRRG6}In&;~A9a*rFIIlIb4J~?d9n6%uL@v3$;s#^D5 z|Hd=#bASEA>o=0dHS*>R9>02Z$HZxT0>7YPgIU){L`fM&Rzw@jg{}$x$U(?)Cx6bZ zPlZmdAj8eL^|wlKGwONWD)c3~RTWVs7+l_ZukUE$J6hT>TUecJI^QH7O610ylI}S_ z@Csu^*Qv`V^NF<19S1!1zWg|= z`FaB<nMf97LzKKA}9{DyfhXynDU zhSRsF(NVEA@&12DDkTv&hOizJJS0$%`9eW})w`ZScoUL>qoFSwdiRd_LQt`)SPV8} z|4`SnI)b-ravN}#5Z_8XGX`uy9)eT#WMn3|Cc@kQ04C$mnTukM>$C}JUch@Bf<&^U zVkE}j@^9cO_W!ppDl2M!gC{)aSH4(x9|J z`P1rn`**ct0W9i%o@>|d?X$@M_tap=_!2a-$368_Wnan=|7q^vaKkP^46qfIkWfJa zh{Ngwd-faz^`ux0NfJdJfls7;uDS0@Y3DbMJhl zu7BHL+UzsDmG$8BwM}a(q|e>K;qsZ@ruVTjO?Mm5VUTxVr+T0zFfOMSUkypjHpStx z1`00#ba07qjL&;M>+BSTzKgvPuZ%XGQHJ$&Jh2)q&8rkkY?C-1@;>9!of<5Z3L&kJ zwp<#odEc6dmkhbw!8+I@TA=#ZB?rdF?4vVID}T~e61aHcpJnCPiocz6uw7+l~d%T$=@r^be-_lBSnAy zfY`MlAD?D*F8W&38MK%eYE{k`@nHby(#~>9rq0OY{$dC>s9i)!lKz#u>8o3}3Y zC5&30S~b3;p(AdZasoxdKMS@paACk!LF+Y)AiV(`mwI;Z;cUG}N{kCaPJ3vs0u)X3 zP_OtTheCKZUntNbZd8f0j{2OM_BZLRK(2rcf8PrffZ}k35#G z&~C__dL07PydQ$m4(XgBPZV-<=FflRF-doa9$Vd{@0mpJu{ZW~%SH0Ur{Cw(eU3Be zyXu9qGhVB!sH=x#U3v=2s^iU1o{Tf-O#Mpp;gHuqk9OwAxDYe6CkD{OH>jg%Y6{dB zYgVn=gAk=Ebiq&r5*s^vNt3`AM(sC(%pdc1=XcWBoyDMZ`Ii$YZOZ7N()EU+R^u$0 z@#Voc3T(&SW9<9a{S|<)KA&tAs2MHTQU7qaL+Zd%+JXjL&YE{}Dqqu7)h_Xh#AGdVmv`$I;5VnuCxGYmq(TtN>A^t<=Qs!vlqGgi z;q65HHJvBfzhnsu9XmDIT&zClx#Nyvio@ZtAW>CBU5^{P*`2^hmM-Y(44_=nZ+5kM>{}9yd zSvs{zJ?U7_>bZf*ovAJhc=u9r@^W+@w-paF$_g%;7(S2kIy%3Vr8RAuD$XWhHC5TV zj=g1&c4GnE1{%2criDR}qyqZQ(PLG3=T- zW7+_>x!#qhCvcx~3>wtW?FvNoO|G2oH8Hs&%uM=lEJi(=U{OHA^&p|ZH(U~)J8xbT zm}(MUubqcvwu@J;Uyp>E2^wIM55Pmr3WbQK3mjF7iw}dbyK9nrc;fJp$U$G#j&n~3dC-K>lE27vQV-=P{-J9wgtt+bwT~qUhoW6a z1%pU4xl?!Wo007S!TcNnfevK{0ACPulCnAqeHo(++|U3Hz131ul`T*CJMp3kIibo~ zwZlfXlX=j-yXY@axL)2jZCb!TGCppqNG{Ru8QwW(WS{3avFkx%?J8t{lt~GI&@^g0Y0ldg(L`xfN&FZsuCGt9H zuG_kJie-MVmkwH~Q5@9IB$1)U8&!qc)~)V~W?sO=g!Qry;zL7sf@7lzVDOgdrl5-+ zZ=@gP=eM$4_^bx#xz>sn94`5sSH|$gk=tW@=eK+EqurQw?cvv_q#4Qs5{G&p%dlU_ z^r^;+ni&2^!*F`NLiEuuMvMO{|u3GgY|LnAfi_TYMC2JW*FB-pRlj)xQ;|ahk z0Cabo!Qr1)IWRb=4gv%sxfe*TVNlPBasy~OVe_qfMESVfG3TMI!mgRc9SisIww7HW zUFve75=Jmh^6`RiMqaE`cmml%BxE(l&}dlLL0@4o|2&kVv?v^%oH{U_J>kFgWs?Qc z&U>+I2lj9mCv;Q4WHlFiTiP!q+Wt?mg2lk!8YP$%mNN(sp>Q8N%(6q`Z531tSHQu- zl?Exp&wP`(PtRKx_P=cSM(eTA_+=9IIXZvJQ9iLZfc0;_@(i5vdB{*UH+8Wndx!GA z(+U%Ld(~&*UrmG*B_LV$MN`R6ytQ04fK?D7LO#WbTsDNUbC$0A?cH?-Xu9c!__ z;3^;#QYdhqM4Nm{XUH)lnGmx+n>!}BO6_BD&L=s6hnAlm47}U^lhC~6O?`UUi}^}b z$I%Vo2=wg9&~?siQa+a z-eXo0^eZafU73Mj@or>gzHM5}UOZa7Zaw5D2!Ht8qx%DswO2c&`WjBF%A&O&_Zhtt zE8kHRp0Q&Z*IfbV^X|5(JC2}T{@{G^JX^kVg{npyK&Zw&z45)fXqu-Yz0Zd`KkPZs ze~0%XO#)uL+Ke}L#$k|K@cT@~b#OPm@ITtUxwFdjqAS+`OTxI|FAQAXWI3h0 zc`_ns$!kG!r7)=Ezi1Z9(j!m7<);js1bP9WU^fXfdS>-)Cn^3e{`|9T?HC~z`1f^Q zsmRblWk=XNEc6Z*jTk&gqGx1S6pn8za2DXNKhZ^;%zA^&@Ejx$FN1y+S^y#nz}a%T zK1nBZmqj4l1rkZ{17w>6B$4)DJNIyiJf5Iz)5;_-198o57x{9z3{Ymbc9` zH4Sxjf&oab1jT+V<`xAqN+>8ue}uw@puy?@*IR?Ro}lDcFpv-63u3e)Opza= zS;!iVFx3SZP8k?FXqZYvY(@bzfia1JWIbpM+40yJets=hK}9IZ1m$UP4_&9?F3mhU7Vxf{j z7NuJmd=5Aw5-o7{Q1ld@g48XQEAl#ac{48_oj;6J)VN^iE;uiIh3oCO%nZx`Tj=7- zZxV$CpMnw&@7XtXs$wb$vbdALFQRXw1ca9A`vV^4f5=HkoxGvnte>Z4S)Y+C+>U53 zjXG&JH#fEvEIrUzAhQr_GW|t1X+!CNf3)1&2+aL3^4F4VTK6-o6*);Jm3#5j*vj$~ z=ADfZtn#mOCy~UE^ehO?Zk5pj@dO8!_PtXHk542P=|eF9Fdh#;`m3QJcx5*E$2Qnx z-g&i`)3aTvQm^hFxdL`)jPCi((37?w=FWeSkQ$>Q9L@Zy#%vQ5$%O_{wdi3*+jW-} znQe;B$fz-ILC?GpV;wvOB8r#NzD@ekL%}AbhI|uBKpM;?o z3N_5aTZQJ843FR>2f7z6DEVGMitv+Ixkc!uu;@aZQ!w+*h4p7V~(J3}4*xY=a5Xp93~PISscN!IXHQf-rbqYJ}@H!nWC20`9gS z3-K^Cm8BG1L{kU=0^qlrSjl3iAmtY<_R)wlT?J@}IzVX2`auhz(fbs3e7lbn7SJ6< z?G|#}iQ3(_b{`vZ_pSp;Ogy`j@>jf3hMEs1iH2fru1>QY;*7_L? z=NJ5?w@&yXGRQkwv!@%SQR3U|w~tQ`X`c>m?v4X()`=&AW9SMQy9vew;MA#r0Utg* z{p1P1;HQ=r|IzLLz01y?FS+dRH31)eh~FRkr*=W&el#ix#adm-Gc@57BE; zeLWNQdi@In{Eh1jO*@j0yo0ju<(M568iAg3HMjC(YF8w(V3+{t%0W~Olqs&~{bpn} zbjHj^tA*$7Haw}=U5UI!pIsH6+5j1FJM>rOGo6WZjt$gJ1;q=1`E-7n3FG(R2h&;n#hfVXP?K)KD`%8CdgDgc_N|){z z+(RP|9E$SJ1$XYznqkT7jpO+8?!C4!@k=EZ9AiD73!BMx+Eul z*xeHRDCfD&$S?YF7U>O9#*1 zw+K1O-hLtW>!{0N*?TXCy}vS4vi|xi{9wrJgo)F(vZWzPf*4=o>$W+;c5l^GAN(}W z`|TDRRkxvKte@z{%X}@0(l`y??fhqCdXC*HoIGQ9E>|rj!4MiFCY3eq6UrV8iXjA$kpMMLT&{=SM@{H)-u~ z@m0|c|IlqS*RFfgfSjFX%Hb?kL91JvxP#pK9p-+83*oBbbKn1+JD_#xIdxx$b_HJ* z?F`GztxT!J?ivY>p*V5#K&HS}o}n--vyr?0lM&+qU# zM_?E(7W@dyfYpyS=g`=I-q;G*2BF=`$dD1iE#u?M{^9BN&y##E8?c417aU~?zi(f2 zu2t})(0adxp?4Y%bDS*BFJ14jW6WOi+mdbhZS|R{kYzIqvxhrGNu^1$FA`aT&Xine zO`n!8W6P-2)TAFS8)(#t`yYOHr$TF9ki$-cY1@wv*xH=eI$?cHA|}dITxs zMekU6LI71aM)ov=`>P}oqw*k5c$C7_&St>0I`o9@Qm(^%ozKtMzms&m78ER*8}@kL zOrOefCDxqMTc>?&_A#mgo}bXrBVQkkC>U9z`INlGTQXXf0=g89-2yC1y)G}k1^)zd3c`U01u z=`?LuvXcAMm>Bv(a$#6Q*z76d8;QH4T{iOmwGRJiJ|02{<; zdNR&~ZX+#%biJ)lUtMA0UScp|f#ZA?k(Q>315QBF=n|yDxH7nu5fJ~901d~sSS#%6 zRE$F)O(%|Bu&^Ilnh=1eg-83ke0!d zE`>|YbQ!1=tnr3PItsNu6r+c*VFIdI4Up*e&71eNBCw^v7UT{e&}{@C5NNLsq#s2+ zNZ2Rgjus!{V1(Zii+lrESg3dfYP%jq6iJfxY1OGiCYg^95J*|VeP&!108KfvyV z49CyH!h*fF7TVv{1$FqRqT}xVTPZ13lGF2aQ!SVtb^@*TSXyE&&@pV3my>%8scf=w z(8AhU=$jSmp#tzjTBq18_rya9^SLV!@5ScylKb703o>UEO^ZRuUN2&<*ZonV6bF&>Di+FR)K( zx`qJ7ppMG1@%0tPZh_t_Df&RTu?cjEbeA*i-VeL&hZ{14%x?;*$p;#*dUagD7r1cP zSi%j19Csnk@QSftah0c+m;I0&OvON*MkvFt+@bMQJUeC(7o2e#d?0 zH`pt_v-!dHy?6G^U7V^4*~aaw0%xWBC7M-lr1p@Y6EwXP4}k$4iY&zt>%L-XQ&8=& zlK2X{V1_|Wg8l^(%i2Lq4^43rY~N^O**Wm7N8h-CnJosKVG6@|aLiaE_@m_?U6bVI zvHN#Z2HR`PE{`BqF%to-p(}$)1BTQUgnI|p@wO8v&M1Dakv3kRXL+~jUr-02l zU9ce~{D9}IZBX{A`t+?|+j)BnGtTSPg`Aumm_L`afB5044XAZ&BY5^82in6@MXCWb z@od90E~7)Otn8V0YkKRcKgpdncf!2VnOfK|%x0T)cYV+5{}4JsTfXLirI0B1`)Axu zn=SlK_)HB_Y;BZz8h~zbHC&vKBHX2*0^~rkEBAx&LDNp?C4#&}*RPM?vf{pLi zF3&PlokR`hi3Q%fAcOp6dw$^+WK zZs+vk@@~J10vPe3OqjD^LFBn<{6vi5_RCl`xs&h$-|R7%y3U1iDyVX*hw6!pun0hj zK&MN^npnw$jJhp$SG?NtQ9atV20EC)lnb9LD>E|_bHn3E6E4?Bj~MntkXGd1MMD!w zf{UCFE$ahCv;K0aKZ}9U5qA=JIC6Y8T5n`8#D`JXA7zpb2;Nved^A~` zT)QyEwgJe1;zH%%!Aq2Uf@;%H9>qWweoi~d=rZEYR{&Wwff2btoe6=&nX~bi}SnhIw{i&7jt-9|D^HP&VKmiT0Z7|2zRjRl@Nb~ zFC|-jl04z7)R!tY`U+88jsl9lwz7Aj;-W8iF;6~{3ge3@Fo9$T_{xx^YL&ihjqzrA zU%Uc~_ovdZbcxTlzCShA?H<)3P*GIGH0+*rRd|8cVBZ*5yyCUh?Zi0q`fa{y+=8Mk z|22yR8rofAfs>`CI{16@-bv})J8-LCc=&oRD?2L6W{QUAzVZ(;j|=Zc$7Jkhsj6{H zLoIHhPcViTs{;ktIDbL#cIz9k@kotGD{!QJ5yG*6V2oH1!+k^;r|7sj2^y!I7YPry zAceT_L?U5U;2PkYb4Wj|&(i_p`5^BB$PXgI!5~%YQ;Z5QkkP@^9H|B6NHn$s(jjHc zdmdls{rctF5J7=FQHjkl#;1H2i7?h(mCmL(EG6Dx5tE${T_3Y14I`e(GYi2$8)h<*!x5E{FCez4 z4fXc5=Cs;|@fqfe;2?bI97#xeWhDeuN$*ElUTMY(Z$!j#cxR{pi*Z;i|7 zSkqOXsR5%srx(7$c&3ZZw~HvP1F``f7c-1>&Uoo(N|G$Y#S{ZTqALIoViT_7VeN5! zD@%;RWm{Lfr;Eql?IyU$dWt4nKqb|pzMq7TFmyr$f!K~LVTRkgyv3?02h4bxXnZMO zP3$^;ygJa0f4D#B2K`s5qi3^Lr2IHs@I>zJ2afbW9>ki??Xz_WmJf2M2+p#wq-PWeBqn6EtYY?j+4Ti4E(u@5C<YfYK2K z2z94!zZ1?AJ3Bii0Io&rPp`$TqFoCbcNDHgjgUOqLTrb!CM6@Q%8S98C#HKFE_zY~ zl%i{uM6U;#bI{`X^rE>xq4nyrdR7>HZJWGq(1igdi-!*uH+7=`$L5dui-*eqcho$; znu#H`hjD5^b8s0sGs2OmJAty6LpUa8D)%2JT#-q9vOX&m)sHbACZ|^k`TcKm#+W%n z#g@nCPx*Mku?M<~Ey1w>bsxz)f?Ni*$fN?$;ud)(3EkVp&J_{-T)GxjiC`Ci3f2;7*A*k>mYq{fHVbwG|*zS5{rRta+~{Swi6&5 za{^>!n>Rz+2!p8zXTwuTS|P(g?A=u8V@V-@suT!^6r{E6)JccE02cywIe^2p9HG3D zDA{mDh)ISarZ5$dJ@#f*TIrBp>u&P*q=+|M<8w#-!V4t%_|qAqeOG_+FrV~mizA6q zAap`AcN&Rib7oKM=2fj?r>4wv7kx>7Vgz`TWx%*D?c`ZP?vV4AaPF(zxrs0xQI`Ps z04~(>*}N^8!WR1=K0X~@}&u@|G(bGaV{{o0#(Np#acTHsGWq|KD-f=&lauD z@45eh_o`xEeAA3pE-w;WUex`;Oo7?5R^slfyTNhRIPOQz#0|Mk)#VsdAOcj7snLrk zYc`Gy@$QOlfUF8%Qs6)K#6BaozW4jQ9-*7Pj=l7c)|hb`S1-OB*HBC%8;z9X>C+Ac z={r3yq-!=J%sB>pKg4c$locmU zovDh47(`36T+Z^BygkvX1^A57g_jefrVTt^T^04jl~c0oDl=yal?%DGrO~8ezz6eGZ=mGXJ7?*{qu0b!>8gDS46^bBAWCM_j7kltk$hv`~p^_r; zG2T_C?#iW0DasA$Xc{<${A;eEmBCx1zLauVAdFCkm<_d)SWSKvXPb=+^>O&@7>XampnD$FiJibLUN3Be8j3^>-0E_2JmlaW?A~(o9

JC!I8$|CwW0kF3QBmRAy!$%fbGFs#@3bhP-)CV2eJ)Mz-=RoOBQ2KxMv}l9Y@`2~;r6;FGLpu%`#t$ozIQab;wMbNbS4 zR`}w1eNwlC(YhmM8o;rO3s2a5ks#3N>JKRB#mkrB0G6;pO6lZtxv6{c z<{*3`aan`fL|bp!lC5T*8a@LRE{WR^^#P&bC4YZ^(0v3_f$z{w@@DHJn*tr4GT<)g zV2CIO=udD};Kyf{bM%_vP=LqC4&9F?S$QE&7V&X-_X8il{f&U zkQPB0Flshl>K#vT1UY`hv%Cw6EL5zNdFMJZOrQh^BNCjdX==9J+x71aP_1eYBxq_l zgh^G0EHWTM6rY8<_-?fsrxT~B3PjH2n8N6=cwmK;l8QR>LmPeArm2s`mCBC`xO>zI zH=e4hl=fz7x~7C5mbbY$V0FsUKx(!+`qLBMsNl+>doSZbZD6^myxMy>ASL81%Mxw;v=1I#}}T* z#cNU~?F7-Mu=d{l@)sdKzHu!;%XH*Yok%nW2v5+JFk)JQCnroGW9yVYo7^4j4m6o-gFukJaQp`d> z9|f=y`FZU(P%gNLcXUm_c8VU|Mll=8?%yl_tK}d9#6&i{#^< z(*PY~5P%X`&F=1QEFt5B*oPS)6tEMUq9Ak8+o!6^<_2!|W4xJ}r+myfu zphIK^ouPnFI0;f13qjcso`js@nC|)#h2z;-CAoeGJjFZdFL4`1VR(3*S9J0K)Yr6N zKq1H!;nPXMDNW_7MQZ9Py6EJ>fm5tcezXceY z8nt%#F~d;mtatPz^cXIV3IG^UWYl-{y-~fpbPduLQi3C1$fTY=^hXRFG z8?s!k2>&jEP^TCJ_d}yVzYbtvWmI2223_VL%nFtE6S9F=)Mu<- zwro3sz9=?0aNz#^`~9Hoh#l9?u#JS-n%r$LU6yNsAlgYPCFq?f1Ps_S^K@4-*}+je z`FGY^7p_J2pP0z2Qj8`MuJqdY zgSfncvAg1Rw2;ve{=%z6cE1&Ay~c{!{6I-vk5b9bA8jLg%bJ5s7EG{IfVzmm`c#W% zQ?R83>@FOGN`(|>2ys~26$HNQLEZ+QPZZyD)+1G9=sE9QXiTOu@06Sqm=PjAk!c zgxaoehw+ky4oA2ha`Q@rK9Fa|u%1%th}q;W`fJ0)mOsA&k-vVXaZloynMe&>&Y(ak ziid&1Su%YK@5VK50vaLsD{e5IupQ=;nCNIxTosDR)Q8H!>`J=9qD@%{(V!w$QnEq| zfzek217k7dhcul4m|#R?g+j*Gb~pc^j!raO{Lp*K#<~nHSydcKajvPfPT88l%I6|{1n`#HwNPucrMZI|4L|$g#ROZ#g>7_}09p^{R&tcYm<_8@tuq*;&K`nfXd12`_vk(Ajw0ze&1L>}lH& zV^We+QgDNudfaDu@#YD||4Bwcr`p;%sHB4Tj#Vnwigll^=8_GpSWQ{^rk|ex1mAmE z=-|l!KV!btsOQDMLwoD9&FC5l&@_0@OlR|oXN?_Vgpc`BYk~D5eJkLoLotWgr$&i z?68NNo#pf>f`>1p9NPeM5AGU{E5tJ36e@r5j{MbMe~mFSt4NJY7S;v~J5^6c^5{P( zl3v7q58je8t|&W(?@bNKBZ~d@AMAeM-qh@ll%XK z9QhyYq^~D^>?K5Q&Lzt>hJ;IeO>ficTpCd<^R?_5EP}{@xGAHJX?=tB|7B-aOHAvD z`MuWC{g%6b=v(A0jfgOkAi((lxzG6S#P@&S1RWE3{be+do{^r-`{Bl~|63EJqw9ji z+odGW{tLe%gjkF+(t}Z3O=9cbw~cOkAZEBgSN)Qtu}lWK>vhJTeXtV@pH3uJ>_p6K zH1QOFpmdyO==apI9w2i^)gJ~7d=2km^BTgzlCsq}O1n@!W+8|+)gGtv}{eT)8 zwL~aQ|8l6eW8<){kz5)3By&3a0~H2ta~#CGa$mGZ00V6D*gcLO?UO6Awocj&KTZq-GBOAI{(>8zk#` zydC%?+fbTSAKisfUlAq=c?xp(Zz2#10=oi>)S}|z;_Aa_ zWdBnb(o@!eU0f2$5F`B&3Tgm*X<8_qI^#Qrxt~uJbfBuJC{div5fX|+&9Cmg4xJZ^ z{Nu}ab1c7>ntm(qJ&%%1`+RopckPkOR1VBdKJ{w5L8VL|;bKp76nEhf5v>L;mhUVYc_eMzT9gidKDB%+@9zbvM=&6>@b?agrwCc({~PMJ zL6|{;9P;4@W9BQ}VG1~4x{VBQnkfbj5>gbN)uCCI9aXXZIIDFFF}Z{SQCkf`2*dJ4 z6)$K;PS=k-O-gz|C*Z^11R=7u5=tr*PXxttWo#{XtWN?}Ii@~D)!SIFK7W+LZOy2v~A&SQQ8l&RsV1nsg=>{e*T;~U&>Necwb$j%h`-59P}tJdV1Unw&>XuOLa8V+sTF^UI*PzUmwsz zp6K>~+?%uLhiMoKejBiU=`W0cnlMx5>}}l<`*0AQ1dWj(O-Z{V>>IX-L$@y}cmRYl zuO(frYk|&gXG`7#Hpqkjh5}0hMI(P+n~4^YA+z`x6I_4_#B=zY#Fzw&<$paS50`Mc z+@VGyJ;AO~9wM5}4rqJq97lc8l0le&jb7$>%zvD{aVY|eZC!yKi{y)~HSG-0{VBmt zeKBvBLq(B!+;hP4uF{>b)M;B#N~(Jgmiy-yl0a6;3uiVSE@MQ@Kuy}ULgN)09eJq8 ze`|V36auK23km892C6Mebtab<<=e39cAeS~ZoJ7cEkl?NWM&tjZeq@`%+ZJ1ZJ9oT zg-rAWj;gA-%|j{qFM;d;bFSoaWi_tZ?HYfJtvE^om!PgafuiWYpp>h~imvYJnvD{b z%1gbG4h@n8q3Dc;_FyY%c!bfrk+hDWX^M&f`tAa0E)27!2w;BN;kxkr4G3JQJx#5* z_a`(QCd+mtx>&r`mBR<44{4DzV!uxF&Q@HRatws1Kuv%Orc7V;efxfP9qH8Tr0E!72t<$|7`{1OH&(C~bU5XQ7-OTM z0=#eAK;VM+kE_#F-Fl?t&l`8N{WsJ@;pnIiHK$J z27W*BetI!q1I?(y*Z)f8@s~n|*Gr~nW~O%5Az?{=^j$O4$yu;IKeIbS5mKPrDpWS23ZEITf8A=2rIm9KDJsTq$pd}V){ zy#UIK7Ke`aX#qo>CN{jf+UOz}%nJvaFSMha)-8PeLL9xNFk^yzhVN+yT_^n;=#((N?VKW~5n@q@Q3yK}vHPgo zjf{(ndysC~WmhYc&;MSm)`>}1>fO?lhF`j;n2LpD90aiyE6~;Y!R&Z-WzqAvh=;?e z?J_v#Gd`s(2|>al0|7%`UW+#8JTDTT&Y&!oe^aT%`2Zn?N8{Xu3j_`-nLqv%EX&{3 z9Ci;(CPW;S5OrLEL=t0i(LrJ$=YAM)9`xayq?}b?U>)K97S;<|NjP`y+Nme4hm!|0 z%mbS}T-a=h!@N(~An{2>tF*%_NT((1(P)vE|Hby%VJhJ_E z5$7%(a@WAYN$l9MW4(+Us@j}tK$wY;vuX{(oaD#ams#)n#75_IeG2DISawm>Vg5ij z-mZ{#7jkpkub?s~!Z$i~hN}8B(O4`)1O7k@JAce2!eei>dLeAuh%|J4()Xz{#9&3B z-q%u}CLed7?Hq>Y{f2CJTAo321=L`=>T1ctzxCqw*}wPV{dePQt2Sp>I5?&^lM2ke zM*_eY)f0z6{?(7(iqHEwQEEV`LG|f8Y)gO|ZB}FU=RrHWf$vI?(f7~2^XAUCkf;^d zMAno&BoN)Wj5o*uo{q~oIc|w14kjMsBLa#VbByOd)vcDUckU}DbOSE{qamV>z!ToU z%;A~o7CNo(lp9-Gy!h$%XO}=kQ984r$GKaVf&D~HQhE<7z*5Kzd%$XoYsD*mHHmp| z-uvdwWq`93MtbE+g_)q9UgJzt1n}vh5V|BxB6)x@l&OKT9Z5^jOQTfuow)P=VK1wO!{(pA zfsMVB5}y0Ey)GN_-AkCzkt0njYOlfAhS@{?;KM1vpEAf$U&*}45ZsV2C_nymWW%r_ zvke?oz`dmMguz2|5J&oZFkr-zV>UIy2~He zm}teCoGuQYFqvi1>yK$%-h;(@^rQ5{ac z%a7nFlnaXKAlUjT3;>5DIUy-l2;hY=DLP@-dvBwg=aZI&Q>=SC6kaFUC=6ST-bi<83ev=jkAM4b#rS76fAjyU( z`ouEc-^JdCD3kZ~&l<|VoUXj@`?M&NQP$g1WRR@AJ~Qy+;j7^+hj}x&MXes=Pw<3M zyQ%+|rk0^C=vY+I3JN*C_}Y~Ri@4AH(}DZn9W0(<;|><*NP6S$VjiR*a28P5S8_n0 zN%@4xg@TTD5yqDgFc)kN7dG90GBG+{Ahn0!RFr3Mc2q%SMu9MUSu|b^kuyxYN;Mh; z30kC zT+?3y@l4d>tPTcpNa@7N_K^v^zDs5n1a9_5pEzt!?MIyoUky!7!wSa1Mwmsg%iwkT zkOZnmMaPvP298LUv603xwYS}^?@yvl{IqE$fE)Su?)=1iMDE|h3s4b8<8zP^K{y#w z-f4IdoG;LnQ$_=t0UCsdL%ubAw^u;pe&hKMSF}nXa0DfeDg+xA8SNtMY>ST`m{zT1 zgP5*Lzu!J85TrA(BSgb#BQ&!owvvCcre|~{duw8(%mAdEZ(-}voGD1rA$syb3wU%sq3 zM3B>79IvcU3dy5(1bff zG?-!7G10@9EjY}uwm1XhW=VhnxUGMd`RK;!&)?;-^Z;}^l#56(K8PXC>!{ylt#(sn zJU?=*scEd}WGr^df=M?k2b80RkVHt{Yo)3N+kPIu88zqOdm{Y%xpUuK&IZ4s5suG#G%^4BEfnEGOuFbUSF# zJXxU6Qb>8bQYE6G)+@VCSa^ z2B2L2dZfQf2ZX|w_xdN`Jz(a-Xq4nzrjKsNLCYEIjv2pl+TK+C$0Up@rF^mJt^GCz z={jPCx+0{#eNBIPZ7O?jeYkNHb~75j;0(K5mx(2^YiVc<6)+^rglx(O_GQW+g#|s9 znK^hf+8a=#8aNAruM`ftz_dqdyxo)Y3*0mNzbd`;KJ}Z!jMiXd%>Lx*h|n({1gOVD6~Oy;S@cnI z3p}8aP!_gDD2zk`*QbafH*~aQ76w8Qf#HyCz8Z%QVQ>ev%&k+VPA!sDqEU?_S7&vB z{T|l>ylvzX7^kl1kn28y+Iom(Z6Rg={bbjsX^2eljt-M`%nVfuIt7z^8?0(Hs$H_# z@_YqtizaL4zRmq-jK0Xk2BGp08MM;)Zph52k{;GjXxdXS2?4q zx}AMC_UH8D8cV-7+2~skmiF+&FZ-6w*jw|sb=tZj#*F;*eRdM<+>K2yW8%WRbc;6x zcIWTQFj*1e^3L(n$VlF^7YkfJ|IxmpRX;ZI)vJ+)p0}rS-fnSw-7(UjL-pi%T*MNh zeNezx;1HF931ppa+Cfq}gINv8r@-72xxSytv5ZX$aYgrbbcu+F02JMatrP|orc)f? zrYefK1%(6=F}V^*n?9)i3-zvnqI4UZhQ&cFESM7Fxwt?9zLA=5w()(aqFqK;=kZ5k z!CmJ!{{`<)R!V{xFQ6WsTzuf<$qkcuPmq!C${l=7+BE3|eZT@<_r5h^!=#TE4U6B+ z8vnaLy}%Z^=?GRlKN@`NiR0r~6m2^ZL_r)+9AgCA ztqQE$=wo?P-T;#?vsG%(_hhDgh^(a_K+LTpp`Js~~Up)e>3=y$@NXY~Ue$+=4BSDf9tGGLET zB)5BU)7@gc)8glFlJ|C1y_&DWTKCqJA)itWYGLPmIWZ~9nlfjK5*5Dtu-WA!wW55ZFuZU$8v zx5XKx;SA_TWY|lCfWb|0iO`Z{cSk6dn&1%zRXT|qmV3ZihmB?p~m@OxKP`4k(vYLT&Z1|Lt38 z^vKLY6f~8KWbMrohf%sz#>8SE@pbe$=IZ$^GBq3;D6T994Oak0ZxlIduDv?b+vU`$ zG-PBt_<=a(nBx(WaB*=dp1Rn%G~FD_FmzT&0Qg5wLswl2^0bK;3DboH-4VkMrDaF;V23Yu8)Cb)zrNNSF%(PC;XC7mvtP9> z3u~;rZP+_;Kr{_pMcFm2GZ0>yL~ zI;fB2XsMTlY3+wB&u%rI*a#@cDcrxf!>H_uJ<{gEEOjeeph1HD`-PgPA!WgLrsd zP`;r~hWbY=7*u_9Lc$?@E{;B~xWBw9RRmNuO*eB{t-re#>VM;uvm9vpac4B7g3vsh zn3<_=-MTf=1==zhx6yljn{@&K&P<*mD5HvGRbOG65wzH)|1$6hW+BE!QZ^Da{G_~_ zDJ0kCJX1qsvZA0MPY07$`Fi@=klAIUeG6Ont-+2v6I*Oq*jJs_C$w*Wv%7}5Jtq9_ z7}=sw+#uP8W8Uc8=NYQFO&z`^M8(~}xGbD%cOJ+f0j2gtj`=2kDp1tbt4yxP8AXU< zHYFB#8=No2gLq#x)g{qV4&AyVFQs#6Is__rpp6JqX;wagYR3uN8ciu`O(A0?b3i9n zo#YD+&+dPgx{&vNtu)A>YIbeTQ?u(w6fOC$gktjzscatUZ_wcH_jr<38zYT0CQLfD za&1g8_qX{e2wN)vHMA}`did|UMofxaoH8T*l zP;5Lj-T}%m-YwohxGLS1F4Dd&dB3AqJ`2f$pOqdE0C!rGy$cbFxQS zSnB+gd{PvH7lZz1Gkd$bnwl~EWC)hZl^XvvY1aW&bGKcxOcgz^fx`itA9}?@v?C&! zof7XTz$7rylC}%jYQfu45Ck9+q&`y1342&L2nM}`_4rXL#6Sws96!_w?NFMjgVUf& zP|UQ|8U|q&1n5W&CgmnbAGDsW7xT%@kHlB%Hvmluqa&iRB+sm1vAv}jSB!31(y{kA zT?LdSG*-396?${DktjDQ_@xwRA62I`#kwm>0ZHjlk(Xld5YHb=hcViH2#lD8b_3&& zOYvFw#4PS3u%AZq!2b)M>ROtJKtE{d_Fl*EI@(u##05y z#LQc*DZz|c`kZBDMNkz6Uc1fTW=*u3qZv>0zQ+y)xSx*?U^U?73-l9s#Vgo&s3l{D* zFk5Bkx_%_?VS4e?gHdHYCc1?Pjj*hL<2EyWkxg`3)r2Z+uQVkXR#E_CMj#}R7jshn z`z`cA(!PF^lW=Cg_@*msPKGjdM%BCPHi?W~TjTomTK@+-a84MZUb}951C`ne#thH! zs70Q4TZBd#!@8K8w|H@|f3K4>?i{F6%H)C`Id=xHVUS*M0&J$$GRM%vnQrbynvb8!>dAp|ynwYowGaFyO)bRtpdeA+wd=gt;d`qX zpsZE!XOQ3HgifW2`CCmkE(g5ZWXb?}M>q}YtZ(10geeema$*Whd(q)2XpJG$+U=eSHbG$V{(7g4h zl|dwzqd})6=|ONV@t_k=8g@{S1wvAs`;^#S#2{hr^J8=g ze&GPTHf#uoCyRrqe1uwDP`&Xu#a^p^zV?Qo_1@(ENj2QQcJz-&NYi#T zAUEZ4QIToF2j4-5<8rdH`@7P^mAFwMA-M+cDMH9PwnjokFnzFV9k*cvL<_OVV>wyx z#uL7{YOh^7Ib%<|gH*@$)0&p@;s+OMUpF=gKWEbXOOJN!a@4IY@6ivFG#u6C?dN?`|SAEmTWTNv6gNH83PE%tD%iwempF zy{I?%c$JXe0Sv`B^XGM#XgwDp^Vr#=vrnM*B1tu_TP1>mTgzk_l<*hO$`2|4P#|xR zC7M*Ra!Dp9Cw4?ou*A!xM9SQ{);x8!m>9>P76yLZIJMIcLvL|M_qN0h%%?^>?F}$> z=5cIKg3h+rgg#VzySZyrG0g_U?^kKYR-Kro)q@cnp^BE((MW5?{AidZQDPJsg081> z4){!;CRn2rB?oaxr~h&U^P}pc3T0SeErPG2PqaXYM?(x5Y$5AxD2Qm{A+};vct$pECMF$Hf+d5x<)#}% zm<7ykUh~;a9?`Qid?xy@n{C!kF)KAAkh9(kZg}7Ez3YYy2mgVW2v@?aF0rHA%X>w^ zyyK)t3t$Qd2q=PXtDJR6Lj-_LkQO&*)XqbZlW{ZfYE3M56qM|>f-*QGfD`Y>CW0-? zma_;~;J4~9y2HoV4{VJ67t>e3DHKxN{BcZoM>6+tITn}egAu_6C7hpCPRF$;3;9Ib zts8&e()>sbkoml~%l^UZLU2Za)X~{O$6rY%CxVhmiE1;*0B;T^f{=HLq=4f9{R2kN zUP60`kdl|8gDdc(P`zS0XADVNVBB&+$ONjEG8Bak23@)qKpH{Sp=hM}i>TOC{We>JH zj7uRyJhC>|{9XrwQPj?MP6zE)3BNadrWm*iY17&B=S|?rCF+hoC168x7GtZnVyD=o zoFjl4s;PJ2?8il z`w0SOc&POd7ADH`z?p-ch`JwqR0@A3TO}u0q4DLY*UyHA`H}M6B+>jA+HD=Dsnp9Y zejvWyjXBT#r2@+hI-cYe-!qRw9*)nWVqmah(#*PFb9j#Zz~Ai@O;wQJ@Vz{jmeLr^ANGU$b|SG52bYuWYJkfkOC2y~#x<-WyX>>&wboH@T7F%*;8zz4y~LiNbK&lc)_;oMn7K^o_SMClbPSkM4^3(H;G$Z|cre8q}} zUwgDdwUrIyBWp03hrupALvX7KG!(Sx6c(r+7*e!*Qn-~#6pi|^7O`);x=ya&AoIzm z{Qs>Fbb6dr)~IY>YSPpXbO7-<^0a)rtOcG8MI5=HVW7}hIct{tzAF2cNFEYIiji|g zk!N)7z`CP^aU0qs>b2>EG`}_WLCXApx7%yjKOET;p3-9V<~}BezaV%uVQqQNQOSR4 z0N)7e8%!@%96jHwPKafD!I96u&HZ6dTKJDS<>_`!Cr_5`p@_Is{=NEU6;xU&zl+NC=FKX= zvPE;28W}n7YCCyZBh-%61Y0HV=4@Hcbv*D4C!Uf@gTbqj(`qBO{8FMi{)n!I-tV? zq?c5Z3fGh*ui-+ADYi6|^8l`<=JH<9%52tXsk-%{Ljee|6K=>qU4=lKFsu^HM1U3A zFOybzIk}WZ`|3R{1f*`?g>uC-ZWs?Fq(Ss(riwcXH*k29>vweS9(Gn7l+27phR3@^o7{6{0`8vh|*s2^Q9ngE8(qXdQE~b;8ETlMnuZ=Q!cN% zXdfWO?H#-B2VS`Dgqf!tk{iChX&Ehh1t8tjS1=)aVb z%Six7oG#o{4PiW^`8$&vw;>{xv^hxh)Q2hx(on2SPqwUmu>ic0#&PFettT%(2>_O@ z9KmF2NSvvAIZ=Ap=NYDtEI}!|D?|uq0j4Z4_Ss zcA=+RhiTqqp=(~C_?PqM&b^9K8Vno+0v+oOpf$>or73$gJN2hKkxrbDisB2Spl7m> z{emIduNL#ZDUQ0Lt=J8<;@OMrY%9U4tMRkrLRt~wv0=-Wl11Ab66mE0p!^4k5^>ev zzba~2AaSzV!qdIQ&Z|ZkYSr?>;cNF7PSv+4&Lqw?R=8bF1%eo`jQ|p#xl9!AFe69$ z_Bz1p^f;nR`!{Uv!HlE2x)lnUlih`+A^k0_MGWjqlcuyUGCrk0YPJ9B`j3AF694)CBbbpOJgdQe^jt;eTkwPWaSx)7}n7sZ?%59h-%Nlk@IPGtM`@ zZ3%7jzyDS6eEvOahK=SMYUqS*g3@|S3-HzJ)kZly4X|9Dz4zsca?Y6^>UI(q!7Vn*LacA14M{1-uBFzHW zDq>sqVpX<5$8|`dZ$RsSvoR9~2-sruoFabKMs;*H@+CMi?USh)7#%6P2CDSZ3U5%eWH|==G!`jcN0J4ss}S?vIYQbRRC`Lc0E^Iw zSa~wNBiatjC4AqKDH=hAWD5c=a|z@qv}1s34q&ec^7phm9O%vSaleMVhb4EiP})sp z;;4CSW~uq+MT78@MH9G}e`|p<&5&QbZEK5$<99bu zQICz#`H;2`<7Pg#5*D>OZQQtVIc8>b+LwfBMJh}w`=KbijC?5K{CGalyQDX^g0g2= zg+{6!Z`Fe$zRZJIJqU?O)c3NPpZI()l2bO_yQRi@5w~9;gWV?N&PjHHf(}ezf-%;= zE0~y=_+)@23(jOxAQ*3snL}e_zPc; z5#`&r6N5(Gb~;T0&0!ftk$Dm&NDYT7y?uDJoh_ zyd+X{#Cfqj0XB3+59Cj$6}tG)dOJkZOQmtQHaWM^dC{s+AnByXDJ{x?XM+r?MDEerq2c!QS$5dSd~|-4>=uiE zrZp|P=+az#4o{kA?n(Q$$=-c6I20rjX)uA-6_6d*&NAcjIW7#)mIb{ciU)1}Ut_I@)XJ)z_0S0^tOUFyp5>}nz!s`!}<`isno*pB1DeOkv zcOC}{a$iP72>W6S4n^}Lfd#-Ha%Kse2W7iCbI}LXP(DR$ux~ii!jTV+V}Mo);!ytA z2RJmgpgP_hzS%HzhloH{0wq6l(dKG}(Qy<N#%NNVPqAPYWawQmO<4?-R z)bzXBSBf0)P^+l)p!}GxMKy?aDn!VNFBay4lfY!}!I(J^ZANMR2xwal^#}~uxT!+# z+}V#K!<(|2KFDRU^52shy}aeL&^1W*m6D}pEa?mowob&yn)~gi$Vg2NgqyoYH(NxY z;XxfI(jL6S0Y$}kpY*Y@BP?iS%cF(;ZZ`$OGT{BgphnOnSZi)+6t4W8?gM^=|Hv(e-2T5XnYyrr`r^zOYs zQSMb(Tug8;*i#Ev<@{R@R*8wll_>mx>~^#krcCeofK7_l@Z9`++sClS&ih&BCEJ`b zj-RhOuF*Hrp%SA5Nz^T5U1?L!B|0Xs3$(5V^0}~&Npt?7WF|^F2!23-15&rXytb*S zsk3>k*N<94gmd9nS}6kJyJM_Is*dqwg#<)dhALmJ%sDTe{bca4Q&;@#Ng$blw0i`B z&ox8?N4h|m^a0kreNR}E+6_tj*K4#cjg8+D4x$i;LR>C^CML!a^hriOS$5wSa8HPeb%3jcIT)R5Y#wMM%^E3}BUw>wky#!w}y&&VA9q z13Pw{!Mx9e?E*aa6h@t+HBH1AbS+Q;!AhB*0LK!yJYa?$eEQQ@S1+@IZHE+Cdv@>6 z#SSr|e2|>mfLo7Q0fiLNhGDu$!R@Q!}3_l^>GAWc*WS! zuMBb?j?5?&UCN9=OGFHR@m$nAQUIc?Py!Q>qb&gTLmQJ#@uMBR~+qr>hF8D0@NNBDcV6FR)k-L>=ZR9M5-TYI2o^8-5)LzI6*>`Z9?3 z!?=qeUPI1_m@z@G%Lj_VHHc~9qLu~Um`f^mai|BqY|~85BLArRwtuS} zMu+B?GxB}&Iq&JdPu}Y{4xhzN)+VK*-5v@osg~bU$>?_^UzXgT{)24Tls5p)pa!(;ZQVGPu^|BB= zV`zv-B;3?(!;Y2KEU5yd2jMA66w4FpJ*t=x$i4?(eqX(E)26~P#U<%{qUbq3n*l)tnPp!aw z0&VYLMVnIyR)}kO*A@PWCTsrw4U0l8QSt+>UAty={yXLFI=f!R#c8esn<7S&4V$jf zXrkdAP33^blN9Hz#YZ6P;izt}sjFMYqG3pww)nu}M&}pQX|ybZHlTTEe1DzJG3bZ6 z)FnfYg5*rHH-M|ACCne9^?yjtPFpo-A;n`<^a<-aAhz_p_Ir4I%_M{3?9nn~=Z2f< z5Hp?q{%vAMhN_zM3$p={Gx!`y*a?WXdv+HMLLQVNg%EEwlqsvr1C8O)*NWyMiwK$z z3`qe!7~YsPzp@rQ9CX{Eh9FV)_j$bh(i4xJi{}oj-Z}J;-L!^zq@V2gbNG-*i{H37 zGhKgl0~;3DSDlM)0}VnLyeyg=5X2BJVv-ci`0A^#VpZ!f#1bD&7MVfB-$I=R;`tW1 z$Vlwj0M+Ene_FIrUb&ZhV8SJ<64DDNj_SmF}`q5OowTcHepf2p)kYNW37(7Prk!uwjge{GZ8i z?%H#r4Y(@n4{_e+7E~P`%s|+<`5a&aNIkzyK2@YRJv|oFkXYwefT)SP&=C#>*4 z))Z4~|KuCebvgcR_eLmX^qm`o5;TzK!fz%_V4#bS%VDjNq&0k5&cdTMnTPDXCwwwi zC<2TyF3`vOS9Fmt3(;zS@pmMM+Wai(atzwR=jl02Ni;qEZiSh-(Ysw6|FeH`@u4L? zScxi%E-`&=VQy}2D{!}asQnFbb+xU-V-S}3$c;}Wp1s_^lEp~)#ua^aBd6VGD(VZF z>1~;n{#$%V;Aow`rC`CEF_1AIh)TDc^w4)eJOrjXK>dFceuLv(A6~Z-_9|->CFf{}SvNF@qE(2Q>o}v+{G6?yWa>M(q4?GBYZ#R%SGWLZaW$r&;a# zI}n&T`-I&KjrwMpmmCZJ>G`pa--JIKurjhbx8m7l`-GC_a2O$J!uHq*yB2^x{3|Q} zV^#@!ZwWQE8}VaB_TM<-&bc{}AF^H8>e%zR9zj7t^YqRGl3Koc^$Xpy57f2a_?g@; z(=Qkt^p;EG2`P(NMnyK#b=K&C?Np_DUI#rmrpt{~4}n&dMk)L@P}NBY5njT=!-dFp ze~h4jpM zW_W0!$l8Q`BPQhuKSHYm9FDe$Bjz8rsO%ZEP>oM~3%RM2dO*Jvne_{CU4uGe9UX?k;I0c?O!i|reU&>K=5SeUY%S8RqMQgdEYlE zJT3ZysuE~HL}n3hLs3L9KzI~JRX;(lA*xt<-TIFh0g_{#m632mEG!}h&=h&+pllVr ze0dwG%cf)U+h^p*I1@WM^kj?Ya|n3xSbAEnc$9PkrY`ils|; z0uO4sH$rQIiLnQZ41hs66rLi2b6h5PSGMc_wPKYs@UrMG7wRWsCqlHo^pssiL-)I!_mMgyvO0p&EmDlT{!!3EEl}egB3vv`0xC z|M%f5w!tVVNho6-4=Nd*0L8+3Ue+9W3IPnXNp9zbMWJsqy^WBEt(IUONY+newVQj= zbN4{x0mW6%cM)?!HE~ihlKpf-JHzCgn|#FVm+;fytBLt4zv&aqo94go7JM+{r~g(# z;fuOTKAzE`uB$oE=Iu|J_3Yb+y81J!7CoP8F1_~Sl_UJg{blr!+;BrMjN+^|m z!vZvBd@$}A&SC66j0Piy@xc&VfoS28EM4>8D5DA9yO3RSbg-jeIq28p`A%B}&_88iJei&wkvoZG|6F@pyaYPDKNfiA7nv52h zYhh(AixzEAEI)Q_8zI72!lwNjaR#U;ul{#V>H8(2MJ*zTPger7_sJ)p5LAPM*B9F~ zaoA?B|4+_1Aw4UYUz$Fu*cNX1=rANvqyE&c8B$r}sH$ zJkiIHkBg6jIx&z#0^PNU{ zRZXe9R!>U114j6psEwV3YNmV^L@_9vHjuX>|sC~q_{^( zf&#)4ET5#_Zg#8`1F01@iS^>V28+aY0_>Iyq=vTb=-DiJmMGi-9cisAVFE7v^K-^| zA55O8-p{+lxNlFq$NeN4N59iFPgFMvfxqqi`SX;S5SN3I9o?_BZK@ZHtPq{RFJfyO z+KWZll{atPpV{LsJtttpk$)XMxBSRgTX&rL?4hXH4?5d#RA1cgefzA<=f8Y^#ME_> zd!Ewbiv>R~o&PVE^JMio{G8a{+leY`BUk;aC}`bxt9Gr^%3ktq{;$8Bdj0BO6Yr-< zFQ2pQw1mdqWwkt2qs!S&eY{lrwyVcQ{5gG2{QNC#)22WfP~?0d5sdI8m$gI(OSl-eFFf)6001UU#==cZUwH z$?*3rF+K=TjQprHH`+*JsK;z=2$l#G&OL9qCBp$*L!njCC=W{&Jv$R7PC^yGpV6v^ zH&|HsX03XYA?5Bv9HE)&P@;7Z4ombMoj*}W@6v`Q?!k;VZ{AR}7EsJ$N;fB$25Ick zzqhavZhng*bC@JB^kl@|;TIw?7|>#4&GPNoeley6C>(~Ghdi-VCY^_Bgg5}~rMg~h z0Pe1!p`qbku?Cztw2pYJ{2e-f8R_&+)8MpHawWPL>^nRNc}EeQv4|aU<73jB+lMjg zCY;2fOB1~G(5qQJ2+*MA5=37E!%x|V4+wO=2yS`E3~C>le&2@~4s%jF%=}Y?@r%FT>b7{%O>ec6R=F%0V-Xy_? z(S2v99W$~Jf|RLPCulNl*WRY*r6U`!77kjTNr_@RCK@jEDa!1cS0ugV)I;87QhNhZ zJM;~`Lbc?tK;FKNbwhMZYG8?=24}B$N{b4zBmf0%1WNGHx64!ULDXjjOuUlZC&LzmwEg30vsirJj^? z9->3W9-=L4Yisv$VirTlz29xaP1ID#Gj8uY|Clpq$60cQMh#f#>1;;HUyfiitnE5+ zoA%`54r!znYhb5ou_Hl3y1JpbkrbJ$orXPh5fRnOBJR^?d}&fG}8zbDP#L zZiLv5a+F0xvTkj2Kz+d@a}d@{6m<$LLDkzNE)Ho+uZlK+M6z6vV|mO8jUdqdaEoIw zLR*zHi}ndVu$~Fc#}6$h3&mw30<;C%E;eOA+wjv2&!uwtv~2;+)>sTQ4Xb!U$upEv ziSwYvxv!_k>)BBS2oE#+5U)bGAf1BBTDCu+2zms?S2?MBi)vCX=F3nkCw?7lZj~(J zqGzUkXK+DXEHRF&T81tSIOBbCC)jHu#ep(-ldx=7n3J`Lmu2R3vkFT)MJKC_2M$0f z*ZisqL><`i#@UD1@zJuESLd0;R|ErPeFomqV{` z9)wYaV)}AD+`W4g;2(Qr$ky$a9>pu*51@yXD9UbaY_Q)bCPpJ8?Uk0g?;#8|Bv3Wb zjoiRguybev7ConJi7-qjSW2g_r7hN!IWkjrf!hrJ+O_GC$&RQUl+}nExaE~nGC&VP zhoKr9vY`pqp$=uRA~)~egWmmk+B4o7)x`PbCq#y9tc_o$nbeCJiTue>>FC27ECHy% z(K&dP%+F|0#s!lJhDAJn zi?mQ04;mSTqayadq7$}1`!ETbTV53<|03fR+vV(g!8!Ng!;PiIPVSB|uG*wShD!zQ z>eHj^x2#rb-YY42tdTk; ziQcSxt=*iI3LpVk-14Iv8!Pkr8Q~qS8&QRyDQDl`v#2!VuyjN(cNQ;BM6PS?mdjO* z>|uZ3!}CG4TEFCH8QM!!bI3gdiz`hDkT5ObwCJJU7v5T+3Wg&9PH3mT*hJ1M96oxW>Nd3w}?S0j!m0FhgUUP8Aw}vpuzOQN=3{( zaG7fomxzg<^lK~=`2c&`Lr3w9nCkZVT-27wJcOD+Mv+I49*J_g3=w!jNEp0rBnd>u z3rG!fI|aY+zWTk|H*enLz}f=nlD0cwX%-eQ(e{f?M;`qwIJy{ICh(C5IWeA4)2-mg=>y<<5cw{Qkol*o4G4Ymczh(W)(9iV^sj0dsL=FuUFgKB4Oo?` zsH3xZ;zJvHOdnWJ+VH+big~&J88zzS^Ju&eO3z7D9dweisLRF#EUe88$>tM*bmVRd zX6t$gFTN@AW;rkHyT#M?mAVW0&AIW^DW$n*>)xe3of{iQjU#(4>=Yor!VPGZo^yPc zxU4jDS;_;_35VxTO6Su+D8TuEu$0y81SOwsk#XKW#a^Mccg<0|nhzIy)oqMl7#72M zET4(oj;ENo$SEWH4cqGYMi#OQZ?A8Fd$a`Fn4i z1D1|mLx4VI2!mll`^1TjO>k)GZMr`?!lR%>plUcwX`2uyE>5EGL)}c`)}6{t$*XFX zvfA!BOx&fhN}>u-VAn6d=~9G#D;8m{h@&Yj0=6jMHo$@C3gxOXP3h+J&_c) zVqym|Ly+tcJ=QfJA9QmA_n4uG9}(Mjxj$u!BuS0l_7g0TUll#&PL#&6ep?gIV)pE# zVA*N=7HpI=(QvHs#ht+ufTl9KJ*zseR|4M zV(YRU(gPvUr>{)&R&VB)ku~|2TZB1n7q*@SHUnrjG%+bc$K%_`gT$0v#iicQmB3YY zLUp{Hw5U2dIw4UQuCI%!w!wTT+yESf0y{t0B1COrZUWMN;GMhh)jha-liB$FKzfqw zl#`-Q`wO&NHiV*|v$Wawb5Pvurae1%1}V6y3w$5Zn-tZKz7!2R3VV%ZGip5W$KzA4 zm9x;F;3WkvzaGZI8n426M>+A*AfeCB6KQN;2!zNtH9EYeIMo1`R_EKuKENOj{TYX- z;5W6-`0e5s2F^e9Hoa|69bUpR+qWPhr*;-MO@86g=q4=D{K(L6Dov}-XSRWqI--3; zAKOk5Iza*#WF;j*x$LXu>v->o-jl4O>kUv~NF0zp0MHc5hPP+Q*UD#l3j5;Xzqyk$ ztNZ28l1Y}Wph_t%2K&n#Dar@iLSpTD3~|F_CQ4+Js~SNzU+q=1wyKh*Gj49Q(sdymAf*2RFlz`A zEZ%Psl0VJ*6A0TSojEk)OPrmZiTt8eWCevUwHXG*e9n8rJx`gl=JPW~@ErsrM5YNB zI(^cZEVO>!N6MdgAgU+8M~yBhxhwhYTHx=xlCM;4-&B zz~cWJW?=d+jC^*le){_Qb8dtxC40sjDq3gxN-E=|9KCaQWY>ii^qo6Y5zci~O?hcS z(s~~6Ui89dUDU>vys(FXetMP8jd7#ak6vB(v*O&L*m?M3d2Z(l&QBey)m1`!h4_*l z#76B6O^+GkXXrF%3s>q|q^O$YRhyv#=O%u-TF zenb21tLYlhIN^OKRmTjZ_tai`I8ztTm_rugRv&DWHkZiw?TwFfp`gBF0l=3Lk56jF{-qnarf^1Fg!rwjK1y`j^xav>!0CopumkbiDi(F zzClL?JsOcrI66p#ic}>wD<859+6jU-)(CrsX_`qNfYC`AeHc0~sAAUO2_rcPW}O&r z;z1vcmKfm_Y`w(C;t6yPJ2*iz6^7;*0h3#QbuX!4u3sQY8MWo8Dw?g_qOHJ+1+pz= zkydy6$D0zvwhWnG3TbiusSBTkt*Y>IK9J+if5_QPO*uGqo_xv)v+Zs@|2$%^VNrN4 zSq7lzAoB&eS25M0KmznftV&J}7s95pR-^TrF}~hwv=73}OuMvcbs=UOu#jAYpar7k zHz4G_A~~KkZg3ql$h%Yf>9|zo0|)fMmeJl+)CKgVSYSy8W@w}k`%rVu=yq@s z18X`f$vp7~DpduGc?NIiPB_SmScO~EQL8M7T{Yz%Svkr~ljRV<>B+46M*U@0eS_mb z797qfQtkpOflDmTUlN2cuL_B5I9w10U9Vuz&(H6L9dfvNWIc$122i>jC=7D#M(HeI z?yNT??iN+V~80Lnds|;Ml=5dIZ5PqtTTb)myT5I+-yDW=a3@ zDKZhZya+n+10DioBDCGvYla&JftLGs?AYPnojG^>>f7Om3vfHPdUI&H#rNHw&Vv${ z63i1HH?V%Hg3x&}8Xp{+nMdw!zE4Z?ii;1TTiES^2j}0>`TZEe)YR0H3Tw&9lPY|N zP;V%qeV+%#El_WvV2E3?-eeb`tO|G@Xnhzf$&|0g&>!RIcp2Y*=jJ`50ZJvt)zb~` z5d`W+A1VjZHQeULB7kw1lNBPhXx&RJX(TNQ?JU>oh59ntGAi7$x4j+PKJuvOU$8!4C57uMAX_9z zHWG0x^?Y`C%Q0&nV2pPVg6z9%1awB7fw|gJ8&1fy)GfMq4qo67=-U=uq_eoyT*CB^ zYERfTfd3Q?Lz!_3K)lL<_8oha9@~TX5rQ&ziSef`OkaBdYtdsT7va#m-e^@AoZW3Z z-WE8@*sy2-hJw@4z(1UBRI++mr}2E_j7x5iJFKlLO?J2uorMgQbDXhddXeZQ8mQcE zwK0$1W=5ZF|2Cl9sh^#6bjm$!QzVM#3o;#AnBGI{bTPr=f+SA7`5A`^2L^AzRP~6U z+Yjat!g3NC14bH00Tjx#l*Y&c;DrdL2yyDZDb!=*4CEJWXe_p9RXC3hlXI9!uS3!d zFy`W=%7d#}aQ%}(j@p738*=IaS%F1=#rD7R@*)tARmex7&Oai8QjfWBo!Cdw{Za@C zE&ichXGQqJ2;{=WvIo1y*rD@Mclg9-|I^g$RoHvxVQ%hzTFr@kQkV(2lzt2oAy4sB z5?vw#Q(;Zo&^&AVxS^+(#7{(X{I$+&u6<|b`eGIRszWvV65h0Ok>pH_rXBQq8=obby>ln3+^ zXfyv0qK6Yce=*jwNFAKT{IK=+W8(~bG_~-zXx0pOEjH9QRjShRljzgOrr6l1h!%NI zR;2u{mrZJ1@9^NeswrE|ejN5(T%M#2*a?9^wG){yF<}PYsAjI97At?i^_abRQTEz% z(diLv-7o`VkbN8DC5pU)Flm$N3+E%EzhD79b;-{!3V_0vB1hz_5;PT467kz_vrS54 zL(|2Q6wF{7f?K7Ca2KZ`8`3zB3=QFI~&yBH1a9ueH3xnTR%VlQ@oB|5{C+ko}feZ5BVQw{FaI}!>wFQ|aFrH!T{ z0fHISAS7sQA-Z^KOnn2ZK~$k_H3!A*Nr%c0{33JazoyAS1_i#ft_2T$!!{9j;gf~58n?~E_>ejI^p4OA7uHqvxWg|)Xdb8XbW87qX z8=W^YD;8Ru&8Ut=b_`=W+-JZy?3eo_Ts2N z%Cps!XkmbkEHFw62>znPT4;sP4Ux>2Vx_#U_V-+1dcNwZD4F~c*Fy6I4Q(rroLDXt z-*f4SiPWuiX;KZA*?-5@X9`FNvwD{};-no*jddv)P`;d~7OhEv(m=nT1NNqb8ziHG zS#aZ$CakoS)>E<+NUn!Q2VNfvNTP5)j9;>{XJs=O>@GpD7d`lGsrz=~$8URBjYJo8 z+}mO;>v7Hh!X>>*ZcMDG)T^PdeuuTGz9U13Ub#gV>EDSmzP;xQvGBOfa>PqFsyKPx zm@$;rIn%s*=K|!2L2>(yG4OkoiruWbZyQ5Nf1oN57~Pok?w8Y}X#Vy^_@h9_=CXGOhkPt^X&4wFaS?M&C*?Cv@!7?oT&6JQamdrf`lto=TCdXHu$XBFvdAiM>=@_Li|_?_ta zaPdPIqtV?XBbOdt5Hp@UZUH)zIPtxPu@yX+ zKwTDw-#r4RMZxmnExLH2uy=6oWq>lX!mp{%C@N8Vi-Gi(eIb{N{PeQOLC2eAu~@(k z4%}r8QJs7bCQwh=Z#-h2f=*55>cuZsU>+7;0c7QR_B!E#TA>#6-lFkA6evCA+R`o? z;6W`J?gEm*;0M4A;}OR0&OJcg1hi_955vX0h^PE3@KRo@l*2fjqCivl>8LHh$Vv)2 zx|4x-Y8X-=DNawzfll44dza#GycR3}W23Tc4YA@Mz;+qEdfOLFo&0e-`&L<(d*i_I ziOp?tP0tItuS1nvXCyRk!GlQ|sPGPwQW5bW{|S3-;ob(_1PFz?z8`k(G(^@Oy#f?r z0F;C|KPgk3Gi4tm+5~MEt!^yDS9X)INhG#$jSDcIeLFtZn#eAdTk84o64lhH;{0*? zx>I-TO-{gBJXtfL{06n3_ruyEYn|8dBfSD6lhKf9WE;*#+mVr(g^;Y0H+%#A>8G&2O9zFS3rMj0>Xo!u( zb?vm_6k?viF8|SpCr@^gH~@naDLJsOfbu8-C~C#aDc4|?GGbxi?Q22`q9UTdr>EB! zUs>oHNTCb}fjXh0w%PdQTFm*bYnbWQK^|wF)KS1g0w7xahYGmg6#!K44Vk@X9OKPM z^*)e{QiU^+3@AW+Ur~5CSc$OU;3ryDm>o^-RYIkOeyyvHjSzACCqsxt_VwW}3UVyU z&p(kRrinF@e=l_>5{~JC8lZUg2@|qU9@?c#PpXU0oc}!jMYUrg9B^KQ;&AlyZp>`z z9~dYZtps9>(&KiEP43GjoX=EA6xQCJ3l{Qv6cp17c*9AwzhG=@91I*3N(c!UsD++F zMS1q1U5ih#zqE5@gUTNl^XHuRGOysXB@J0CPIFF_^QH9JMhfyD-xbmdWP2RfeK+Yv z+3$lOR7Qoi8<49CYEcaIWVgg*cL^AW&`^NMhQTos*AUitQBJ0@SU4IM5fKsP8TZj^ zae(Tk4AlCdJ!#9|3^fg9VbbzRa3Lx8VHv>Ch`m3xnGB&@H4)ZJ1}2se(Bfdv5Upk0 zh-Oy`!b=MC#gZBdKY_*!%uJpZ>_a5-f~MeCB$`w9C5fGZ5|1A759J^jl5CX_EBgC6 z4I0pwk3>)~3K-i&IglldYsALKKE%?8PZqS(wGyQJ7L%0vZWHiyv1MXG^ zx`1sH9wak?cc}iBx7I!GFPI|1dsD(aHiy8}OgV8RMu(9~!;m7F-&c=8`?dsh9eNLf zq2N&`w>2dmjF(@{)=}*4unwl)@;D#a5w7#M$Lz)jZpL%t-NA*u6E zHT64pjAsyGpw1RI*muLnjVHF(es?}pYezPo5)p} z5@lm|roGO8(p3HOg{Y?V$BUn4UjjD6<2ET^8yOiv;rH(qw(M119XIkgfD|vB@PG%S zRWlGRjlJ#1C9F_c?Ip{^b)?~`RZLlxnR?!ddGb@G7#8XIRpnn~c$=Huibe(xSH;~j z7hIarsh%P+8Z!;i%Rq599eThA^+mF!1FhKv?g!ewr3X}rC9J_bdvHq_#3-`^`lvi` zpDo>kwFO^DqG%FUNDL!d4ao>p;t#8&b}E#8`Ghj-5%M~-j#SyPvEAw|%#2Z8mOfg| zCr%j&AF5)vu(P&F(7RO3Qu_k{q?^lbkN-4vHd{GU=ShR9pp+UZp)i>CpntxN+mwv% zOIQ?>g{PVg85C_~$KJyNd@3M!L6_;K9RvVRlq&{KQfm`a0R!!F&u86{eT;XZy#-`_ z`EP%UnQ)S3;>&^gfcC$GtY;$z-AK?oh}M2!4+w1;AycqnZoqegT<2kBN@1X_l_qJ* z5C>w14arhOv{N;JYb4cNkR-qa9bVsVXE=e`NTsq5I0&lfyVH{hhU%3-#s#t00JSzj zxGPe0xp0$KS=;1)8>&3)&qA zFAfc_I4xlEa;6L8ba&x*2e`}p^%wn-WeF%`9O_X62rFGPwq~tT~awyQP3z0S<} zJP-c{ebizsxd>Y5zcBE5tWc6;6$))dk|Vg={i>=9YS?oeF4M}~Q$8&Bo7N<3LO$@P^&PpBFrF(RTgn%T%t|4Wei;4&jJc8VIL-a@n{giApsz=vf zrF{XLeMb=}Wdxql(9|@}n~EvURuC&Vsz|JaFM3fnA%d8ZIL@O^guZ~nB~?(SiD4fT z+a^5}h>8vdfr2r|81D;?>|1i&f;=dqoIa?C+U@ZHy14tLO`_fTk2DHlO>rG+Nj%lw zNB#66JUIWF;+D7{nUd@8H~|hKWjZtf@R-qBC@3T--3yCqD4!LcKx`e;q!|JAskyG0 z%caOuFthQKB`K`|OOI$#%$VI1)onReh5#BMT5_{A)P7iTv2e?YASCf=n0g9B=_ik6 zTe;GsUD8Ilg$C0`d9)8B6hf?nP28A5iGtv$+sMEFsOnn|qpU{|;*fl|mWWAaA6?(W zd>n8d-p2YU?5HRT`0lhN43bbILX_Kj&BT&DJ!w$2w{YwOnQ<5iQYj~1=B{#!wrk$A z>3o)}))wQfnhLf>gUXVLAtUXR8FHtVAIafR?1=vQ8Ym}U-+ zwHa{cxZUe@!YYlNK!?sZ&z@lQN^={KOWSl~F-gssWlu7_==UxF{icGZO^IjC5z~^F zceq?3;O{dqmf#|E_Ak_|L50#WwFL_|xsawJz*lmhsKa2sda zw=AHvS#m$ElR?HTY`xw#-4M4*dD@e=GC+=l;#8w zow6Pj)j->FHf<`>3JDJ0iCL|Q8|{sysW#v{RU#uJq4c@SZNr1(##^13Pa0`75Mj=v zBm3&KRf|i+*grwM=%}hAiR;CG(dxSIH$OUC<0jlo*wmc|*%7w9MY*`}xuql}-IVm# zl|Rq0Kjl*}!C8Ly#DB!lpQVdq4pg)7vynFst=>~*DVP40e6_Y|gCe#;PnvfXvMiJM}fUy{jwPR7lf6k>^?o2?bkX^!YtM!-Dg0xXc$<3oveM!rqj6cdU00U4 zl9rZJk(kvky;N;+L&hi_kc>DFAz|$RfHQ?hM@5mdJO5qs0&!ajvnMUTL@4Xs;R3ukucv zbEJA->QF;Lf=JBHRfn}BTH`{Oox%LGb~;HPflg3Hu{+3JK?9#vT=1f~6K8D=fEp4B zBTun~0<+SXfvkK}*-Itgfz@VjnB!)9{2h0xc6aCXCD23~AZB4cOd-j~p>|$K#|DG~ zGgv!;P$~|C4=?!bX#e2gLiiR1w7Q@LdxDI$b71WF;U;l~e)9_r?YBcvW?Lr7aVD1%BuT9(jT)eyPk{5DhiI*=9y*)65P2aTY@yr<5J%SJ{$;=Z)yb0E z#umPoFJGEo|8Uo#3F3jh!{JeJJ7#A?<3kk`3!?COt^H;^I7A)k1_A0&`IEK+LLX9f z(ArvrT!#aXC1b;MMt2rahD8*XXJMuRSxg)mbaVSqK&gT9AL(8OWO3bgx`|y(LEY!3 zIPcgh1$4rU&6A-0y#P9*RO^6KU_suCK0}8EOjC<~uzKk`7~#}$rUzOUzm@?RF#hTN zKMNz_yN(TG_1M|jUz|NgR;b}UZ2or4KbJ6vBCHm+`3wChV-Y^s+gX^nD_xj{hRDSE z+FnCPiQl!jNvRsBK8$tG7A~b_#7b)u!hM3SO?p z26$6;*ece?v|kptDM^%;hyjqkRAPJ?_Y8!xAa~y8A7^3r=->QcCGL7hk1j{160}#B zpB?qU-;l+(UeRc|PiPa|5#$9Zz#fMed1QvVd>L^4I6*)LOiD?^zywPv>9U}nakGf# zGH2q0EX2MrW;>jL7B*gAtY9gNwC(=qZbO)b!m=O4Fx?>Ns~eE$iyYB|0N`aa{#j}k z4g8sf<)^9p{I-@9f9=_jq;rDP)q1~cA1#o9FlHgP(58$a9xWD51=3G&`s6+SNvZA4 z8|#{FNr_k}xZ82Vqp5>M9s9IbZQEA4!tyFIoayBuVOS28HmVIKepieE0;@mW=_M_$Ak;}<@Y9%?UHd6?UI>&cAgdv_?_&U?{(=%CII0_%+rFWvCV z!F!jd^2aQgF5z+eS^d%CfO8T$pIRQQT$qq1y+|T%#*~*&_`8M=upiUDNk5Bgyc|yp zQ+eN&@nw-{So-6}sBLq5e3{~bw@glE1zv6uZ8NdH*(q;$U<65mO>?$!S7RSI(AlCn z@>>Jx{@-MmiiD+jeUGXUOs=`d0!?hvI|PX6Uh~{uLudYTs>%c zsgZ&QTjVM^{~2Byj%CsGwUDf9kulE>>Hdz55{qWSyKCpQDY}$6PZNQ$udnyev~P1+ zS+9!Pg8R7VtVg+Z4h@R4_INDRI(NyWMm#)rWLs6A@)T>hDPg7k4s3SkCC8p3nnD>&9<4fqyZ_ttK&u)1>lbi zFbUIRBAO1+c|c-CEZLd881m*cdn zP3OC!k4gE2pM`;2KV}x;6lisOu&t{Rq?!2EHuKFCX94vTlEn5Q<;;X*f@o93bfVj` zOF1;Wvor}CgVku2ISJ6Hg|K_;rL5+-#Q_OknD^_qRN`{c@PKS62s};|fvIx}7l)gdgH`lT&CJ7piAHF{_d!esDbSXtlq`p!ywXDFa^psoVP#7dSP?5M-=9}q{ z0pS9J*hus!rIb4(>AEK(!mw>KbIsPUIY0Q6)V**Q<2=zajaLVJVAqz?xADlMDe?j# z1)*3&LefuwOXJ%f0S<+L#A!tBH}Ufh0?$RBNbnKF+(8jji0oC`cMf>e&mLc=9I2U+ zPSNe@KeR(L$?``Nla?77$8R=KN<0mqqeD5P%`vYdu2z_G+no9&DenR##cu@-v~4R0 z1cOauto4{GLGtwDgkHVp(9Q{u_Io{yi<@x6k@_%TtYrJbLWH^@6b71YL3^xItyZPG zx%%PM)qL^gRKh8dm$skCV-$0&N?SEN zM$hu2r}kWpounoE8@Gt2?5|l7gU#;^KP`;5i#zY%=;u88qUo9>ov+BMN_jrMZc9A< z$ya;=dL{eAN@O%kdu@LTG2T)6xzaYFZ$m1=@~3TDc+vWMlud|iv2DsTa8>KlEf(&T zc@`UB(83qzE&V0EUze&opJ0)R;tH9b+BZ&GBu~F?6)wCYLj7xhqv@>d@|%9!o9w7r zfWiwxaawW!jFH5j;rrk=BG)zi?N~M`hwuQBl`H$|W?p{QVA%*QB$s1upxgsE`I3;lx=g0xGoV&U*p{*7#yBia7=QVjl zHH|8Z!Qw|mgTv}&iS3d2K5QE?a2lXLr(Pa-4aB3-0Na9*J%k>CaD zA2=(NaTt=BAEHN^4B<>76D=jNP^K52ud`KAX+$y#ufPKc+%7>vXx>@vR(5y&kK^`9 zTGg!~>J8;Ixg=XEQ`9jbc9e&JlqC5qIm3V!Qi>;@M(ox@hD_rO9^*4iaKv389TsL( zh1Cc<^Hgb5I#*azE zX&NF*5vsMCP<2?}LFH#5-p_-)F@$Ae`sLD-hY@AB2X-X}QXctD_ig$3c89HoIk^7o z`$F)Y;p243u6mI|T{_C=F6#O8DoHv(nHnHYx~Ja3eoLp0v)cMEuB`J2LO)m!zh-{4 z^RN+o(v^4onftNKP2`M=|0S*4dGyY*-P`&;zkLm8M5)c2Q_8BiMbOTZ&nmjTQ4Nd? zb#}Njm)-Y>a+M)b-|E%fVP@d-uUx6$G83C@RaCA({I~VwZ^Aj5#oz^6xJ{BbV)|&| zDyAYdUT-#-5fXtC4UJRHifL1(?1xBIIR#V!4)Al*T69@!DLq7f=NsSLiy@Pm@+PN# zt{B$1(9tQh-3}*}p}zipeSQ6K6J;eO${)ZT*0F`wbmFYCt$ZuYLKTV6xx5W^3bOm0 zkiAI@f75I_7Txx9c4auwG927s$2`(swGXZaUkfe@zqwmOX#V`=;Xw6|ZQuStu7^fLKiVipp<5ITVIHtTWP7orGz zAWR)yTdZ5lx1a)7EW4gqu*rQ3a5aDfBE=0QG0U9CVp=C-+(Eio!hU~~Q<1|-)jpo^ zC)}>I8x7C2b5fb*--U!c(0kOO$~zAh%!~g5{RibVLs1E#vEN)-JWjZ8mBZWo*H8PS zEDwt4V>3M+22d{)Bi#@21J~}2ecwL2Tt0J{_IncBVBLMg%P4wyx&i%>RRbVc0c1Ud zg`$5=^AM(>8KrJ*vmUVFbGWv?EYujg{o&uIfd5LfnwA+og`$r@_02rYB9C=@e z!zm8Y^>95F`}_#c0EK&yYzOOyf(0pIg``r{mSCjmw7qJl2fzx+YcjNzV%(**5~l~i zZsOw&2FD)dE%Okw5{xa}-wtH(!qv+DHN{1bzU&8)k5TxTzy4l!K3-~=A9zqWUM2oF ztJv7fJ&1;*19pa5rGp0!1OaR)>2?&im-dHt0ewhioK@OJH7s**5;?p3TpiE%p z1rv&WPY%!#Y@Yn`3Kr^o;@p8alxvpS%08(h&dL+{GhOq;M>#5m-LK8%HMzk$VG$<# zmCH24wAMCX@bktzWeDZl&`IXfhg|1U&OHUJ+zoy>M`IHLR(#eP$%L*n;umIZnjyE1 z?IQri39xfqz~%H;Ox_5@G|>{cZ)lGw#Xm)=i_>xxY--YC=xp>tBzH!a1h-xzH`*U+ z|9?@BxgYNUKR}x#&@%S-3^v8jJ~lSbf_DZqkH1lYoD!A0?&FJl_VxsIX>Xoh1WjXo zRtE`X0qFY3e~fk91VZ1v+u=HFJj=P~W{H_IPaP?Fv_cHIYo&1}>LDWs%L=kd5=)oZoT-*Nq>CfFWqSr^Is^8{xUWPgNe^$eI z$8?b!>I-+xC9KDR*pMv#P)Z}4Jr-p-79Su`s^sD><$4$)1MyJC`ySVPfbS1qa6ipBYNwWN-mF&QqOQZ|^VT+-7MEe^HXO@N*| zaaf155sYw7XsCMfPJjd<%~1R9#ICn<5J49z#=#3Av<2$tUw`dP%z-na zUdasI#S@fFvzIyi9oEu#3+KU+J}^(N@Vf^Y?0iBb*I_N3RbSJwQdGus-#&W@kneEF|#V*-x<4W|D&-S%5{aJqabX_>R-GQ@bM6E_i3rz~N_cW$p z6_i z3;7pEx8${2q4HA*DWW5=e5oMjCnb*&4_}k3e3D zJ>XiZ67Z7~N1~&Va@a82m5nKAn{f5eW7k=b?3cD%$S*$;VkBsR&JuDw z^@sxKU)nUJuW6s6ZF2q=B3=VGeL))%V3>=Y$6Ix^F|%7Y+S4OouBStt0XHh(T4kC~ z-AkMr4uVN-jlX^y{y2H4m9H%$8=>vU90804oLb>*AC+WSf`QF~`;>Y+R=K@#YsyJ! zqs~M5x-Bi%dIOqqdg8F;N@%3r%?@f)$8B6w-(;oXZfj{Yeq{InWn$w66Xorophs;j z^IZe+`1^aOe~l<^BPY|xeRP3*TuelJzkym#IZES4+;HT#A`dr8FgdWLm;g4sWkZAn z*dgJT!bD(qOsp`)v3k|cfnvU=%YX_b>nY_dKU~ui1u-R_>mqzDJx}<}Xs<{q*6M(C zz!I>|$ex0#;ipiK(-JF$qO$NFA-E_2tx?pf{X_F^NvH|&$kM&BZMa(zW;Ken$GoqW zT$fQ*L(`{A6UMitv$66Q_J7Jkq|5?5I;1B8Kw{VU=<#C%nD20Dl!=X-l)SVgE(LGC zp{JiZ;LoPb)2~Qk6Umz+`$BRRuph8b`REKd4g)9TkTQTqjB*iy*Qaru29t$I;z&|` zpQ@pq5FgNLXGrU@)#sdgel{i7;qjwvy&O=8Om3g(fKlQpf$1xMjU&TnbXHB6^QH;sZfxaQ0o)*`Ei=e4hcpfiT*6`-n z0-nQyQTpFTE=ZBSM>*YDmJw#yC-IGn+Nix<$xw2Zl8{rae=P6Pe*mX9lh z^;&-{xDbx<3q3+hl=QnnnNfy55J5MFob6lFi_jI@df`V;lty|4-7(d4h*~F%+1l=t z$fp03#$=lbC!~^aa7zgif{>;KzZcTHj_ae?1r-i4C)PC3YGgRByAtK;d@PgtK);cv z*oM6WxwN?sqdB{!R0*(p8eqEa9MR;a7 z@I@qQh*HN7aVAY7Fo5Ft#S1NAL98Hg{Pm-v#8?$!7ReFDO^Q$oKS#+#zzXPIE(U3` z^QN79d(dH*yHxKLWJ^ULQ;zmO3^r+xhQaL61PQdsg#~oyrp8a_GszF%H9f^HsK|ng zj(2T$;}mt zOhsxeoukUTGXj-8T<<;T%GRMZH!ou@$u57aqgPLmb%9Ua1I*DI6JMO}v@A~-2I&%6O zN=BYv5Z}f?aOU=Rf12Iy2~;Ubyzh49K*umPC+|V>p8~(TM*B*blNEsl(K;l2O7S+D zXslq@@Is(Laz*P3>qHI8rU4@A*0?D~Wi_%he(wp4V-jlNx9Teu+>9td+QZXw?JSsa zz#}$ZlzD)2-%khNJH7}&lY3BQx{aTC22qffNgM>K(fG&_^7KqE``C*sflxh%LrlL$ zr=!oBd?c9YSD~#WIELN)a@_*ghhqWECKz&uj^gR3*;&xB+Q;Q)SX5q}_ta>N@3%lH zLe?BtY6UkF>;#3NF#Rp?Q4zNb7igJgv?{tqhstQuxs{fd&f^sv6pBXwPouihJp5kZ z-Z>rU8OT>lvT<5TNvpwfl5bp#Umq9z(SbixWoTG{m5t=n6o)Y(Ps zqOu%{=t{|^wl*(`R?^wtwzvCyf8{OX4*%VZ|ICzMJI_A9=lOm=Z{Pm3rOJ)dY3T5d z_O|WI2Jgec7CG6*E5ER})*GlD4msWyuNcYrL4&)##omj}(?}Y_^^Mc)8_PGd&Cty%PBcEBbgm4BZi;vzR0|@5 z72F%Nrq3dP*fRLT&F7c+r}*rTtWUUS{Cr?a(qTVqA*i1NjuOp_4qk|+YSlB8Nfb7O zm7};^@}Glb^jlYLBR=<5RWnsc7Do!kq?B61{n2j(0Kg&fF-|s3);(Db&BH;eCcpZ$ zO1BWf4wRhKRc29o!XDC!_uOg7*RHN%M__((`&pi~u2iVXZ|JtauOT}R#t;6D_| zp~-B~Yh}ZS4EYXA4u&R%$~olZ7ZxV(?oAh!N ziNO~QVZvcUEKwGeHwOXpM7ni2a;zt{OdXI^p(kfX z(jn(hbwX}*5Xug!k>moy2?ag6G00M5$ni1Vr)1&Df%UF2@(91XR>q(W_T@TW4N%Y@ z1LBFqCd{CkBfOQjg@c)*j5+$7yCzpabas}eaif^6^NHWu3F#>6 z(ne1+lag~2+Iv%mad>9wiIQ&BF#n{oICXByjjO*z6@=^?w1um^w`QmLwFB#J^;zy) zgI*bSM&b1eprUy+-xA0|ZWB6V8tW`ffCL$tH(_j=QGnvtKDQQ$0;N)0Sw?7Ap2ADm znXL;2F0VNBTN7cpq6Qj6%;9VcJo z$`}UiBM|a}Vk}#=pt{=Uc?1n_0u0hQzIb5ZePm?u5pr0F3j<{v@NyC{;=5P=Xh!8| zdGjX1wNCYQ_0^WC4|$JCR#X<9lz22u4B~SaEZ@$g;Gn7}-wllyzFsj0bS3c#XysRA zM_G|6g8GiQH~?JTU~$M_)5>Wr;Ls$v_FA)cbK**>1Kovi8xr2hM*1(VKis-@@W)_x z403)0$q5?~ZIm?z!~RE;KabSc%Thtdk`(!vZ<;5rBR#e@ z0Z2%H0BA3tD2>z|L&5xd$>v@lu_bnpvWyA;K~GkvQnt+{Hxay@cH^0LaMe(>gDpwP zQn!YOriI%rH=X`FmX*RQqNd!O)1FJ8F~OeM`x zfc!H9mmOl$cQZjpdL&s&v7_aeTD2pvxhb-O=0+!^3@<9=5(gDF+H~`^^<8YB23K;f4TNq zA<F`Rs@b{uNQ z1@)<7h)5kMy4h=lpU;DkEBjlom_=xvjPd!Y?`LMV<&g~?f*G_3Ad+`)WfIw^1JTL; zd9{RH@`X<`_u_=14F%b58Fg**V(MCso3bl0WT6un0sDCrJAFc_vR)pLdZ%x{eu>yz zqz?+_8cQ%J9Ql~29V0w#PCvM_OiVG1Oj0amm>~WeGy?bgEcF2Eo!9Znf{eRp9^zve zfagpfw8ENA=r_H4%3{ey3i039_WV*R_Yc%jI=gxZ<7 z47?%4SW=%tgC7S`RzgSa3>t+PSS2nQa@18B&0uRmbzsk=Af_1-$M*VTJI)@$?ZnaF z{>>KKfcQ76Y$;s~%S~fSAp{<7ZZ2_O!=PcI7EIk?VL?ewsNw@Ks1v!0u8?2Rb_5ix z&bPJfU|I;5p-+c{hYXOgGu&`r1|bX#a2ts$61O}U3Oe<=fo3qh133rAQL%uItlcwH z?RJdTY=w*QtNMYWh*9)X{0%xc*wvos)J)$RpZU|Xz4NA&02L`)SXgZI0u>3d7HnYz zgk5oqMKfPRMXpY#Q~W{$Yt-r{qrXg`RCb7^|AEYJI|OsUFxuC(?3OvLE8WT literal 0 HcmV?d00001 From 31a19f580609794409142b2d1ef4363c74542067 Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:57:36 +0300 Subject: [PATCH 24/27] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 975fca5..290b84d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ This is the xls ingester project, it reads data from excele files in a given dir importer_kupot - רשימת כל החברות והמסלולים
importer_reports - linked to kupot - contains the report date and file name
importer_asset_details - linked to reports - contains the details of assets and values.
+![image](https://github.com/hasadna/open-pension-ng/importer-erd1.png) + + ## Setup From 7d2a4dd76a8d0c8be588d95a9f7969ce451e01d8 Mon Sep 17 00:00:00 2001 From: Guy-Galil <63203781+Guy-Galil@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:59:11 +0300 Subject: [PATCH 25/27] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 290b84d..dff3049 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is the xls ingester project, it reads data from excele files in a given dir importer_kupot - רשימת כל החברות והמסלולים
importer_reports - linked to kupot - contains the report date and file name
importer_asset_details - linked to reports - contains the details of assets and values.
-![image](https://github.com/hasadna/open-pension-ng/importer-erd1.png) +![image](https://github.com/hasadna/open-pension-ng/blob/importer/importer-erd1.png) From dbe32dfea74899012dd796112cececa930d52223 Mon Sep 17 00:00:00 2001 From: "guyga@il.ibm.com" Date: Wed, 10 Jul 2024 17:03:53 +0300 Subject: [PATCH 26/27] Add basic UI for testing --- djang/importer/models.py | 66 ++++++++++++++++++++-------------------- djang/importer/views.py | 38 +++++++++++++++++------ 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/djang/importer/models.py b/djang/importer/models.py index 54e791f..11a8b1e 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -23,39 +23,39 @@ class Summary(models.Model): class AssetDetails(models.Model): reports = models.ForeignKey(Reports, on_delete=models.CASCADE) category = models.CharField(max_length=255,null=True) - stock_name = models.CharField(max_length=255) - stock_code = models.CharField(max_length=255,null=True) - issuer_code = models.CharField(max_length=30,null=True) - stock_exchange = models.CharField(max_length=255,null=True) - rating = models.CharField(max_length=10,null=True) - rater = models.CharField(max_length=50,null=True) - purcahse_date = models.DateField(null=True) - average_life_span = models.FloatField(null=True) - currency = models.CharField(max_length=50,null=True) - interest_rate = models.FloatField(null=True) - proceeds = models.FloatField(null=True) - value = models.FloatField(null=True) - exchange_rate = models.FloatField(null=True) - interest_dividend = models.FloatField(null=True) - market_value = models.FloatField(null=True) - percent_of_value = models.FloatField(null=True) - percent_of_asset_channel = models.FloatField(null=True) - percent_of_total_assets = models.FloatField(null=True) - info_provider = models.CharField(max_length=100,null=True) - sector = models.CharField(max_length=100,null=True) - base_asset = models.CharField(max_length=100,null=True) - fair_value = models.FloatField(null=True) - consortium = models.CharField(max_length=4,null=True) - last_valuation_date = models.DateField(null=True) - roi_in_period = models.FloatField(null=True) - estimated_value = models.FloatField(null=True) - address = models.CharField(max_length=255,null=True) - average_interest_rate = models.FloatField(null=True) - asset_type = models.CharField(max_length=255,null=True) - commitment = models.FloatField(null=True) - effective_interest = models.FloatField(null=True) - coordinated_cost = models.FloatField(null=True) - commitment_end_date = models.DateField(null=True) + stock_name = models.CharField(max_length=255, verbose_name="שם ני\"ע") + stock_code = models.CharField(max_length=255,verbose_name="מספר ני\"ע",null=True) + issuer_code = models.CharField(max_length=30,verbose_name="מספר מנפיק",null=True) + stock_exchange = models.CharField(max_length=255,verbose_name="זירת מסחר",null=True) + rating = models.CharField(max_length=10,verbose_name="דירוג",null=True) + rater = models.CharField(max_length=50, verbose_name="שם המדרג",null=True) + purcahse_date = models.DateField(verbose_name="תאריך רכישה",null=True) + average_life_span = models.FloatField(verbose_name="מח\"מ",null=True) + currency = models.CharField(max_length=50,verbose_name="סוג מטבע",null=True) + interest_rate = models.FloatField(verbose_name="שעור ריבית",null=True) + proceeds = models.FloatField(verbose_name="תשואה לפדיון",null=True) + value = models.FloatField(verbose_name="ערך נקוב",null=True) + exchange_rate = models.FloatField(verbose_name="שער",null=True) + interest_dividend = models.FloatField(verbose_name="פדיון ריבית דיבידנד",null=True) + market_value = models.FloatField(verbose_name="שווי שוק",null=True) + percent_of_value = models.FloatField(verbose_name="שעור מערך נקוב",null=True) + percent_of_asset_channel = models.FloatField(verbose_name="שעור מנכסי אפיק ההשקעה",null=True) + percent_of_total_assets = models.FloatField(verbose_name="שעור מנכסי השקעה",null=True) + info_provider = models.CharField(verbose_name="ספק המידע",max_length=100,null=True) + sector = models.CharField(verbose_name="ענף מסחר",max_length=100,null=True) + base_asset = models.CharField(verbose_name="נכס הבסיס",max_length=100,null=True) + fair_value = models.FloatField(verbose_name="שווי הוגן",null=True) + consortium = models.CharField(verbose_name="קונסורציום",max_length=4,null=True) + last_valuation_date = models.DateField(verbose_name="תאריך שערוך אחרון",null=True) + roi_in_period = models.FloatField(verbose_name="שעור תשואה במהלך התקופה",null=True) + estimated_value = models.FloatField(verbose_name="שווי משוערך",null=True) + address = models.CharField(verbose_name="כתובת הנכס",max_length=255,null=True) + average_interest_rate = models.FloatField(verbose_name="שיעור ריבית ממוצע",null=True) + asset_type = models.CharField(verbose_name="אופי הנכס",max_length=255,null=True) + commitment = models.FloatField(verbose_name="סכום ההתחייבות",null=True) + effective_interest = models.FloatField(verbose_name="ריבית אפקטיבית",null=True) + coordinated_cost = models.FloatField(verbose_name="עלות מתואמת",null=True) + commitment_end_date = models.DateField(verbose_name="תאריך סיום ההתחייבות",null=True) class FilesNotIngested(models.Model): file_name = models.CharField(max_length=255) diff --git a/djang/importer/views.py b/djang/importer/views.py index 3b4c0c0..8090bba 100644 --- a/djang/importer/views.py +++ b/djang/importer/views.py @@ -3,12 +3,12 @@ from django.http import HttpResponse root="/" -# Create your views here. + def companies(request): - output = "רשימת החברות:
" + company_list = models.Kupot.objects.values("company").distinct().order_by("company") for k in company_list: - output = output+"" + output = output+"" output = output+"" return HttpResponse(output) @@ -18,7 +18,7 @@ def kupot(request, company_name): company_list = models.Kupot.objects.values().filter(company=company_name) for k in company_list: output = output+"" - output = output+"
" + output = output+"
" return HttpResponse(output) def duchot(request, kupa_id, kupa): @@ -26,13 +26,33 @@ def duchot(request, kupa_id, kupa): report_list = models.Reports.objects.values().filter(kupa_id=kupa_id) for k in report_list: output = output+"" - output = output+"
" + output = output+"
" return HttpResponse(output) -def tabs(request, report_id, report_date): - output = "רשימת טאבים:
" tab_list = models.AssetDetails.objects.values("category").filter(reports_id=report_id).distinct() for k in tab_list: output = output+"" - output = output+"
" + output = output+"
" + return HttpResponse(output) + +def details(request, report_id, tab): + field_list = models.AssetDetails._meta.get_fields() + #list(models.AssetDetails.objects.values().filter(reports_id=report_id).filter(category=tab).values().first().keys()) + output = "" + for f in field_list: + output = output + "" + value_list = models.AssetDetails.objects.values().filter(reports_id=report_id).filter(category=tab).values() + for v in value_list: + output = output+"" + for f in field_list: + if f.name in v and v[f.name] is not None: + output = output + "" + else: + output = output + "" + output = output + "" + output = output+"
"+f.verbose_name+"
"+str(v[f.name])+"

" return HttpResponse(output) \ No newline at end of file From f80c4432194d4312121e3e38c1401fe772573179 Mon Sep 17 00:00:00 2001 From: Moshe Nahmias Date: Mon, 29 Jul 2024 21:21:50 +0300 Subject: [PATCH 27/27] WIP --- djang/importer/models.py | 3 +++ djang/importer/templates/importer/base.html | 10 ++++++++++ djang/importer/templates/importer/index.html | 10 ++++++++++ djang/importer/templates/importer/kupa.html | 7 +++++++ djang/importer/views.py | 10 +++++----- 5 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 djang/importer/templates/importer/base.html create mode 100644 djang/importer/templates/importer/index.html create mode 100644 djang/importer/templates/importer/kupa.html diff --git a/djang/importer/models.py b/djang/importer/models.py index 11a8b1e..54919ea 100644 --- a/djang/importer/models.py +++ b/djang/importer/models.py @@ -6,6 +6,9 @@ class Kupot(models.Model): track_number = models.CharField(max_length=255) track_code = models.CharField(max_length=255, null=True) + def __str__(self): + return f'{self.company} {self.track}' + class Reports(models.Model): kupa = models.ForeignKey(Kupot, on_delete=models.CASCADE) report_date = models.DateField() diff --git a/djang/importer/templates/importer/base.html b/djang/importer/templates/importer/base.html new file mode 100644 index 0000000..a7159b3 --- /dev/null +++ b/djang/importer/templates/importer/base.html @@ -0,0 +1,10 @@ + + + + + Title + + +{% block content %}{% endblock %} + + diff --git a/djang/importer/templates/importer/index.html b/djang/importer/templates/importer/index.html new file mode 100644 index 0000000..26a0762 --- /dev/null +++ b/djang/importer/templates/importer/index.html @@ -0,0 +1,10 @@ +{% extends 'importer/base.html' %} +{% block content %} +רשימת החברות: +
+ +{% endblock %} \ No newline at end of file diff --git a/djang/importer/templates/importer/kupa.html b/djang/importer/templates/importer/kupa.html new file mode 100644 index 0000000..9c4b47b --- /dev/null +++ b/djang/importer/templates/importer/kupa.html @@ -0,0 +1,7 @@ +רשימת מסלולים ל-{{ company_name }}:
+
+ \ No newline at end of file diff --git a/djang/importer/views.py b/djang/importer/views.py index 8090bba..98341be 100644 --- a/djang/importer/views.py +++ b/djang/importer/views.py @@ -4,13 +4,12 @@ root="/" + def companies(request): output = "רשימת החברות:
" - return HttpResponse(output) + return render(request, 'importer/index.html', context={'company_list': company_list}) + def kupot(request, company_name): output = "רשימת מסלולים ל"+company_name+":

" - return HttpResponse(output) + return render(request, 'importer/kupa.html', context={'company_name': company_name, 'company_list': company_list}) + # return HttpResponse(output) def duchot(request, kupa_id, kupa): output = "רשימת דוח\"ות ל"+str(kupa)+":