From 735e75c66b0aeb1ab65fbd5ae49d4874568b0575 Mon Sep 17 00:00:00 2001 From: Michael Chouinard <46358556+chouinar@users.noreply.github.com> Date: Wed, 13 Dec 2023 14:10:16 -0500 Subject: [PATCH] [Issue 821] Create database ERD diagrams from SQLAlchemy models (#824) [Issue 821] Create database ERD diagrams from SQLAlchemy models --------- Co-authored-by: nava-platform-bot --- .github/workflows/ci-erd-diagrams.yml | 43 +++++++++++++ api/Dockerfile | 7 +++ api/Makefile | 16 +++-- api/bin/__init__.py | 0 api/bin/create_erds.py | 59 ++++++++++++++++++ api/poetry.lock | 44 ++++++++++++- api/pyproject.toml | 3 + documentation/api/database/erds/README.md | 17 +++++ .../api/database/erds/full-schema.png | Bin 0 -> 10325 bytes 9 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci-erd-diagrams.yml create mode 100644 api/bin/__init__.py create mode 100755 api/bin/create_erds.py create mode 100644 documentation/api/database/erds/README.md create mode 100644 documentation/api/database/erds/full-schema.png diff --git a/.github/workflows/ci-erd-diagrams.yml b/.github/workflows/ci-erd-diagrams.yml new file mode 100644 index 000000000..f7b8bb5ba --- /dev/null +++ b/.github/workflows/ci-erd-diagrams.yml @@ -0,0 +1,43 @@ +# Update database ERD diagrams so that they remain up to date with the application +name: Update Database ERD Diagrams + +on: + pull_request: + paths: + - api/src/db/models/** + - api/bin/create_erds.py + - Makefile + - .github/workflows/ci-erd-diagrams.yml + +defaults: + run: + working-directory: ./api + +# Only trigger one update of the ERD diagrams at a time on the branch. +# If new commits are pushed to the branch, cancel in progress runs and start +# a new one. +concurrency: + group: ${{ github.head_ref }} + cancel-in-progress: true + + +jobs: + update-openapi-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + # Checkout the feature branch associated with the pull request + ref: ${{ github.head_ref }} + + - name: Update OpenAPI spec + run: make create-erds + + - name: Push changes + run: | + git config user.name nava-platform-bot + git config user.email platform-admins@navapbc.com + git add --all + # Commit changes (if no changes then no-op) + git diff-index --quiet HEAD || git commit -m "Update database ERD diagrams" + git push diff --git a/api/Dockerfile b/api/Dockerfile index 865ea8e0a..513944933 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -41,6 +41,13 @@ RUN : "${RUN_USER:?RUN_USER and RUN_UID need to be set and non-empty.}" && \ FROM base AS dev ARG RUN_USER + +# In between ARG RUN_USER and USER ${RUN_USER}, the user is still root +# If there is anything that needs to be ran as root, this is the spot + +# Install graphviz which is used to generate ERD diagrams +RUN apt-get update && apt-get install --no-install-recommends --yes graphviz + USER ${RUN_USER} WORKDIR /api diff --git a/api/Makefile b/api/Makefile index 00abc6ecd..0a83cf894 100644 --- a/api/Makefile +++ b/api/Makefile @@ -164,6 +164,10 @@ db-migrate-heads: ## Show migrations marked as a head db-seed-local: $(PY_RUN_CMD) db-seed-local +create-erds: # Create ERD diagrams for our DB schema + $(PY_RUN_CMD) create-erds + mv bin/*.png ../documentation/api/database/erds + ################################################## # Testing ################################################## @@ -190,22 +194,22 @@ test-coverage-report: ## Open HTML test coverage report ################################################## format: ## Format files - $(PY_RUN_CMD) isort --atomic src tests - $(PY_RUN_CMD) black src tests + $(PY_RUN_CMD) isort --atomic src tests bin + $(PY_RUN_CMD) black src tests bin format-check: ## Check file formatting - $(PY_RUN_CMD) isort --atomic --check-only src tests - $(PY_RUN_CMD) black --check src tests + $(PY_RUN_CMD) isort --atomic --check-only src tests bin + $(PY_RUN_CMD) black --check src tests bin lint: lint-py ## Lint lint-py: lint-flake lint-mypy lint-flake: - $(PY_RUN_CMD) flake8 --format=$(FLAKE8_FORMAT) src tests + $(PY_RUN_CMD) flake8 --format=$(FLAKE8_FORMAT) src tests bin lint-mypy: - $(PY_RUN_CMD) mypy --show-error-codes $(MYPY_FLAGS) src $(MYPY_POSTPROC) + $(PY_RUN_CMD) mypy --show-error-codes $(MYPY_FLAGS) src bin $(MYPY_POSTPROC) lint-security: # https://bandit.readthedocs.io/en/latest/index.html $(PY_RUN_CMD) bandit -c pyproject.toml -r . --number 3 --skip B101 -ll -x ./.venv diff --git a/api/bin/__init__.py b/api/bin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/bin/create_erds.py b/api/bin/create_erds.py new file mode 100755 index 000000000..f292201e0 --- /dev/null +++ b/api/bin/create_erds.py @@ -0,0 +1,59 @@ +# Generate database schema diagrams from our SQLAlchemy models +import codecs +import logging +import os +import pathlib +from typing import Any + +import pydot +import sadisplay + +import src.logging +from src.db.models import opportunity_models + +logger = logging.getLogger(__name__) + +# Construct the path to the folder this file is within +# This gets an absolute path so that where you run the script from won't matter +# and should always resolve to the app/erds folder +ERD_FOLDER = pathlib.Path(__file__).parent.resolve() + +# If we want to generate separate files for more specific groups, we can set that up here +ALL_MODULES = [opportunity_models] + + +def create_erds(modules: Any, file_name: str) -> None: + logger.info("Generating ERD diagrams for %s", file_name) + + items = [] + for module in modules: + items.extend([getattr(module, attr) for attr in dir(module)]) + + description = sadisplay.describe( + items, + show_methods=True, + show_properties=True, + show_indexes=True, + ) + + dot_file_name = ERD_FOLDER / f"{file_name}.dot" + + # We create a temporary .dot file which we then convert to a png + with codecs.open(str(dot_file_name), "w", encoding="utf8") as f: + f.write(sadisplay.dot(description)) + + (graph,) = pydot.graph_from_dot_file(dot_file_name) + + png_file_path = ERD_FOLDER / f"{file_name}.png" + logger.info("Creating ERD diagram at %s", png_file_path) + graph.write_png(png_file_path) + + # remove the temporary .dot file + os.remove(dot_file_name) + + +def main() -> None: + with src.logging.init(__package__): + logger.info("Generating ERD diagrams") + + create_erds(ALL_MODULES, "full-schema") diff --git a/api/poetry.lock b/api/poetry.lock index 080b18eb0..6fb1639d9 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1511,6 +1511,20 @@ files = [ pydantic = ">=2.0.1" python-dotenv = ">=0.21.0" +[[package]] +name = "pydot" +version = "1.4.2" +description = "Python interface to Graphviz's Dot" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pydot-1.4.2-py2.py3-none-any.whl", hash = "sha256:66c98190c65b8d2e2382a441b4c0edfdb4f4c025ef9cb9874de478fb0793a451"}, + {file = "pydot-1.4.2.tar.gz", hash = "sha256:248081a39bcb56784deb018977e428605c1c758f10897a339fce1dd728ff007d"}, +] + +[package.dependencies] +pyparsing = ">=2.1.4" + [[package]] name = "pyflakes" version = "3.1.0" @@ -1536,6 +1550,20 @@ files = [ [package.extras] plugins = ["importlib-metadata"] +[[package]] +name = "pyparsing" +version = "3.1.1" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.6.8" +files = [ + {file = "pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb"}, + {file = "pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "7.4.2" @@ -1752,6 +1780,20 @@ botocore = ">=1.12.36,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] +[[package]] +name = "sadisplay" +version = "0.4.9" +description = "SqlAlchemy schema display script" +optional = false +python-versions = "*" +files = [ + {file = "sadisplay-0.4.9-py2.py3-none-any.whl", hash = "sha256:bf456f582b8f5da19fedef7a9afe969b49231d79724710bc7d35c9439f44c2fc"}, + {file = "sadisplay-0.4.9.tar.gz", hash = "sha256:af67160f89123886ab42b247262862bfcde0a3c236229ecdd59de0a1e8e35d96"}, +] + +[package.dependencies] +SQLAlchemy = ">=0.5" + [[package]] name = "setuptools" version = "68.2.2" @@ -2109,4 +2151,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a3440ea0134e772b0a01f888be1b12e3e881fdc34577dd22a0eb9743e6f8cccb" +content-hash = "1725ab8db18eae6b340e1f4835868d7a866c93b8f5dd67882e7d5a0ee7b0d3ae" diff --git a/api/pyproject.toml b/api/pyproject.toml index c9d93f285..ef4ca36ac 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -40,6 +40,8 @@ pytest-watch = "^4.2.0" pytest-lazy-fixture = "^0.6.3" types-pyyaml = "^6.0.12.11" setuptools = "^68.2.2" +pydot = "1.4.2" +sadisplay = "0.4.9" [build-system] requires = ["poetry-core>=1.0.0"] @@ -50,6 +52,7 @@ db-migrate = "src.db.migrations.run:up" db-migrate-down = "src.db.migrations.run:down" db-migrate-down-all = "src.db.migrations.run:downall" db-seed-local = "tests.lib.seed_local_db:seed_local_db" +create-erds = "bin.create_erds:main" [tool.black] line-length = 100 diff --git a/documentation/api/database/erds/README.md b/documentation/api/database/erds/README.md new file mode 100644 index 000000000..8e5f0c3f1 --- /dev/null +++ b/documentation/api/database/erds/README.md @@ -0,0 +1,17 @@ +# Overview +This folder contains ERD diagrams representing our database schema for both our postgres DB + +Diagrams can be manually generated by running `make create-erds` from the api folder. + +# Dependencies +If running outside of Docker, you must install `graphviz` (`brew install graphviz`) for this to work, this should be automatically installed as part of the Dockerfile inside Docker. + +# Caveats +The diagrams generated are based on our SQLAlchemy models, and not the database itself, so there are a few differences. + +* Fields that we name different in-code will have a different name +* The table names use the class name +* Property fields are SQLAlchemy only and generally represent relationships (ie. values fetched via a foreign key `join`) + +# Files +![Postgres ERD](full-schema.png) \ No newline at end of file diff --git a/documentation/api/database/erds/full-schema.png b/documentation/api/database/erds/full-schema.png new file mode 100644 index 0000000000000000000000000000000000000000..02528cd69bd4b5a4cc1bb8e2707f51d04c2219fc GIT binary patch literal 10325 zcmZ{K1yt4Fw)G(-l#(t9k#3NZZb_9!ngd8kcZYNeNOy~r2axXWP`bNYI={{Tz448A z-+dPb{0@h4cCIzooOADx_lnZ!Pl=yGAP{t!w-QR=xe9&;QINoIS-S4We;^skNlQQ; z9=|gi3t}M<3W$t^sESMKewwSc%J{>BUOV=#>1Xy#Iu<6DOdqC~$#1O^r&p?(WIl{U z(GcgG<>%J;hsuS-Jqz7X5=%@pt-HDo1Hwxw>19vPCipXsvCzxpFyj z;k6%f&A4bhNq7I0xXCBbMTj7ZHA5pRJxhdzcudM5<+p^Q5{}sV{5v74AJuzyY((iy z!&fMgBAsusNPR=2DR|-Ws6=fL8@qyBW-hT1NB2gb=#P9yiD2m}r}gIIMihPR?r=7! zZ@Rbeoe;d)7uwbpND*u@%s67{faW&*udOghiP1a>pk^azz%27xo>9yj7wMdWNW`gL zx^GnS!EEU*#nhE@bNd4Qgv2TCUYkGvyUafB5&@1NNqC;e7;g;44(aN|J&_U3D56@Bq&kpn+e)o<+|{`L`cXr ztxY*xNT-J(F)?=9H7?3#H_KFJSgLBvgCeXNn!h@O4x2H)sGlC+kQ01R)_ z&+*7AD!Od`shjR-`pLq#70$|L+YHmwQ^#oY`#m*=b9L#FjkCx}>t8x1hSsLsW_@#W zf9Hi0RX(I+c(5wAyW>wFHv3(kDi05B5T9^)N=T;jAZ(P)+KyGk?OwakS_6~;;j-_!NH}*oreH)0)tM% zq{!Xp`5;RrJf0L-iIm(2n2?ql7+gep!7nk80xO2X?R!aw28;L2OH-V1 z(G8Ugw1wp15q|X-4h`2oP^&(p1=SMinYmB0R9%{NYbPcXw+`~7$>n&pT}Cao@Dx4J z@)Z0mq%trYMQRi*Xf4v?l~;(sB6^P4MVP8X3vGQlefVTN>wpxJSymPnvKj<|xSemI z@PzOqhzYtx=ycR_vN2zmybzc2h{64=hcWJxH?mxT`{A)Cp{eG3F zGA&ZgrE}ydjU_TSjRgE%TC^#yB1`Oh?OR;(+eJwD-H6`o_G^o+zPDJ!A~@<&{|*KJ zf;_LU55r9+bd!6&P$ctpMMR3I{}(b1q6rFKAAFL_lL16Eb?X~Emf9;p9OQ52z z&hsWRD~pPOK_ViOo~WSKz-&8kV1Q)B+ufxBwU^Zu710x9knqy+p7A{z>7}pB=lQDL z$&9dBRn>8g$}JLyZ4L+$5{8Z4N%!s5C>=Ai@|&tEg?DAoNJ)_lFAj2P1j%k^anPk}m%ojZmzO%5LEC52HxBUiPM%?HZOzfCq{zz3IIpN*d;ndRWUnBpl_W=@ z;+RkgXOI-F@R+gF(2%vUHj}E%u<~8;);W74cz9u=5uZ|8E#imHx(K)zYuCbYbEj{O z^T=L;?DAPCqHsI|6W+=+vKGVUiI|#1?J_Y)L1(@a-pHW$m=TAsee*c zrE5Alq-8!d!#I;-t@tgJ=sAp-(}W58qJNOtM`Q%~T}FJr0C|jhSEIXkP@|{t)Rc?i z_zwlyVd_bZhD%NfiQ?K*|asTe-XK@bt8#5@h2>VZS;#&29AzG-oAY+Z8gX1>wZDTFc@KhMz|DFs>5&CkAJ3~Y;Q}% z&@@==S(cjag{M>JAaQffZ5|buo1c#)BP0D@Q}c;*x1#yx1TL5*I-0Jiy!?-P$`hW8222w6$8uMP=Uo)cX>Nc`_T#6iB(FBLzqM4Z`tsFzJp0gu(G@&A4 z9$=D_^P~!<xb-dQByzwQ6R<(pUX=l zwyvL!76SPSBn1xXoS#@)hlg?C;O<89Bk1v&&C{Pfm6jar{GG?XLO`V&-7sEU>IEA~ zSUVhr7r6?^e{iC@!5rT!! zO_svF-xW<&xV113cYeMfw=t!(HC{x5gmxq*KTsvrZD+shJ6j##azud)Sz291>hcec zyjNN$BqH+8EI;9%DANlM4j#t}F)G76EjNoX92jWu^?qoKDr-*vLc~e4GzI(Pa$BQe zqNk^anJVl}6oiqNSy_n|9lh8x-&mjmW6~_gpr$tFb$gAsIiwR96;=NjJF>s4nDuU( ze1~~_o0`ZXU+_o#{>>pF={cEcPeFtOxjwa(1ZlG|6www=grgF=yu0f=Huj9yY45eH zY~N4Q4}W#=^m}78H7aP-_KTo8q@-__8!rN(r(1=*&IcUnLd?=1MsZLAG-U}cVAJqdrA}2^4D)gk)yg z@baOzS(IsVZ;_5gphA@M5wie0xxe>R4W-1=pyYOXA||%cGHOfl#^I;sdlv0X`%Mf& z!8cf)op}sV(H@K4tJ9B}3DKXe8h2Sm7X~YU#L21d;?fYZvt#aYcSf2A-7PolBnXaN z@RyD{A#pP$&Kb=Ywo&Iy>hbNaK!%F_nym?Zs}fSEyxBfEiI9~AtxUtnvJHpR;G%&f z-LoQOu|4bR8mjRYCRy!jEZBEIiEO)KgvwDfGLkqsoms1kiwTK*-h0YyB=I`3OOMFR zk(YV2sZpV#wv$E|!y!fXU-;?26ItS{Y_TzY_vXw@l>OP8B~Uz8T%Hk>cJ@S5%0jDN zZ5@XDOMU-!0k?Fr?#=djig#^IyLgh7Y8CTzZ(HT?>TH*Qir0nv_wN{6P(y;l!ld1< zMKv@M=_96~F)}IaW>Go7FJZc<5YB($vpo3tP%6{N!B10)?=akt~F-vG#EN@q=Acu#C`)_%b=g zAxSnG3B{o&HT4%AC+mv{E>=hH(giSWZHsm^4*~+B*d2E~KF?ipg@%@bO;t^Nz5T3k zCE9p(bCbYu?7K{&kRV||fcF=6N@W4 zKF1g~xc=4iIO**Mkl(e`dN-Q&g}J=K-uPUn#)f(3*I%g=Zb6uX85*cH=u1~mAqYq= z?TgLUg8hPAXYi%kwHVHTFSFVBBuvr?xP1NM{ouKb*;ju2E~Cj`x3P|rZtDd|{LmZj zu@2fX>1f{!kR)|=bu~82PZ=1CD!*l&o5mPA*zNcoHZS@Xyz93;HDx%McSj*fncO+! z2f1>-cZFV{*eU+1E+jX1aD|!)m*lRc#aBqF$eW$!n;YxP{;p5qy?F1nDnf>zkO{L$ znQm$C%~qqbuqXzHe{Oo|C=MV-yV;BV{&E$!xa4N3pEU<;4$sJFp|C0_9iXs{%L{}SL!k-D210bn)rf&)6v(4{@sinsdsix zy1Tu*X&Wp-e&cx3{*_Yn=-3q|ksUE;@fa83Bs@5gkq8D>3;33^CvcGX_zhkgea0%w zn#Pk_ zkl>Jz&oQn&9kewf6*U~#h!FF8r9L%q*i~e z_KL2kV%){){7Fm{7h-Sc$>;QkDL7-AyfwwdejLb^f`jUB6rX|HO`C~GzP?O+1a z>d_8pZ>>iyO-(8OtLOT0UXc?R4MnYnP>EEQd2y!v340cy()VxKsG>=0&=unm^U`p{ z(aMpyXo`<$|F_ou?o+2{L=&8!+ewg9C9yU^BT~db@X2HW>tS}Nl-1nayn~(l^VAcv zVY1yxYl4etYAS@5gL#>)31$cl&HPB&qC2naNvbR#E-r3sOAFlca#-GpIxV^9T`3^9 z(g1{L6w?y%^};*)v(#8j{*|cO%NHm*@!BlaTpn)>3TNpx3n_WNV#a(Vt_Sb{qqDN? zVI~TQCx8DuJ`ADWYV<*A{N9rKnZh8^N*2`jRR#+hZr{W+-;l0$c?SCVWlN>;$nY`} z$n`Zoe_n+5v?Cci`r_bDM^-{D@XcTBvm_2_Cy>W7SzN-bT5e!xcXBjiflnyp zK@lE~w!A-2!tYe9YBp3;gE)}H`NZ?O#_-{u3cXMI-8;-2Y2>z%5fSruBVBEpwS+E| z7elE;*)|1+3c)>me6A?yX<^J-Y7;oo>sdPu`T6AvUne3#ih?dEa7_%_1Je>Yj6XLD zl^Yqod#7Gg!%Ic6L{+SDjG&kQeDRH_eC1IJK^%{FPu+uGV$$6 zzv#CF^~XwSPF^hlCWyegrfbZPXCWpb1Yqa(R^U0-NLhL?6Q=4W^%|$y?;ubdg8>yY z=m-`)n=$U~0WfyCU(F1%#Xm0Y$wX;{WV6>{h`QldX>DzIzb_A%5H`Ctk{Xxer(^;> z9~j9A60!hLb~Ly$!YpUaBUsSeWNK^TKUTkw7AdBGuBkJYl<@W4`w{*r7?f4$+7#Ep z(Q%x3wy_?8M$v=(=G;=s)|MWF{9f5gFZw%ny)&)BLId6-Am}goG&G2PUQGY4CiKX$ z>aH=Y=4D$G*{R0+)QM8Rh3#g)rI^SKPEB@$>yAv5l93_8XI9$d{=vygNiMXA*x9KL zv&lN)kN&8b(G+}i)LG+ohXHihBf)Y$6!HFA_>{f1nj zRi;)$P1qG4mM>xC+wv3!W5mMdKnCaqYDh^NROOTIHKbs+xPo-zN7O4W*$XtZNIV2=a7-+W?GOMHalZiG=fN~lmVd>ui@!c5JwF&4OPU{ z)T#!o9sC1_#?5sl`oF|qsEYx@zGA$KnoXa@_-JYUJ7qwWHf8Uede8We(R6K&MDs2^ z2&gmlLY+81YIVf#lF{>e@8A1hx(u>pjJ~h5x}MY}Vq`30bJ{B~{gaOj6ywzFg)(o@ zOUC8{2Dp>(TVDj>maD(s?Ssr%jqaGV;(G04=0+PEWh>{Fl-M|!l$6WvfPZ`$=4ss; z_0_1PBbAFUR-RcP#GSqiBk&FcbUd|&Sd~x@{;W%b8)Gm5WJ&c(G!vgYzq9&#XM7eAv&C{_?Yg1v5x*K5}c{7j^5cl zJPd1oXbJ>0jqbC$$w~igx^~jMu)YsZozs3WvsP}#B+^H&J8D~-Er0L(+JLLSImi91 z$h%j8G2j2`67n_rp}erscwnf>r>(7XY2 zKIc%zA|^7j`|ahbF4!lFvED)*YLTR9JpztXt{@Tp0O!RQfmG7>{wZ3Rb4|>}>L~Q| z-dSLOw*GWW8r0VnK_EY7?;714+kN14gF~kC&q zF?uKzeZi>r-%U#c>Uh4-kM?P^)CNqXdXX#?QwT-eWCQ)%w{HfA3yUz*QJzF$VRfn+Wj zD{UF7#i1c2>n0+f)6*imn@Qs9cj@0UbJrLetv^FpSj6EVCMK5bE;ioBv%&kb>d;m{ zRYHz6DA1H`qcF~<2QPZ5C=8WC9S$dENlA4hA@7xrVe{#`G=Aq65Db?G_2gVONW@4< z_lQ}~droPC@evBbqM6*YxmwNsWc>5o1{qdK^KYK=R3AW}Uup1^L27#8$uS|+)Y}tu zbVYR$HB6{A|ADCU-W5b_NJw@nSQccenpYno`Sd3|wEi?$1q>Lb+Z-M)Y-H71?R95} zh&(Ly@goO|-aWFd?eW`p!gLD{?>`L0`%SE`tgWqXE!?3WT?w+8js(?sYSCG(0>%x9 z4e&Oym1%|dW}P+ixQ*E@I-z94y0SDwkH*P**nIAfq9Oy+Ejliz{TXIJcEswP7L@EK zbcuGK_JQKY4()nbXitT88eJ6Jo3eVmYM58c48$g|W`0)ehT+iOi)l=$vvKW5rV9ow zbwzl0dakGONNN>N;#F}C&RS#o78P+eT^a(*DzkF=(~H6*&@&E4C4#8Ex6x!6y1#=xomC0Zys_@` zmseL$P*t5b2}jlZFp6hlU_fJ4^rLPN4NdpCFj7ljzjZJ<2@z3wU*9GaSSrt}Wx9;y zd%B{$qbz5uk_Fw*$?YBXI__^+RX1FfUkAOZMGYv(yUx$QZ}adJ#7(aTBpOYmG2<~r z(s?9>x3`g#e*H3T++=1N=r}gIBjqcP)znCYkqMF}y>*bwOPssvHI8_tyE_|P^dWLY}d}F-|>gv)AQuDBw243sFYK;T|>*Ng(8=x9>HhHEjt*rR>yzS(du9g67^yb=a z>GrCh*MaUyUY;@Vf{+dl1oQ6yBrs_P#l&!s3MPlzZ?XUzOvfOFft@<~Tga6M&erbk zGB7=WXDO~S=8XR49twXHRJ;rdNzxaTn zz(A&e!XGS`z@h6#HX~7F2Ln*2Q{U8CtSM)ExFGK0de%0S_PZ>Yo4c&Fb=i@Sm{@;z z!c;>uiP2ISUpc0ZwfYA57)THxtN&Yc*lz!ZYd8sQ zlCoc7NR1D36>zVe2+Za#1qVQepw1tior#!k5n2|AzeasUU{CgM76L~5_J6Pt(t(Ar zO~U)pC)Z32|3A0~gM^mWujNeR0XS*LhV4dLvi#=g?TiSzugs!MmSwT}??2ZJF?u`8 zofP8Tgg833*%kEpP#CY;At9v(AulM)paOTUJVPtP-C>Z->ekkF9<69P*+C+OhSaw?M`Y$srvfa zL$a9j33rJhgN_*4XTJ?YKd4q3_uEhZo<#hSe4muW)A3MMvCDWSd4o>k1e~Mp>$BNT zPookv2m~i1jNceh&8@XFU{lhtiG+cmmFYRGOwfI zM;&%-1@V&Y;Y2Ku>;{&W^j@hk<o0>&mk+aB&3&Syqcb0K zjRm#7nL!KMUvUM*&5QH%JALSC2r%JgI`L4heSig)v4}t=B`*TXJ!G1u@uj%#C~5io zPrC3xfBcL7i966i6-fB4=ROGE9)#&19ZlKL=nn0U{KYUx1e_N>`~1vzDC=`W_i`u$50#C zP|jxrRMC%Ox)I{XWKvWl^KWDNJoOVtIX}E)QhK5flAc~_V{0oV(jxt6Mx_e4Djt~p zjLFS2&dwI|e{_LJ1&Fn(yNc2O9^@y*$oZwfE&EOsaelb{iBCxG=2MNNZT(X zgBBkCI!l2>nd#%J#^SEF0E^MmG@hBt?T|bi5|@y+ z?nvTHU^DE@b#tI7}N@}oK>@MNFysrzWK?5GGxWT(89 zoniX*Ixv^HOh#Nc%{7Do@k!&C7yRcdIZ;xg0Pjm+^Y#4tD;*cWG%>*?NLV>q7G6Kc z3t@n{J7a?m;D8thhetjAj2-9uA?E9`(Z|R?iVl}1N^rx8IF(8~;{I8S3f=Uk z+$b><64XV2j|5#ah0*(Vh!^#Yo#nzq4 zex(X|J_q97_U`%xkf=6~#@B);#8;$=EBc!Q;n0vyuCBwo^l+di#d7|R;sUM=xBgFr zLZRvuuZ%Dx{)-{bEiN{*&ds@H0^&0@J-sx@gYY%|zAd!OqQ9SW?Fl?%t$mN!yz91S zj(N{VqL$$@Ou*8Q59%M#OH@ZEaCep1Miz)ZX6-eo$8aqFgY< zBXh9RW|jfto4%E>2$J{qGiA4_Bdm2%9q8+eze0}!!TND9%=G^2b{UP)HjcuRf-+!_ zB#*Sj!Jw<+ScsyJCx1`>Z=d9U-zNCy2vNa5J$;Xr$F}t{Awf%=?e%L3$$*u07L;)y z1AWrdb;ERbaZSlO9rkCqTwGm&KLS};Sg3K_jc>bWR4ax=b&H#u{}?#$=#b#$<7@5d zL4kvV1LtPup_Su-p|CjR4K@z!DR#M7lvobWyo?HEj2+@KWJ^@_~EG+1z zUYxA=PoHl8>AWKobn7TCDbdu_OuFQYe%m2*y+zfv`IiVX0fS20rZlX+k5BRSg=1u8 zRVUQa9g11SThHu+G3z!Y1_T9#k#BBpa@yP511`*E^7%7UdQL+_DiH}u#opeY+h$c= zT{1c;@7MWS`yf6(KA7;sZG~^oSgR1u&~`T&)yD;UxegB9zD9A zXR1!Om$v6?y*S*S*AB<5vS8ut9UYC$%*^CpVZ;7z4p)HwLLLy{eg+4ls3|G6#l*%Y zrKP3ytu|e*JX8Dlks(gb%#8kVaZ&(QUh;vHKs8m>yw_GvPTZWFoKWc~vJz)fN4S=@ z_I6EMTXuerOSZ@NYinzngoQIu5#i5Ix0So5{^~7FSA3F-xZAH@Ol)oz2FegNXaDhn z_uIOfmVBsTf4`<(LpG@jUsb*HA&0LBh0x;eLWj;}nN{%$BFdFs79{^&W}gIgTXN!Z@vi_l6Kd%ZnfQBV`L=KcbFfs*q5b>#3S z0%Rza?+a*fX{=BgD@5Y0tgM*oc-S35?xcn)Sv72vf{6(Y47@FxN_wQx-Fa$uR_@Hv z&aUn1@8orD<=nmEGmeE$12nN;eM*hQmfHb z==Ml_uGSve*w~nm(*zA@MB|OWzg?%-S!MPu&1GeuGP1CkWO!W{UaIo5vPNls{8&CU zrBSH9{c$HBWN2GofB!hK^Xb-yi5zLn_>`3XgZcXI+uK_W5fPu7`L;Gcon|i`GohGj zzs}B1hQ)j`K0Aq>_$c!-dA_*ahc;HKz*QR#VPRpA{sRy; z92^_~8ip#Lv=tQ4pFMqQNJB$2Fg=|#m@cTbLqNyC5c&($;aj-K?3x`fAE>3};2Wns zx-$b5Xt?EkUEJB;wDQIjx!0vHoW1~A)b!NUPvidhLI=_x9zIn6Y*{yBzvwh<92*6Tj~mvvubJx8P!WA+}z&8#B$Xi>e!3E!Zz3$D+EMH($w^O)t~3>tH&xc zA4Q*Q3vy#*-8m`)d+XJ==hQIugs2@oSBAz1~9MaW~7TJ;Cq9J!i$ zjr!@)